diff --git a/src/libs/subsonic/CMakeLists.txt b/src/libs/subsonic/CMakeLists.txt index 072b50ec..4403232a 100644 --- a/src/libs/subsonic/CMakeLists.txt +++ b/src/libs/subsonic/CMakeLists.txt @@ -2,6 +2,7 @@ add_library(lmssubsonic SHARED impl/entrypoints/AlbumSongLists.cpp impl/entrypoints/Bookmarks.cpp + impl/entrypoints/Browsing.cpp impl/entrypoints/MediaAnnotation.cpp impl/entrypoints/MediaLibraryScanning.cpp impl/entrypoints/MediaRetrieval.cpp @@ -11,6 +12,7 @@ add_library(lmssubsonic SHARED impl/responses/Album.cpp impl/responses/Artist.cpp impl/responses/Bookmark.cpp + impl/responses/Genre.cpp impl/responses/Playlist.cpp impl/responses/Song.cpp impl/responses/User.cpp diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index 1d437a16..4dab94ac 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -20,33 +20,22 @@ #include "SubsonicResource.hpp" #include -#include -#include #include -#include - #include "services/auth/IPasswordService.hpp" #include "services/auth/IEnvService.hpp" -#include "services/database/Artist.hpp" -#include "services/database/Cluster.hpp" #include "services/database/Db.hpp" -#include "services/database/Release.hpp" #include "services/database/Session.hpp" -#include "services/database/Track.hpp" -#include "services/database/TrackBookmark.hpp" -#include "services/database/TrackList.hpp" #include "services/database/User.hpp" -#include "services/recommendation/IRecommendationService.hpp" -#include "services/scrobbling/IScrobblingService.hpp" +#include "utils/EnumSet.hpp" #include "utils/IConfig.hpp" #include "utils/Logger.hpp" -#include "utils/Random.hpp" #include "utils/Service.hpp" #include "utils/String.hpp" #include "utils/Utils.hpp" #include "entrypoints/AlbumSongLists.hpp" +#include "entrypoints/Browsing.hpp" #include "entrypoints/Bookmarks.hpp" #include "entrypoints/MediaAnnotation.hpp" #include "entrypoints/MediaLibraryScanning.hpp" @@ -54,9 +43,6 @@ #include "entrypoints/Playlists.hpp" #include "entrypoints/Searching.hpp" #include "entrypoints/UserManagement.hpp" -#include "responses/Artist.hpp" -#include "responses/Album.hpp" -#include "responses/Song.hpp" #include "ParameterParsing.hpp" #include "ProtocolVersion.hpp" #include "RequestContext.hpp" @@ -64,14 +50,8 @@ #include "SubsonicResponse.hpp" #include "Utils.hpp" - using namespace Database; -static const std::string_view genreClusterName {"GENRE"}; -static const std::string_view reportedStarredDate {"2000-01-01T00:00:00"}; -static const std::string_view reportedDummyDate {"2000-01-01T00:00:00"}; -static const unsigned long long reportedDummyDateULong {946684800000ULL}; // 2000-01-01T00:00:00 UTC - namespace API::Subsonic { @@ -81,14 +61,6 @@ createSubsonicResource(Database::Db& db) return std::make_unique(db); } - -static -std::string -makeNameFilesystemCompatible(const std::string& name) -{ - return StringUtils::replaceInString(name, "/", "_"); -} - static std::unordered_map readConfigProtocolVersions() @@ -157,19 +129,6 @@ checkUserTypeIsAllowed(RequestContext& context, EnumSet allo throw UserNotAuthorizedError {}; } -static -Response::Node -clusterToResponseNode(const Cluster::pointer& cluster) -{ - Response::Node clusterNode; - - clusterNode.setValue(cluster->getName()); - clusterNode.setAttribute("songCount", cluster->getTracksCount()); - clusterNode.setAttribute("albumCount", cluster->getReleasesCount()); - - return clusterNode; -} - static Response handlePingRequest(RequestContext& context) @@ -177,8 +136,6 @@ handlePingRequest(RequestContext& context) return Response::createOkResponse(context.serverProtocolVersion); } - - static Response handleGetLicenseRequest(RequestContext& context) @@ -193,458 +150,6 @@ handleGetLicenseRequest(RequestContext& context) return response; } - -static -Response -handleGetAlbumRequest(RequestContext& context) -{ - // Mandatory params - ReleaseId id {getMandatoryParameterAs(context.parameters, "id")}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - Release::pointer release {Release::find(context.dbSession, id)}; - if (!release) - throw RequestedDataNotFoundError {}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node albumNode {createAlbumNode(release, context.dbSession, user, true /* id3 */)}; - - const auto tracks {Track::find(context.dbSession, Track::FindParameters {}.setRelease(id).setSortMethod(TrackSortMethod::Release))}; - for (const TrackId trackId : tracks.results) - { - const Track::pointer track {Track::find(context.dbSession, trackId)}; - albumNode.addArrayChild("song", createSongNode(track, context.dbSession, user)); - } - - response.addNode("album", std::move(albumNode)); - - return response; -} - -static -Response -handleGetSongRequest(RequestContext& context) -{ - // Mandatory params - TrackId id {getMandatoryParameterAs(context.parameters, "id")}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - const Track::pointer track {Track::find(context.dbSession, id)}; - if (!track) - throw RequestedDataNotFoundError {}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - response.addNode("song", createSongNode(track, context.dbSession, user)); - - return response; -} - -static -Response -handleGetArtistRequest(RequestContext& context) -{ - // Mandatory params - ArtistId id {getMandatoryParameterAs(context.parameters, "id")}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - const Artist::pointer artist {Artist::find(context.dbSession, id)}; - if (!artist) - throw RequestedDataNotFoundError {}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node artistNode {createArtistNode(artist, context.dbSession, user, true /* id3 */)}; - - const auto releases {Release::find(context.dbSession, Release::FindParameters {}.setArtist(artist->getId()))}; - for (const ReleaseId releaseId : releases.results) - { - const Release::pointer release {Release::find(context.dbSession, releaseId)}; - artistNode.addArrayChild("album", createAlbumNode(release, context.dbSession, user, true /* id3 */)); - } - - response.addNode("artist", std::move(artistNode)); - - return response; -} - -static -Response -handleGetArtistInfoRequestCommon(RequestContext& context, bool id3) -{ - // Mandatory params - ArtistId id {getMandatoryParameterAs(context.parameters, "id")}; - - // Optional params - std::size_t count {getParameterAs(context.parameters, "count").value_or(20)}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node& artistInfoNode {response.createNode(id3 ? "artistInfo2" : "artistInfo")}; - - { - auto transaction {context.dbSession.createSharedTransaction()}; - - const Artist::pointer artist {Artist::find(context.dbSession, id)}; - if (!artist) - throw RequestedDataNotFoundError {}; - - std::optional artistMBID {artist->getMBID()}; - if (artistMBID) - artistInfoNode.createChild("musicBrainzId").setValue(artistMBID->getAsString()); - } - - auto similarArtistsId {Service::get()->getSimilarArtists(id, {TrackArtistLinkType::Artist, TrackArtistLinkType::ReleaseArtist}, count)}; - - { - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - for (const ArtistId similarArtistId : similarArtistsId) - { - const Artist::pointer similarArtist {Artist::find(context.dbSession, similarArtistId)}; - if (similarArtist) - artistInfoNode.addArrayChild("similarArtist", createArtistNode(similarArtist, context.dbSession, user, id3)); - } - } - - return response; -} - -static -Response -handleGetArtistInfoRequest(RequestContext& context) -{ - return handleGetArtistInfoRequestCommon(context, false /* no id3 */); -} - -static -Response -handleGetArtistInfo2Request(RequestContext& context) -{ - return handleGetArtistInfoRequestCommon(context, true /* id3 */); -} - -static -Response -handleGetMusicDirectoryRequest(RequestContext& context) -{ - // Mandatory params - const auto artistId {getParameterAs(context.parameters, "id")}; - const auto releaseId {getParameterAs(context.parameters, "id")}; - const auto root {getParameterAs(context.parameters, "id")}; - - if (!root && !artistId && !releaseId) - throw BadParameterGenericError {"id"}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node& directoryNode {response.createNode("directory")}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - if (root) - { - directoryNode.setAttribute("id", idToString(RootId {})); - directoryNode.setAttribute("name", "Music"); - - auto rootArtistIds {Artist::find(context.dbSession, Artist::FindParameters {}.setSortMethod(ArtistSortMethod::BySortName))}; - for (const ArtistId rootArtistId : rootArtistIds.results) - { - const Artist::pointer artist {Artist::find(context.dbSession, rootArtistId)}; - directoryNode.addArrayChild("child", createArtistNode(artist, context.dbSession, user, false /* no id3 */)); - } - } - else if (artistId) - { - directoryNode.setAttribute("id", idToString(*artistId)); - - auto artist {Artist::find(context.dbSession, *artistId)}; - if (!artist) - throw RequestedDataNotFoundError {}; - - directoryNode.setAttribute("name", makeNameFilesystemCompatible(artist->getName())); - - const auto artistReleases {Release::find(context.dbSession, Release::FindParameters {}.setArtist(*artistId))}; - for (const ReleaseId artistReleaseId : artistReleases.results) - { - const Release::pointer release {Release::find(context.dbSession, artistReleaseId)}; - directoryNode.addArrayChild("child", createAlbumNode(release, context.dbSession, user, false /* no id3 */)); - } - } - else if (releaseId) - { - directoryNode.setAttribute("id", idToString(*releaseId)); - - auto release {Release::find(context.dbSession, *releaseId)}; - if (!release) - throw RequestedDataNotFoundError {}; - - directoryNode.setAttribute("name", makeNameFilesystemCompatible(release->getName())); - - const auto tracks {Track::find(context.dbSession, Track::FindParameters {}.setRelease(*releaseId).setSortMethod(TrackSortMethod::Release))}; - for (const TrackId trackId : tracks.results) - { - const Track::pointer track {Track::find(context.dbSession, trackId)}; - directoryNode.addArrayChild("child", createSongNode(track, context.dbSession, user)); - } - } - else - throw BadParameterGenericError {"id"}; - - return response; -} - -static -Response -handleGetMusicFoldersRequest(RequestContext& context) -{ - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node& musicFoldersNode {response.createNode("musicFolders")}; - - Response::Node& musicFolderNode {musicFoldersNode.createArrayChild("musicFolder")}; - musicFolderNode.setAttribute("id", "0"); - musicFolderNode.setAttribute("name", "Music"); - - return response; -} - -static -Response -handleGetArtistsRequestCommon(RequestContext& context, bool id3) -{ - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - - Response::Node& artistsNode {response.createNode(id3 ? "artists" : "indexes")}; - artistsNode.setAttribute("ignoredArticles", ""); - artistsNode.setAttribute("lastModified", reportedDummyDateULong); - - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - Artist::FindParameters parameters; - parameters.setSortMethod(ArtistSortMethod::BySortName); - switch (user->getSubsonicArtistListMode()) - { - case SubsonicArtistListMode::AllArtists: - break; - case SubsonicArtistListMode::ReleaseArtists: - parameters.setLinkType(TrackArtistLinkType::ReleaseArtist); - break; - case SubsonicArtistListMode::TrackArtists: - parameters.setLinkType(TrackArtistLinkType::Artist); - break; - } - - std::map> artistsSortedByFirstChar; - const RangeResults artists {Artist::find(context.dbSession, parameters)}; - for (const ArtistId artistId : artists.results) - { - const Artist::pointer artist {Artist::find(context.dbSession, artistId)}; - const std::string& sortName {artist->getSortName()}; - - char sortChar; - if (sortName.empty() || !std::isalpha(sortName[0])) - sortChar = '?'; - else - sortChar = std::toupper(sortName[0]); - - artistsSortedByFirstChar[sortChar].push_back(artist); - } - - - for (const auto& [sortChar, artists] : artistsSortedByFirstChar) - { - Response::Node& indexNode {artistsNode.createArrayChild("index")}; - indexNode.setAttribute("name", std::string {sortChar}); - - for (const Artist::pointer& artist :artists) - indexNode.addArrayChild("artist", createArtistNode(artist, context.dbSession, user, id3)); - } - - return response; -} - -static -Response -handleGetIndexesRequest(RequestContext& context) -{ - return handleGetArtistsRequestCommon(context, false /* no id3 */); -} - -static -Response -handleGetGenresRequest(RequestContext& context) -{ - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - - Response::Node& genresNode {response.createNode("genres")}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - const ClusterType::pointer clusterType {ClusterType::find(context.dbSession, genreClusterName)}; - if (clusterType) - { - const auto clusters {clusterType->getClusters()}; - - for (const Cluster::pointer& cluster : clusters) - genresNode.addArrayChild("genre", clusterToResponseNode(cluster)); - } - - return response; -} - -static -Response -handleGetArtistsRequest(RequestContext& context) -{ - return handleGetArtistsRequestCommon(context, true /* id3 */); -} - -static -std::vector -findSimilarSongs(RequestContext& context, ArtistId artistId, std::size_t count) -{ - // API says: "Returns a random collection of songs from the given artist and similar artists" - const std::size_t similarArtistCount {count / 5}; - std::vector artistIds {Service::get()->getSimilarArtists(artistId, {TrackArtistLinkType::Artist, TrackArtistLinkType::ReleaseArtist}, similarArtistCount)}; - artistIds.push_back(artistId); - - const std::size_t meanTrackCountPerArtist {(count / artistIds.size()) + 1}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - std::vector tracks; - tracks.reserve(count); - - for (const ArtistId id : artistIds) - { - Track::FindParameters params; - params.setArtist(id); - params.setRange({0, meanTrackCountPerArtist}); - params.setSortMethod(TrackSortMethod::Random); - - const auto artistTracks {Track::find(context.dbSession, params)}; - tracks.insert(std::end(tracks), - std::begin(artistTracks.results), - std::end(artistTracks.results)); - } - - return tracks; -} - -static -std::vector -findSimilarSongs(RequestContext& context, ReleaseId releaseId, std::size_t count) -{ - // API says: "Returns a random collection of songs from the given artist and similar artists" - // so let's extend this for release - const std::size_t similarReleaseCount {count / 5}; - std::vector releaseIds {Service::get()->getSimilarReleases(releaseId, similarReleaseCount)}; - releaseIds.push_back(releaseId); - - const std::size_t meanTrackCountPerRelease {(count / releaseIds.size()) + 1}; - - auto transaction {context.dbSession.createSharedTransaction()}; - - std::vector tracks; - tracks.reserve(count); - - for (const ReleaseId id : releaseIds) - { - Track::FindParameters params; - params.setRelease(id); - params.setRange({0, meanTrackCountPerRelease}); - params.setSortMethod(TrackSortMethod::Random); - - const auto releaseTracks {Track::find(context.dbSession, params)}; - tracks.insert(std::end(tracks), - std::begin(releaseTracks.results), - std::end(releaseTracks.results)); - } - - return tracks; -} - -static -std::vector -findSimilarSongs(RequestContext&, TrackId trackId, std::size_t count) -{ - return Service::get()->findSimilarTracks({trackId}, count); -} - -static -Response -handleGetSimilarSongsRequestCommon(RequestContext& context, bool id3) -{ - // Optional params - std::size_t count {getParameterAs(context.parameters, "count").value_or(50)}; - - std::vector tracks; - - if (const auto artistId {getParameterAs(context.parameters, "id")}) - tracks = findSimilarSongs(context, *artistId, count); - else if (const auto releaseId {getParameterAs(context.parameters, "id")}) - tracks = findSimilarSongs(context, *releaseId, count); - else if (const auto trackId {getParameterAs(context.parameters, "id")}) - tracks = findSimilarSongs(context, *trackId, count); - else - throw BadParameterGenericError {"id"}; - - Random::shuffleContainer(tracks); - - auto transaction {context.dbSession.createSharedTransaction()}; - - User::pointer user {User::find(context.dbSession, context.userId)}; - if (!user) - throw UserNotAuthorizedError {}; - - Response response {Response::createOkResponse(context.serverProtocolVersion)}; - Response::Node& similarSongsNode {response.createNode(id3 ? "similarSongs2" : "similarSongs")}; - for (const TrackId trackId : tracks) - { - const Track::pointer track {Track::find(context.dbSession, trackId)}; - similarSongsNode.addArrayChild("song", createSongNode(track, context.dbSession, user)); - } - - return response; -} - -static -Response -handleGetSimilarSongsRequest(RequestContext& context) -{ - return handleGetSimilarSongsRequestCommon(context, false /* no id3 */); -} - -static -Response -handleGetSimilarSongs2Request(RequestContext& context) -{ - return handleGetSimilarSongsRequestCommon(context, true /* id3 */); -} - static Response handleNotImplemented(RequestContext&) diff --git a/src/libs/subsonic/impl/Utils.cpp b/src/libs/subsonic/impl/Utils.cpp index 3bfb17ab..ec7a3b6f 100644 --- a/src/libs/subsonic/impl/Utils.cpp +++ b/src/libs/subsonic/impl/Utils.cpp @@ -20,6 +20,7 @@ #include "Utils.hpp" #include "utils/Service.hpp" +#include "utils/String.hpp" #include "services/auth/IPasswordService.hpp" #include "SubsonicResponse.hpp" @@ -31,4 +32,10 @@ namespace API::Subsonic::Utils if (!passwordService || !passwordService->canSetPasswords()) throw NotImplementedGenericError{}; } + + std::string makeNameFilesystemCompatible(const std::string& name) + { + return StringUtils::replaceInString(name, "/", "_"); + } + } \ No newline at end of file diff --git a/src/libs/subsonic/impl/Utils.hpp b/src/libs/subsonic/impl/Utils.hpp index 774711bb..d94f11b8 100644 --- a/src/libs/subsonic/impl/Utils.hpp +++ b/src/libs/subsonic/impl/Utils.hpp @@ -19,7 +19,10 @@ #pragma once +#include + namespace API::Subsonic::Utils { void checkSetPasswordImplemented(); + std::string makeNameFilesystemCompatible(const std::string& name); } \ No newline at end of file diff --git a/src/libs/subsonic/impl/entrypoints/Browsing.cpp b/src/libs/subsonic/impl/entrypoints/Browsing.cpp new file mode 100644 index 00000000..44202630 --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/Browsing.cpp @@ -0,0 +1,465 @@ +/* + * Copyright (C) 2023 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "Browsing.hpp" + +#include "services/database/Artist.hpp" +#include "services/database/Cluster.hpp" +#include "services/database/Session.hpp" +#include "services/database/Release.hpp" +#include "services/database/Track.hpp" +#include "services/database/User.hpp" +#include "services/recommendation/IRecommendationService.hpp" +#include "utils/Random.hpp" +#include "utils/Service.hpp" +#include "responses/Album.hpp" +#include "responses/Artist.hpp" +#include "responses/Genre.hpp" +#include "responses/Song.hpp" +#include "ParameterParsing.hpp" +#include "SubsonicId.hpp" +#include "Utils.hpp" + +namespace API::Subsonic +{ + using namespace Database; + + static const std::string_view reportedDummyDate{ "2000-01-01T00:00:00" }; + static const unsigned long long reportedDummyDateULong{ 946684800000ULL }; // 2000-01-01T00:00:00 UTC + + namespace + { + Response handleGetArtistInfoRequestCommon(RequestContext& context, bool id3) + { + // Mandatory params + ArtistId id{ getMandatoryParameterAs(context.parameters, "id") }; + + // Optional params + std::size_t count{ getParameterAs(context.parameters, "count").value_or(20) }; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& artistInfoNode{ response.createNode(id3 ? "artistInfo2" : "artistInfo") }; + + { + auto transaction{ context.dbSession.createSharedTransaction() }; + + const Artist::pointer artist{ Artist::find(context.dbSession, id) }; + if (!artist) + throw RequestedDataNotFoundError{}; + + std::optional artistMBID{ artist->getMBID() }; + if (artistMBID) + artistInfoNode.createChild("musicBrainzId").setValue(artistMBID->getAsString()); + } + + auto similarArtistsId{ Service::get()->getSimilarArtists(id, {TrackArtistLinkType::Artist, TrackArtistLinkType::ReleaseArtist}, count) }; + + { + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + for (const ArtistId similarArtistId : similarArtistsId) + { + const Artist::pointer similarArtist{ Artist::find(context.dbSession, similarArtistId) }; + if (similarArtist) + artistInfoNode.addArrayChild("similarArtist", createArtistNode(similarArtist, context.dbSession, user, id3)); + } + } + + return response; + } + + Response handleGetArtistsRequestCommon(RequestContext& context, bool id3) + { + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + + Response::Node& artistsNode{ response.createNode(id3 ? "artists" : "indexes") }; + artistsNode.setAttribute("ignoredArticles", ""); + artistsNode.setAttribute("lastModified", reportedDummyDateULong); + + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + Artist::FindParameters parameters; + parameters.setSortMethod(ArtistSortMethod::BySortName); + switch (user->getSubsonicArtistListMode()) + { + case SubsonicArtistListMode::AllArtists: + break; + case SubsonicArtistListMode::ReleaseArtists: + parameters.setLinkType(TrackArtistLinkType::ReleaseArtist); + break; + case SubsonicArtistListMode::TrackArtists: + parameters.setLinkType(TrackArtistLinkType::Artist); + break; + } + + std::map> artistsSortedByFirstChar; + const RangeResults artists{ Artist::find(context.dbSession, parameters) }; + for (const ArtistId artistId : artists.results) + { + const Artist::pointer artist{ Artist::find(context.dbSession, artistId) }; + const std::string& sortName{ artist->getSortName() }; + + char sortChar; + if (sortName.empty() || !std::isalpha(sortName[0])) + sortChar = '?'; + else + sortChar = std::toupper(sortName[0]); + + artistsSortedByFirstChar[sortChar].push_back(artist); + } + + + for (const auto& [sortChar, artists] : artistsSortedByFirstChar) + { + Response::Node& indexNode{ artistsNode.createArrayChild("index") }; + indexNode.setAttribute("name", std::string{ sortChar }); + + for (const Artist::pointer& artist : artists) + indexNode.addArrayChild("artist", createArtistNode(artist, context.dbSession, user, id3)); + } + + return response; + } + + std::vector findSimilarSongs(RequestContext& context, ArtistId artistId, std::size_t count) + { + // API says: "Returns a random collection of songs from the given artist and similar artists" + const std::size_t similarArtistCount{ count / 5 }; + std::vector artistIds{ Service::get()->getSimilarArtists(artistId, {TrackArtistLinkType::Artist, TrackArtistLinkType::ReleaseArtist}, similarArtistCount) }; + artistIds.push_back(artistId); + + const std::size_t meanTrackCountPerArtist{ (count / artistIds.size()) + 1 }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + std::vector tracks; + tracks.reserve(count); + + for (const ArtistId id : artistIds) + { + Track::FindParameters params; + params.setArtist(id); + params.setRange({ 0, meanTrackCountPerArtist }); + params.setSortMethod(TrackSortMethod::Random); + + const auto artistTracks{ Track::find(context.dbSession, params) }; + tracks.insert(std::end(tracks), + std::begin(artistTracks.results), + std::end(artistTracks.results)); + } + + return tracks; + } + + std::vector findSimilarSongs(RequestContext& context, ReleaseId releaseId, std::size_t count) + { + // API says: "Returns a random collection of songs from the given artist and similar artists" + // so let's extend this for release + const std::size_t similarReleaseCount{ count / 5 }; + std::vector releaseIds{ Service::get()->getSimilarReleases(releaseId, similarReleaseCount) }; + releaseIds.push_back(releaseId); + + const std::size_t meanTrackCountPerRelease{ (count / releaseIds.size()) + 1 }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + std::vector tracks; + tracks.reserve(count); + + for (const ReleaseId id : releaseIds) + { + Track::FindParameters params; + params.setRelease(id); + params.setRange({ 0, meanTrackCountPerRelease }); + params.setSortMethod(TrackSortMethod::Random); + + const auto releaseTracks{ Track::find(context.dbSession, params) }; + tracks.insert(std::end(tracks), + std::begin(releaseTracks.results), + std::end(releaseTracks.results)); + } + + return tracks; + } + + std::vector findSimilarSongs(RequestContext&, TrackId trackId, std::size_t count) + { + return Service::get()->findSimilarTracks({ trackId }, count); + } + + Response handleGetSimilarSongsRequestCommon(RequestContext& context, bool id3) + { + // Optional params + std::size_t count{ getParameterAs(context.parameters, "count").value_or(50) }; + + std::vector tracks; + + if (const auto artistId{ getParameterAs(context.parameters, "id") }) + tracks = findSimilarSongs(context, *artistId, count); + else if (const auto releaseId{ getParameterAs(context.parameters, "id") }) + tracks = findSimilarSongs(context, *releaseId, count); + else if (const auto trackId{ getParameterAs(context.parameters, "id") }) + tracks = findSimilarSongs(context, *trackId, count); + else + throw BadParameterGenericError{ "id" }; + + Random::shuffleContainer(tracks); + + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& similarSongsNode{ response.createNode(id3 ? "similarSongs2" : "similarSongs") }; + for (const TrackId trackId : tracks) + { + const Track::pointer track{ Track::find(context.dbSession, trackId) }; + similarSongsNode.addArrayChild("song", createSongNode(track, context.dbSession, user)); + } + + return response; + } + + } + + Response handleGetMusicFoldersRequest(RequestContext& context) + { + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& musicFoldersNode{ response.createNode("musicFolders") }; + + Response::Node& musicFolderNode{ musicFoldersNode.createArrayChild("musicFolder") }; + musicFolderNode.setAttribute("id", "0"); + musicFolderNode.setAttribute("name", "Music"); + + return response; + } + + Response handleGetIndexesRequest(RequestContext& context) + { + return handleGetArtistsRequestCommon(context, false /* no id3 */); + } + + Response handleGetMusicDirectoryRequest(RequestContext& context) + { + // Mandatory params + const auto artistId{ getParameterAs(context.parameters, "id") }; + const auto releaseId{ getParameterAs(context.parameters, "id") }; + const auto root{ getParameterAs(context.parameters, "id") }; + + if (!root && !artistId && !releaseId) + throw BadParameterGenericError{ "id" }; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& directoryNode{ response.createNode("directory") }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + if (root) + { + directoryNode.setAttribute("id", idToString(RootId{})); + directoryNode.setAttribute("name", "Music"); + + auto rootArtistIds{ Artist::find(context.dbSession, Artist::FindParameters {}.setSortMethod(ArtistSortMethod::BySortName)) }; + for (const ArtistId rootArtistId : rootArtistIds.results) + { + const Artist::pointer artist{ Artist::find(context.dbSession, rootArtistId) }; + directoryNode.addArrayChild("child", createArtistNode(artist, context.dbSession, user, false /* no id3 */)); + } + } + else if (artistId) + { + directoryNode.setAttribute("id", idToString(*artistId)); + + auto artist{ Artist::find(context.dbSession, *artistId) }; + if (!artist) + throw RequestedDataNotFoundError{}; + + directoryNode.setAttribute("name", Utils::makeNameFilesystemCompatible(artist->getName())); + + const auto artistReleases{ Release::find(context.dbSession, Release::FindParameters {}.setArtist(*artistId)) }; + for (const ReleaseId artistReleaseId : artistReleases.results) + { + const Release::pointer release{ Release::find(context.dbSession, artistReleaseId) }; + directoryNode.addArrayChild("child", createAlbumNode(release, context.dbSession, user, false /* no id3 */)); + } + } + else if (releaseId) + { + directoryNode.setAttribute("id", idToString(*releaseId)); + + auto release{ Release::find(context.dbSession, *releaseId) }; + if (!release) + throw RequestedDataNotFoundError{}; + + directoryNode.setAttribute("name", Utils::makeNameFilesystemCompatible(release->getName())); + + const auto tracks{ Track::find(context.dbSession, Track::FindParameters {}.setRelease(*releaseId).setSortMethod(TrackSortMethod::Release)) }; + for (const TrackId trackId : tracks.results) + { + const Track::pointer track{ Track::find(context.dbSession, trackId) }; + directoryNode.addArrayChild("child", createSongNode(track, context.dbSession, user)); + } + } + else + throw BadParameterGenericError{ "id" }; + + return response; + } + + Response handleGetGenresRequest(RequestContext& context) + { + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + + Response::Node& genresNode{ response.createNode("genres") }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + const ClusterType::pointer clusterType{ ClusterType::find(context.dbSession, "GENRE") }; + if (clusterType) + { + const auto clusters{ clusterType->getClusters() }; + + for (const Cluster::pointer& cluster : clusters) + genresNode.addArrayChild("genre", createGenreNode(cluster)); + } + + return response; + } + + Response handleGetArtistsRequest(RequestContext& context) + { + return handleGetArtistsRequestCommon(context, true /* id3 */); + } + + Response handleGetArtistRequest(RequestContext& context) + { + // Mandatory params + ArtistId id{ getMandatoryParameterAs(context.parameters, "id") }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + const Artist::pointer artist{ Artist::find(context.dbSession, id) }; + if (!artist) + throw RequestedDataNotFoundError{}; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node artistNode{ createArtistNode(artist, context.dbSession, user, true /* id3 */) }; + + const auto releases{ Release::find(context.dbSession, Release::FindParameters {}.setArtist(artist->getId())) }; + for (const ReleaseId releaseId : releases.results) + { + const Release::pointer release{ Release::find(context.dbSession, releaseId) }; + artistNode.addArrayChild("album", createAlbumNode(release, context.dbSession, user, true /* id3 */)); + } + + response.addNode("artist", std::move(artistNode)); + + return response; + } + + Response handleGetAlbumRequest(RequestContext& context) + { + // Mandatory params + ReleaseId id{ getMandatoryParameterAs(context.parameters, "id") }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + Release::pointer release{ Release::find(context.dbSession, id) }; + if (!release) + throw RequestedDataNotFoundError{}; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node albumNode{ createAlbumNode(release, context.dbSession, user, true /* id3 */) }; + + const auto tracks{ Track::find(context.dbSession, Track::FindParameters {}.setRelease(id).setSortMethod(TrackSortMethod::Release)) }; + for (const TrackId trackId : tracks.results) + { + const Track::pointer track{ Track::find(context.dbSession, trackId) }; + albumNode.addArrayChild("song", createSongNode(track, context.dbSession, user)); + } + + response.addNode("album", std::move(albumNode)); + + return response; + } + + Response handleGetSongRequest(RequestContext& context) + { + // Mandatory params + TrackId id{ getMandatoryParameterAs(context.parameters, "id") }; + + auto transaction{ context.dbSession.createSharedTransaction() }; + + const Track::pointer track{ Track::find(context.dbSession, id) }; + if (!track) + throw RequestedDataNotFoundError{}; + + User::pointer user{ User::find(context.dbSession, context.userId) }; + if (!user) + throw UserNotAuthorizedError{}; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + response.addNode("song", createSongNode(track, context.dbSession, user)); + + return response; + } + + Response handleGetArtistInfoRequest(RequestContext& context) + { + return handleGetArtistInfoRequestCommon(context, false /* no id3 */); + } + + Response handleGetArtistInfo2Request(RequestContext& context) + { + return handleGetArtistInfoRequestCommon(context, true /* id3 */); + } + + Response handleGetSimilarSongsRequest(RequestContext& context) + { + return handleGetSimilarSongsRequestCommon(context, false /* no id3 */); + } + + Response handleGetSimilarSongs2Request(RequestContext& context) + { + return handleGetSimilarSongsRequestCommon(context, true /* id3 */); + } + +} diff --git a/src/libs/subsonic/impl/entrypoints/Browsing.hpp b/src/libs/subsonic/impl/entrypoints/Browsing.hpp new file mode 100644 index 00000000..3253db39 --- /dev/null +++ b/src/libs/subsonic/impl/entrypoints/Browsing.hpp @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include "RequestContext.hpp" +#include "SubsonicResponse.hpp" + +namespace API::Subsonic +{ + Response handleGetMusicFoldersRequest(RequestContext& context); + Response handleGetIndexesRequest(RequestContext& context); + Response handleGetMusicDirectoryRequest(RequestContext& context); + Response handleGetGenresRequest(RequestContext& context); + Response handleGetArtistsRequest(RequestContext& context); + Response handleGetArtistRequest(RequestContext& context); + Response handleGetAlbumRequest(RequestContext& context); + Response handleGetSongRequest(RequestContext& context); + Response handleGetArtistInfoRequest(RequestContext& context); + Response handleGetArtistInfo2Request(RequestContext& context); + Response handleGetSimilarSongsRequest(RequestContext& context); + Response handleGetSimilarSongs2Request(RequestContext& context); +} diff --git a/src/libs/subsonic/impl/responses/Bookmark.cpp b/src/libs/subsonic/impl/responses/Bookmark.cpp index 5dfb2c35..f04b1d7c 100644 --- a/src/libs/subsonic/impl/responses/Bookmark.cpp +++ b/src/libs/subsonic/impl/responses/Bookmark.cpp @@ -21,7 +21,6 @@ #include "services/database/TrackBookmark.hpp" #include "services/database/User.hpp" -#include "SubsonicResponse.hpp" namespace API::Subsonic { diff --git a/src/libs/subsonic/impl/responses/Genre.cpp b/src/libs/subsonic/impl/responses/Genre.cpp new file mode 100644 index 00000000..1e274ac5 --- /dev/null +++ b/src/libs/subsonic/impl/responses/Genre.cpp @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2023 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "responses/Genre.hpp" + +#include "services/database/Cluster.hpp" + +namespace API::Subsonic +{ + Response::Node createGenreNode(const Database::Cluster::pointer& cluster) + { + Response::Node clusterNode; + + clusterNode.setValue(cluster->getName()); + clusterNode.setAttribute("songCount", cluster->getTracksCount()); + clusterNode.setAttribute("albumCount", cluster->getReleasesCount()); + + return clusterNode; + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Genre.hpp b/src/libs/subsonic/impl/responses/Genre.hpp new file mode 100644 index 00000000..33577f71 --- /dev/null +++ b/src/libs/subsonic/impl/responses/Genre.hpp @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2023 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include "services/database/Cluster.hpp" + +#include "services/database/Object.hpp" +#include "SubsonicResponse.hpp" + +namespace Database +{ + class Cluster; +} + +namespace API::Subsonic +{ + Response::Node createGenreNode(const Database::ObjectPtr& cluster); +} diff --git a/src/libs/subsonic/impl/responses/Song.cpp b/src/libs/subsonic/impl/responses/Song.cpp index 53ca66cd..63b0aa58 100644 --- a/src/libs/subsonic/impl/responses/Song.cpp +++ b/src/libs/subsonic/impl/responses/Song.cpp @@ -31,6 +31,7 @@ #include "utils/String.hpp" #include "responses/Artist.hpp" #include "SubsonicId.hpp" +#include "Utils.hpp" namespace API::Subsonic { @@ -54,11 +55,6 @@ namespace API::Subsonic return ""; } - std::string makeNameFilesystemCompatible(std::string_view name) - { - return StringUtils::replaceInString(name, "/", "_"); - } - std::string getTrackPath(const Track::pointer& track) { std::string path; @@ -75,9 +71,9 @@ namespace API::Subsonic if (artists.size() > 1) path = "Various Artists/"; else if (artists.size() == 1) - path = makeNameFilesystemCompatible(artists.front()->getName()) + "/"; + path = Utils::makeNameFilesystemCompatible(artists.front()->getName()) + "/"; - path += makeNameFilesystemCompatible(track->getRelease()->getName()) + "/"; + path += Utils::makeNameFilesystemCompatible(track->getRelease()->getName()) + "/"; } if (track->getDiscNumber()) @@ -85,7 +81,7 @@ namespace API::Subsonic if (track->getTrackNumber()) path += std::to_string(*track->getTrackNumber()) + "-"; - path += makeNameFilesystemCompatible(track->getName()); + path += Utils::makeNameFilesystemCompatible(track->getName()); if (track->getPath().has_extension()) path += track->getPath().extension();