diff --git a/src/libs/subsonic/CMakeLists.txt b/src/libs/subsonic/CMakeLists.txt index 95b7ccbb..fdfbd598 100644 --- a/src/libs/subsonic/CMakeLists.txt +++ b/src/libs/subsonic/CMakeLists.txt @@ -2,6 +2,7 @@ add_library(lmssubsonic SHARED impl/responses/Album.cpp impl/responses/Artist.cpp + impl/responses/Song.cpp impl/ProtocolVersion.cpp impl/Scan.cpp impl/Stream.cpp diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index 9c5ce89a..38d5dd28 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -56,6 +56,7 @@ #include "responses/Artist.hpp" #include "responses/Album.hpp" +#include "responses/Song.hpp" using namespace Database; @@ -185,128 +186,6 @@ checkUserTypeIsAllowed(RequestContext& context, EnumSet allo throw UserNotAuthorizedError {}; } -static -std::string -getTrackPath(const Track::pointer& track) -{ - std::string path; - - // The track path has to be relative from the root - - const auto release {track->getRelease()}; - if (release) - { - auto artists {release->getReleaseArtists()}; - if (artists.empty()) - artists = release->getArtists(); - - if (artists.size() > 1) - path = "Various Artists/"; - else if (artists.size() == 1) - path = makeNameFilesystemCompatible(artists.front()->getName()) + "/"; - - path += makeNameFilesystemCompatible(track->getRelease()->getName()) + "/"; - } - - if (track->getDiscNumber()) - path += std::to_string(*track->getDiscNumber()) + "-"; - if (track->getTrackNumber()) - path += std::to_string(*track->getTrackNumber()) + "-"; - - path += makeNameFilesystemCompatible(track->getName()); - - if (track->getPath().has_extension()) - path += track->getPath().extension(); - - return path; -} - -static -std::string_view -formatToSuffix(AudioFormat format) -{ - switch (format) - { - case AudioFormat::MP3: return "mp3"; - case AudioFormat::OGG_OPUS: return "opus"; - case AudioFormat::MATROSKA_OPUS: return "mka"; - case AudioFormat::OGG_VORBIS: return "ogg"; - case AudioFormat::WEBM_VORBIS: return "webm"; - } - - return ""; -} - -static -Response::Node -trackToResponseNode(const Track::pointer& track, Session& dbSession, const User::pointer& user) -{ - Response::Node trackResponse; - - trackResponse.setAttribute("id", idToString(track->getId())); - trackResponse.setAttribute("isDir", false); - trackResponse.setAttribute("title", track->getName()); - if (track->getTrackNumber()) - trackResponse.setAttribute("track", *track->getTrackNumber()); - if (track->getDiscNumber()) - trackResponse.setAttribute("discNumber", *track->getDiscNumber()); - if (track->getYear()) - trackResponse.setAttribute("year", *track->getYear()); - - trackResponse.setAttribute("path", getTrackPath(track)); - { - std::error_code ec; - const auto fileSize {std::filesystem::file_size(track->getPath(), ec)}; - if (!ec) - trackResponse.setAttribute("size", fileSize); - } - - if (track->getPath().has_extension()) - { - auto extension {track->getPath().extension()}; - trackResponse.setAttribute("suffix", extension.string().substr(1)); - } - - if (user->getSubsonicTranscodeEnable()) - trackResponse.setAttribute("transcodedSuffix", formatToSuffix(user->getSubsonicTranscodeFormat())); - - trackResponse.setAttribute("coverArt", idToString(track->getId())); - - const std::vector& artists {track->getArtists({TrackArtistLinkType::Artist})}; - if (!artists.empty()) - { - trackResponse.setAttribute("artist", utils::joinArtistNames(artists)); - - if (artists.size() == 1) - trackResponse.setAttribute("artistId", idToString(artists.front()->getId())); - } - - if (track->getRelease()) - { - trackResponse.setAttribute("album", track->getRelease()->getName()); - trackResponse.setAttribute("albumId", idToString(track->getRelease()->getId())); - trackResponse.setAttribute("parent", idToString(track->getRelease()->getId())); - } - - trackResponse.setAttribute("duration", std::chrono::duration_cast(track->getDuration()).count()); - trackResponse.setAttribute("type", "music"); - trackResponse.setAttribute("created", StringUtils::toISO8601String(track->getLastWritten())); - - if (Service::get()->isStarred(user->getId(), track->getId())) - trackResponse.setAttribute("starred", reportedStarredDate); - - // Report the first GENRE for this track - ClusterType::pointer clusterType {ClusterType::find(dbSession, genreClusterName)}; - if (clusterType) - { - auto clusters {track->getClusterGroups({clusterType}, 1)}; - if (!clusters.empty() && !clusters.front().empty()) - trackResponse.setAttribute("genre", clusters.front().front()->getName()); - } - - return trackResponse; -} - static Response::Node trackBookmarkToResponseNode(const TrackBookmark::pointer& trackBookmark) @@ -593,7 +472,7 @@ handleGetRandomSongsRequest(RequestContext& context) for (const TrackId trackId : trackIds.results) { const Track::pointer track {Track::find(context.dbSession, trackId)}; - randomSongsNode.addArrayChild("song", trackToResponseNode(track, context.dbSession, user)); + randomSongsNode.addArrayChild("song", createSongNode(track, context.dbSession, user)); } return response; @@ -746,7 +625,7 @@ handleGetAlbumRequest(RequestContext& context) for (const TrackId trackId : tracks.results) { const Track::pointer track {Track::find(context.dbSession, trackId)}; - albumNode.addArrayChild("song", trackToResponseNode(track, context.dbSession, user)); + albumNode.addArrayChild("song", createSongNode(track, context.dbSession, user)); } response.addNode("album", std::move(albumNode)); @@ -772,7 +651,7 @@ handleGetSongRequest(RequestContext& context) throw UserNotAuthorizedError {}; Response response {Response::createOkResponse(context.serverProtocolVersion)}; - response.addNode("song", trackToResponseNode(track, context.dbSession, user)); + response.addNode("song", createSongNode(track, context.dbSession, user)); return response; } @@ -932,7 +811,7 @@ handleGetMusicDirectoryRequest(RequestContext& context) for (const TrackId trackId : tracks.results) { const Track::pointer track {Track::find(context.dbSession, trackId)}; - directoryNode.addArrayChild("child", trackToResponseNode(track, context.dbSession, user)); + directoryNode.addArrayChild("child", createSongNode(track, context.dbSession, user)); } } else @@ -1153,7 +1032,7 @@ handleGetSimilarSongsRequestCommon(RequestContext& context, bool id3) for (const TrackId trackId : tracks) { const Track::pointer track {Track::find(context.dbSession, trackId)}; - similarSongsNode.addArrayChild("song", trackToResponseNode(track, context.dbSession, user)); + similarSongsNode.addArrayChild("song", createSongNode(track, context.dbSession, user)); } return response; @@ -1203,7 +1082,7 @@ handleGetStarredRequestCommon(RequestContext& context, bool id3) for (const TrackId trackId : scrobbling.getStarredTracks(context.userId, {} /* clusters */, Range {}).results) { if (auto track {Track::find(context.dbSession, trackId)}) - starredNode.addArrayChild("song", trackToResponseNode(track, context.dbSession, user)); + starredNode.addArrayChild("song", createSongNode(track, context.dbSession, user)); } return response; @@ -1263,7 +1142,7 @@ handleGetPlaylistRequest(RequestContext& context) auto entries {tracklist->getEntries()}; for (const TrackListEntry::pointer& entry : entries) - playlistNode.addArrayChild("entry", trackToResponseNode(entry->getTrack(), context.dbSession, user)); + playlistNode.addArrayChild("entry", createSongNode(entry->getTrack(), context.dbSession, user)); response.addNode("playlist", playlistNode ); @@ -1331,7 +1210,7 @@ handleGetSongsByGenreRequest(RequestContext& context) for (const TrackId trackId : trackIds.results) { const Track::pointer track {Track::find(context.dbSession, trackId)}; - songsByGenreNode.addArrayChild("song", trackToResponseNode(track, context.dbSession, user)); + songsByGenreNode.addArrayChild("song", createSongNode(track, context.dbSession, user)); } return response; @@ -1447,7 +1326,7 @@ handleSearchRequestCommon(RequestContext& context, bool id3) for (const TrackId trackId : trackIds.results) { const auto track {Track::find(context.dbSession, trackId)}; - searchResult2Node.addArrayChild("song", trackToResponseNode(track, context.dbSession, user)); + searchResult2Node.addArrayChild("song", createSongNode(track, context.dbSession, user)); } } @@ -1690,7 +1569,7 @@ handleGetBookmarks(RequestContext& context) { const TrackBookmark::pointer bookmark {TrackBookmark::find(context.dbSession, bookmarkId)}; Response::Node bookmarkNode {trackBookmarkToResponseNode(bookmark)}; - bookmarkNode.addArrayChild("entry", trackToResponseNode(bookmark->getTrack(), context.dbSession, user)); + bookmarkNode.addArrayChild("entry", createSongNode(bookmark->getTrack(), context.dbSession, user)); bookmarksNode.addArrayChild("bookmark", std::move(bookmarkNode)); } diff --git a/src/libs/subsonic/impl/responses/Artist.hpp b/src/libs/subsonic/impl/responses/Artist.hpp index dd0ad10a..b9614a11 100644 --- a/src/libs/subsonic/impl/responses/Artist.hpp +++ b/src/libs/subsonic/impl/responses/Artist.hpp @@ -28,7 +28,7 @@ namespace Database { class Artist; class User; - class session; + class Session; } namespace API::Subsonic diff --git a/src/libs/subsonic/impl/responses/Song.cpp b/src/libs/subsonic/impl/responses/Song.cpp new file mode 100644 index 00000000..8e9fa183 --- /dev/null +++ b/src/libs/subsonic/impl/responses/Song.cpp @@ -0,0 +1,163 @@ +/* + * 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 + +#include "services/database/Artist.hpp" +#include "services/database/Cluster.hpp" +#include "services/database/Release.hpp" +#include "services/database/Track.hpp" +#include "services/database/User.hpp" +#include "services/scrobbling/IScrobblingService.hpp" +#include "utils/Service.hpp" +#include "utils/String.hpp" +#include "responses/Artist.hpp" +#include "responses/Song.hpp" +#include "SubsonicId.hpp" + +namespace API::Subsonic +{ + using namespace Database; + + static const std::string_view reportedDummyStarredDate{ "2000-01-01T00:00:00" }; + + namespace + { + std::string_view formatToSuffix(AudioFormat format) + { + switch (format) + { + case AudioFormat::MP3: return "mp3"; + case AudioFormat::OGG_OPUS: return "opus"; + case AudioFormat::MATROSKA_OPUS: return "mka"; + case AudioFormat::OGG_VORBIS: return "ogg"; + case AudioFormat::WEBM_VORBIS: return "webm"; + } + + return ""; + } + + std::string makeNameFilesystemCompatible(std::string_view name) + { + return StringUtils::replaceInString(name, "/", "_"); + } + + std::string getTrackPath(const Track::pointer& track) + { + std::string path; + + // The track path has to be relative from the root + + const auto release{ track->getRelease() }; + if (release) + { + auto artists{ release->getReleaseArtists() }; + if (artists.empty()) + artists = release->getArtists(); + + if (artists.size() > 1) + path = "Various Artists/"; + else if (artists.size() == 1) + path = makeNameFilesystemCompatible(artists.front()->getName()) + "/"; + + path += makeNameFilesystemCompatible(track->getRelease()->getName()) + "/"; + } + + if (track->getDiscNumber()) + path += std::to_string(*track->getDiscNumber()) + "-"; + if (track->getTrackNumber()) + path += std::to_string(*track->getTrackNumber()) + "-"; + + path += makeNameFilesystemCompatible(track->getName()); + + if (track->getPath().has_extension()) + path += track->getPath().extension(); + + return path; + } + } + + Response::Node createSongNode(const Track::pointer& track, Session& dbSession, const User::pointer& user) + { + Response::Node trackResponse; + + trackResponse.setAttribute("id", idToString(track->getId())); + trackResponse.setAttribute("isDir", false); + trackResponse.setAttribute("title", track->getName()); + if (track->getTrackNumber()) + trackResponse.setAttribute("track", *track->getTrackNumber()); + if (track->getDiscNumber()) + trackResponse.setAttribute("discNumber", *track->getDiscNumber()); + if (track->getYear()) + trackResponse.setAttribute("year", *track->getYear()); + + trackResponse.setAttribute("path", getTrackPath(track)); + { + std::error_code ec; + const auto fileSize{ std::filesystem::file_size(track->getPath(), ec) }; + if (!ec) + trackResponse.setAttribute("size", fileSize); + } + + if (track->getPath().has_extension()) + { + auto extension{ track->getPath().extension() }; + trackResponse.setAttribute("suffix", extension.string().substr(1)); + } + + if (user->getSubsonicTranscodeEnable()) + trackResponse.setAttribute("transcodedSuffix", formatToSuffix(user->getSubsonicTranscodeFormat())); + + trackResponse.setAttribute("coverArt", idToString(track->getId())); + + const std::vector& artists{ track->getArtists({TrackArtistLinkType::Artist}) }; + if (!artists.empty()) + { + trackResponse.setAttribute("artist", utils::joinArtistNames(artists)); + + if (artists.size() == 1) + trackResponse.setAttribute("artistId", idToString(artists.front()->getId())); + } + + if (track->getRelease()) + { + trackResponse.setAttribute("album", track->getRelease()->getName()); + trackResponse.setAttribute("albumId", idToString(track->getRelease()->getId())); + trackResponse.setAttribute("parent", idToString(track->getRelease()->getId())); + } + + trackResponse.setAttribute("duration", std::chrono::duration_cast(track->getDuration()).count()); + trackResponse.setAttribute("type", "music"); + trackResponse.setAttribute("created", StringUtils::toISO8601String(track->getLastWritten())); + + if (Service::get()->isStarred(user->getId(), track->getId())) + trackResponse.setAttribute("starred", reportedDummyStarredDate); // TODO handle date/time + + // Report the first GENRE for this track + ClusterType::pointer clusterType{ ClusterType::find(dbSession, "GENRE") }; + if (clusterType) + { + auto clusters{ track->getClusterGroups({clusterType}, 1) }; + if (!clusters.empty() && !clusters.front().empty()) + trackResponse.setAttribute("genre", clusters.front().front()->getName()); + } + + return trackResponse; + } +} \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Song.hpp b/src/libs/subsonic/impl/responses/Song.hpp new file mode 100644 index 00000000..9fcba25e --- /dev/null +++ b/src/libs/subsonic/impl/responses/Song.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/Object.hpp" +#include "SubsonicResponse.hpp" + +namespace Database +{ + class Track; + class User; + class Session; +} + +namespace API::Subsonic +{ + Response::Node createSongNode(const Database::ObjectPtr& track, Database::Session& session, const Database::ObjectPtr& user); +} \ No newline at end of file