Renamed folder

This commit is contained in:
emeric
2024-11-29 21:19:07 +01:00
parent a439d68e73
commit e3a94d0540
22 changed files with 21 additions and 21 deletions
@@ -0,0 +1,306 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "AlbumSongLists.hpp"
#include "core/Service.hpp"
#include "database/Artist.hpp"
#include "database/Cluster.hpp"
#include "database/Release.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/User.hpp"
#include "services/feedback/IFeedbackService.hpp"
#include "services/scrobbling/IScrobblingService.hpp"
#include "ParameterParsing.hpp"
#include "SubsonicId.hpp"
#include "responses/Album.hpp"
#include "responses/Artist.hpp"
#include "responses/Song.hpp"
namespace lms::api::subsonic
{
using namespace db;
namespace
{
Response handleGetAlbumListRequestCommon(RequestContext& context, bool id3)
{
// Mandatory params
const std::string type{ getMandatoryParameterAs<std::string>(context.parameters, "type") };
// Optional params
const MediaLibraryId mediaLibraryId{ getParameterAs<MediaLibraryId>(context.parameters, "musicFolderId").value_or(MediaLibraryId{}) };
const std::size_t size{ getParameterAs<std::size_t>(context.parameters, "size").value_or(10) };
const std::size_t offset{ getParameterAs<std::size_t>(context.parameters, "offset").value_or(0) };
if (size > defaultMaxCountSize)
throw ParameterValueTooHighGenericError{ "size", defaultMaxCountSize };
const Range range{ offset, size };
RangeResults<ReleaseId> releases;
scrobbling::IScrobblingService& scrobblingService{ *core::Service<scrobbling::IScrobblingService>::get() };
feedback::IFeedbackService& feedbackService{ *core::Service<feedback::IFeedbackService>::get() };
auto transaction{ context.dbSession.createReadTransaction() };
if (type == "alphabeticalByName")
{
Release::FindParameters params;
params.setSortMethod(ReleaseSortMethod::Name);
params.setRange(range);
params.setMediaLibrary(mediaLibraryId);
releases = Release::findIds(context.dbSession, params);
}
else if (type == "alphabeticalByArtist")
{
Release::FindParameters params;
params.setSortMethod(ReleaseSortMethod::ArtistNameThenName);
params.setRange(range);
params.setMediaLibrary(mediaLibraryId);
releases = Release::findIds(context.dbSession, params);
}
else if (type == "byGenre")
{
// Mandatory param
const std::string genre{ getMandatoryParameterAs<std::string>(context.parameters, "genre") };
if (const ClusterType::pointer clusterType{ ClusterType::find(context.dbSession, "GENRE") })
{
if (const Cluster::pointer cluster{ clusterType->getCluster(genre) })
{
Release::FindParameters params;
params.setClusters(std::initializer_list<ClusterId>{ cluster->getId() });
params.setSortMethod(ReleaseSortMethod::Name);
params.setRange(range);
params.setMediaLibrary(mediaLibraryId);
releases = Release::findIds(context.dbSession, params);
}
}
}
else if (type == "byYear")
{
const int fromYear{ getMandatoryParameterAs<int>(context.parameters, "fromYear") };
const int toYear{ getMandatoryParameterAs<int>(context.parameters, "toYear") };
Release::FindParameters params;
params.setSortMethod(fromYear > toYear ? ReleaseSortMethod::DateDesc : ReleaseSortMethod::DateAsc);
params.setRange(range);
params.setDateRange(DateRange::fromYearRange(std::min(fromYear, toYear), std::max(fromYear, toYear)));
params.setMediaLibrary(mediaLibraryId);
releases = Release::findIds(context.dbSession, params);
}
else if (type == "frequent")
{
scrobbling::IScrobblingService::FindParameters params;
params.setUser(context.user->getId());
params.setRange(range);
params.setMediaLibrary(mediaLibraryId);
releases = scrobblingService.getTopReleases(params);
}
else if (type == "newest")
{
Release::FindParameters params;
params.setSortMethod(ReleaseSortMethod::LastWritten);
params.setRange(range);
params.setMediaLibrary(mediaLibraryId);
releases = Release::findIds(context.dbSession, params);
}
else if (type == "random")
{
// Random results are paginated, but there is no acceptable way to handle the pagination params without repeating some albums
// (no seed provided by subsonic, ot it would require to store some kind of context for each user/client when iterating over the random albums)
Release::FindParameters params;
params.setSortMethod(ReleaseSortMethod::Random);
params.setRange(Range{ 0, size });
params.setMediaLibrary(mediaLibraryId);
releases = Release::findIds(context.dbSession, params);
}
else if (type == "recent")
{
scrobbling::IScrobblingService::FindParameters params;
params.setUser(context.user->getId());
params.setRange(range);
params.setMediaLibrary(mediaLibraryId);
releases = scrobblingService.getRecentReleases(params);
}
else if (type == "starred")
{
feedback::IFeedbackService::FindParameters params;
params.setUser(context.user->getId());
params.setRange(range);
params.setMediaLibrary(mediaLibraryId);
releases = feedbackService.findStarredReleases(params);
}
else
{
throw NotImplementedGenericError{};
}
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& albumListNode{ response.createNode(id3 ? Response::Node::Key{ "albumList2" } : Response::Node::Key{ "albumList" }) };
for (const ReleaseId releaseId : releases.results)
{
const Release::pointer release{ Release::find(context.dbSession, releaseId) };
albumListNode.addArrayChild("album", createAlbumNode(context, release, id3));
}
return response;
}
Response handleGetStarredRequestCommon(RequestContext& context, bool id3)
{
// Optional parameters
const MediaLibraryId mediaLibrary{ getParameterAs<MediaLibraryId>(context.parameters, "musicFolderId").value_or(MediaLibraryId{}) };
auto transaction{ context.dbSession.createReadTransaction() };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& starredNode{ response.createNode(id3 ? Response::Node::Key{ "starred2" } : Response::Node::Key{ "starred" }) };
feedback::IFeedbackService& feedbackService{ *core::Service<feedback::IFeedbackService>::get() };
// We don't support starring directories
if (id3)
{
feedback::IFeedbackService::ArtistFindParameters artistFindParams;
artistFindParams.setUser(context.user->getId());
artistFindParams.setSortMethod(ArtistSortMethod::SortName);
for (const ArtistId artistId : feedbackService.findStarredArtists(artistFindParams).results)
{
if (auto artist{ Artist::find(context.dbSession, artistId) })
starredNode.addArrayChild("artist", createArtistNode(context, artist));
}
}
feedback::IFeedbackService::FindParameters findParameters;
findParameters.setUser(context.user->getId());
findParameters.setMediaLibrary(mediaLibrary);
for (const ReleaseId releaseId : feedbackService.findStarredReleases(findParameters).results)
{
if (auto release{ Release::find(context.dbSession, releaseId) })
starredNode.addArrayChild("album", createAlbumNode(context, release, id3));
}
for (const TrackId trackId : feedbackService.findStarredTracks(findParameters).results)
{
if (auto track{ Track::find(context.dbSession, trackId) })
starredNode.addArrayChild("song", createSongNode(context, track, context.user));
}
return response;
}
} // namespace
Response handleGetAlbumListRequest(RequestContext& context)
{
return handleGetAlbumListRequestCommon(context, false /* no id3 */);
}
Response handleGetAlbumList2Request(RequestContext& context)
{
return handleGetAlbumListRequestCommon(context, true /* id3 */);
}
Response handleGetRandomSongsRequest(RequestContext& context)
{
// Optional params
const MediaLibraryId mediaLibraryId{ getParameterAs<MediaLibraryId>(context.parameters, "musicFolderId").value_or(MediaLibraryId{}) };
std::size_t size{ getParameterAs<std::size_t>(context.parameters, "size").value_or(50) };
if (size > defaultMaxCountSize)
throw ParameterValueTooHighGenericError{ "size", defaultMaxCountSize };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& randomSongsNode{ response.createNode("randomSongs") };
auto transaction{ context.dbSession.createReadTransaction() };
Track::FindParameters params;
params.setSortMethod(TrackSortMethod::Random);
params.setRange(Range{ 0, size });
params.setMediaLibrary(mediaLibraryId);
Track::find(context.dbSession, params, [&](const Track::pointer& track) {
randomSongsNode.addArrayChild("song", createSongNode(context, track, context.user));
});
return response;
}
Response handleGetSongsByGenreRequest(RequestContext& context)
{
// Mandatory params
std::string genre{ getMandatoryParameterAs<std::string>(context.parameters, "genre") };
// Optional params
const MediaLibraryId mediaLibrary{ getParameterAs<MediaLibraryId>(context.parameters, "musicFolderId").value_or(MediaLibraryId{}) };
std::size_t count{ getParameterAs<std::size_t>(context.parameters, "count").value_or(10) };
if (count > defaultMaxCountSize)
throw ParameterValueTooHighGenericError{ "count", defaultMaxCountSize };
std::size_t offset{ getParameterAs<std::size_t>(context.parameters, "offset").value_or(0) };
auto transaction{ context.dbSession.createReadTransaction() };
auto clusterType{ ClusterType::find(context.dbSession, "GENRE") };
if (!clusterType)
throw RequestedDataNotFoundError{};
auto cluster{ clusterType->getCluster(genre) };
if (!cluster)
throw RequestedDataNotFoundError{};
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& songsByGenreNode{ response.createNode("songsByGenre") };
Track::FindParameters params;
params.setClusters(std::initializer_list<ClusterId>{ cluster->getId() });
params.setRange(Range{ offset, count });
params.setMediaLibrary(mediaLibrary);
Track::find(context.dbSession, params, [&](const Track::pointer& track) {
songsByGenreNode.addArrayChild("song", createSongNode(context, track, context.user));
});
return response;
}
Response handleGetStarredRequest(RequestContext& context)
{
return handleGetStarredRequestCommon(context, false /* no id3 */);
}
Response handleGetStarred2Request(RequestContext& context)
{
return handleGetStarredRequestCommon(context, true /* id3 */);
}
} // namespace lms::api::subsonic
@@ -0,0 +1,33 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "RequestContext.hpp"
#include "SubsonicResponse.hpp"
namespace lms::api::subsonic
{
Response handleGetAlbumListRequest(RequestContext& context);
Response handleGetAlbumList2Request(RequestContext& context);
Response handleGetRandomSongsRequest(RequestContext& context);
Response handleGetSongsByGenreRequest(RequestContext& context);
Response handleGetStarredRequest(RequestContext& context);
Response handleGetStarred2Request(RequestContext& context);
} // namespace lms::api::subsonic
@@ -0,0 +1,176 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "Bookmarks.hpp"
#include "database/PlayQueue.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/TrackBookmark.hpp"
#include "database/User.hpp"
#include "ParameterParsing.hpp"
#include "SubsonicId.hpp"
#include "responses/Bookmark.hpp"
#include "responses/Song.hpp"
#include "core/String.hpp"
namespace lms::api::subsonic
{
using namespace db;
Response handleGetBookmarks(RequestContext& context)
{
auto transaction{ context.dbSession.createReadTransaction() };
const auto bookmarkIds{ TrackBookmark::find(context.dbSession, context.user->getId()) };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& bookmarksNode{ response.createNode("bookmarks") };
for (const TrackBookmarkId bookmarkId : bookmarkIds.results)
{
const TrackBookmark::pointer bookmark{ TrackBookmark::find(context.dbSession, bookmarkId) };
Response::Node bookmarkNode{ createBookmarkNode(bookmark) };
bookmarkNode.addChild("entry", createSongNode(context, bookmark->getTrack(), context.user));
bookmarksNode.addArrayChild("bookmark", std::move(bookmarkNode));
}
return response;
}
Response handleCreateBookmark(RequestContext& context)
{
// Mandatory params
TrackId trackId{ getMandatoryParameterAs<TrackId>(context.parameters, "id") };
unsigned long position{ getMandatoryParameterAs<unsigned long>(context.parameters, "position") };
const std::optional<std::string> comment{ getParameterAs<std::string>(context.parameters, "comment") };
auto transaction{ context.dbSession.createWriteTransaction() };
const Track::pointer track{ Track::find(context.dbSession, trackId) };
if (!track)
throw RequestedDataNotFoundError{};
// Replace any existing bookmark
auto bookmark{ TrackBookmark::find(context.dbSession, context.user->getId(), trackId) };
if (!bookmark)
bookmark = context.dbSession.create<TrackBookmark>(context.user, track);
bookmark.modify()->setOffset(std::chrono::milliseconds{ position });
if (comment)
bookmark.modify()->setComment(*comment);
return Response::createOkResponse(context.serverProtocolVersion);
}
Response handleDeleteBookmark(RequestContext& context)
{
// Mandatory params
TrackId trackId{ getMandatoryParameterAs<TrackId>(context.parameters, "id") };
auto transaction{ context.dbSession.createWriteTransaction() };
auto bookmark{ TrackBookmark::find(context.dbSession, context.user->getId(), trackId) };
if (!bookmark)
throw RequestedDataNotFoundError{};
bookmark.remove();
return Response::createOkResponse(context.serverProtocolVersion);
}
// Use a dedicated internal playlist
Response handleGetPlayQueue(RequestContext& context)
{
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
auto transaction{ context.dbSession.createReadTransaction() };
const db::PlayQueue::pointer playQueue{ db::PlayQueue::find(context.dbSession, context.user->getId(), "subsonic") };
if (playQueue)
{
Response::Node& playQueueNode{ response.createNode("playQueue") };
if (auto currentTrack{ playQueue->getTrackAtCurrentIndex() })
{
// optional fields
playQueueNode.setAttribute("current", idToString(currentTrack->getId()));
playQueueNode.setAttribute("position", playQueue->getCurrentPositionInTrack().count());
}
// mandatory fields
playQueueNode.setAttribute("username", context.user->getLoginName());
playQueueNode.setAttribute("changed", core::stringUtils::toISO8601String(playQueue->getLastModifiedDateTime()));
playQueueNode.setAttribute("changedBy", "unknown"); // we don't store the client name (could be several same clients on several devices...)
playQueue->visitTracks([&](const db::Track::pointer& track) {
playQueueNode.addArrayChild("entry", createSongNode(context, track, true /* id3 */));
});
}
return response;
}
Response handleSavePlayQueue(RequestContext& context)
{
// optional params
std::vector<db::TrackId> trackIds{ getMultiParametersAs<TrackId>(context.parameters, "id") };
const std::optional<db::TrackId> currentTrackId{ getParameterAs<db::TrackId>(context.parameters, "current") };
const std::chrono::milliseconds currentPositionInTrack{ getParameterAs<std::size_t>(context.parameters, "current").value_or(0) };
std::vector<db::Track::pointer> tracks;
tracks.reserve(trackIds.size());
// no id means we clear the play queue (see https://github.com/opensubsonic/open-subsonic-api/pull/106)
if (!trackIds.empty())
{
auto transaction{ context.dbSession.createReadTransaction() };
for (db::TrackId trackId : trackIds)
{
if (db::Track::pointer track{ db::Track::find(context.dbSession, trackId) })
tracks.push_back(track);
}
}
{
auto transaction{ context.dbSession.createWriteTransaction() };
db::PlayQueue::pointer playQueue{ db::PlayQueue::find(context.dbSession, context.user->getId(), "subsonic") };
if (!playQueue)
playQueue = context.dbSession.create<db::PlayQueue>(context.user, "subsonic");
playQueue.modify()->clear();
std::size_t index{};
for (std::size_t i{}; i < tracks.size(); ++i)
{
db::Track::pointer& track{ tracks[i] };
playQueue.modify()->addTrack(track);
if (track->getId() == currentTrackId)
index = i;
}
playQueue.modify()->setCurrentIndex(index);
playQueue.modify()->setCurrentPositionInTrack(currentPositionInTrack);
playQueue.modify()->setLastModifiedDateTime(Wt::WDateTime::currentDateTime());
}
return Response::createOkResponse(context.serverProtocolVersion);
}
} // namespace lms::api::subsonic
@@ -0,0 +1,32 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "RequestContext.hpp"
#include "SubsonicResponse.hpp"
namespace lms::api::subsonic
{
Response handleGetBookmarks(RequestContext& context);
Response handleCreateBookmark(RequestContext& context);
Response handleDeleteBookmark(RequestContext& context);
Response handleGetPlayQueue(RequestContext& context);
Response handleSavePlayQueue(RequestContext& context);
} // namespace lms::api::subsonic
@@ -0,0 +1,634 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "Browsing.hpp"
#include "core/ILogger.hpp"
#include "core/Random.hpp"
#include "core/Service.hpp"
#include "database/Artist.hpp"
#include "database/Cluster.hpp"
#include "database/Directory.hpp"
#include "database/MediaLibrary.hpp"
#include "database/Release.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/User.hpp"
#include "services/feedback/IFeedbackService.hpp"
#include "services/recommendation/IRecommendationService.hpp"
#include "services/scrobbling/IScrobblingService.hpp"
#include "ParameterParsing.hpp"
#include "SubsonicId.hpp"
#include "responses/Album.hpp"
#include "responses/AlbumInfo.hpp"
#include "responses/Artist.hpp"
#include "responses/Genre.hpp"
#include "responses/Song.hpp"
namespace lms::api::subsonic
{
using namespace db;
static const unsigned long long reportedDummyDateULong{ 946684800000ULL }; // 2000-01-01T00:00:00 UTC
namespace
{
std::vector<Directory::pointer> getRootDirectories(Session& session, MediaLibraryId libraryId)
{
std::vector<Directory::pointer> res;
if (libraryId.isValid())
{
if (const MediaLibrary::pointer library{ MediaLibrary::find(session, libraryId) })
{
if (Directory::pointer rootDirectory{ Directory::find(session, library->getPath()) })
res.push_back(rootDirectory);
}
}
else
{
res = Directory::findRootDirectories(session).results;
}
return res;
}
struct IndexComparator
{
constexpr bool operator()(char lhs, char rhs) const
{
if (lhs == '#' && std::isalpha(rhs))
return false;
if (rhs == '#' && std::isalpha(lhs))
return true;
return lhs < rhs;
}
};
using IndexMap = std::map<char, std::vector<Directory::pointer>, IndexComparator>;
void getIndexedChildDirectories(RequestContext& context, const Directory::pointer& parentDirectory, IndexMap& res)
{
Directory::FindParameters params;
params.setParentDirectory(parentDirectory->getId());
Directory::find(context.dbSession, params, [&](const Directory::pointer& directory) {
const std::string_view name{ directory->getName() };
assert(!name.empty());
char sortChar;
if (name.empty() || !std::isalpha(name[0]))
sortChar = '#';
else
sortChar = std::toupper(name[0]);
res[sortChar].push_back(directory);
});
}
std::vector<TrackId> 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<ArtistId> artistIds{ core::Service<recommendation::IRecommendationService>::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.createReadTransaction() };
std::vector<TrackId> tracks;
tracks.reserve(count);
for (const ArtistId id : artistIds)
{
Track::FindParameters params;
params.setArtist(id);
params.setRange(Range{ 0, meanTrackCountPerArtist });
params.setSortMethod(TrackSortMethod::Random);
const auto artistTracks{ Track::findIds(context.dbSession, params) };
tracks.insert(std::end(tracks),
std::begin(artistTracks.results),
std::end(artistTracks.results));
}
return tracks;
}
std::vector<TrackId> 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<ReleaseId> releaseIds{ core::Service<recommendation::IRecommendationService>::get()->getSimilarReleases(releaseId, similarReleaseCount) };
releaseIds.push_back(releaseId);
const std::size_t meanTrackCountPerRelease{ (count / releaseIds.size()) + 1 };
auto transaction{ context.dbSession.createReadTransaction() };
std::vector<TrackId> tracks;
tracks.reserve(count);
for (const ReleaseId id : releaseIds)
{
Track::FindParameters params;
params.setRelease(id);
params.setRange(Range{ 0, meanTrackCountPerRelease });
params.setSortMethod(TrackSortMethod::Random);
const auto releaseTracks{ Track::findIds(context.dbSession, params) };
tracks.insert(std::end(tracks),
std::begin(releaseTracks.results),
std::end(releaseTracks.results));
}
return tracks;
}
std::vector<TrackId> findSimilarSongs(RequestContext&, TrackId trackId, std::size_t count)
{
return core::Service<recommendation::IRecommendationService>::get()->findSimilarTracks({ trackId }, count);
}
Response handleGetSimilarSongsRequestCommon(RequestContext& context, bool id3)
{
// Optional params
std::size_t count{ getParameterAs<std::size_t>(context.parameters, "count").value_or(50) };
if (count > defaultMaxCountSize)
throw ParameterValueTooHighGenericError{ "count", defaultMaxCountSize };
std::vector<TrackId> tracks;
if (const auto artistId{ getParameterAs<ArtistId>(context.parameters, "id") })
tracks = findSimilarSongs(context, *artistId, count);
else if (const auto releaseId{ getParameterAs<ReleaseId>(context.parameters, "id") })
tracks = findSimilarSongs(context, *releaseId, count);
else if (const auto trackId{ getParameterAs<TrackId>(context.parameters, "id") })
tracks = findSimilarSongs(context, *trackId, count);
else
throw BadParameterGenericError{ "id" };
core::random::shuffleContainer(tracks);
auto transaction{ context.dbSession.createReadTransaction() };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& similarSongsNode{ response.createNode(id3 ? Response::Node::Key{ "similarSongs2" } : Response::Node::Key{ "similarSongs" }) };
for (const TrackId trackId : tracks)
{
const Track::pointer track{ Track::find(context.dbSession, trackId) };
similarSongsNode.addArrayChild("song", createSongNode(context, track, context.user));
}
return response;
}
Release::pointer getReleaseFromDirectory(Session& session, DirectoryId directoryId)
{
auto transaction{ session.createReadTransaction() };
Release::FindParameters params;
params.setDirectory(directoryId);
params.setRange(Range{ 0, 1 }); // only support 1 directory <-> 1 release
Release::pointer res;
Release::find(session, params, [&](const Release::pointer& release) {
res = release;
});
return res;
}
} // namespace
Response handleGetMusicFoldersRequest(RequestContext& context)
{
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& musicFoldersNode{ response.createNode("musicFolders") };
auto transaction{ context.dbSession.createReadTransaction() };
MediaLibrary::find(context.dbSession, [&](const MediaLibrary::pointer& library) {
Response::Node& musicFolderNode{ musicFoldersNode.createArrayChild("musicFolder") };
musicFolderNode.setAttribute("id", idToString(library->getId()));
musicFolderNode.setAttribute("name", library->getName());
});
return response;
}
Response handleGetIndexesRequest(RequestContext& context)
{
// Optional params
const MediaLibraryId mediaLibrary{ getParameterAs<MediaLibraryId>(context.parameters, "musicFolderId").value_or(MediaLibraryId{}) };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& indexesNode{ response.createNode("indexes") };
indexesNode.setAttribute("ignoredArticles", "");
indexesNode.setAttribute("lastModified", reportedDummyDateULong); // TODO report last file write?
auto transaction{ context.dbSession.createReadTransaction() };
const std::vector<Directory::pointer> rootDirectories{ getRootDirectories(context.dbSession, mediaLibrary) };
IndexMap indexedDirectories;
for (const Directory::pointer& rootdirectory : rootDirectories)
{
Track::FindParameters params;
params.setDirectory(rootdirectory->getId());
Track::find(context.dbSession, params, [&](const Track::pointer& track) {
indexesNode.addArrayChild("child", createSongNode(context, track, context.user));
});
getIndexedChildDirectories(context, rootdirectory, indexedDirectories);
}
for (const auto& [index, directories] : indexedDirectories)
{
Response::Node& indexNode{ indexesNode.createArrayChild("index") };
indexNode.setAttribute("name", std::string{ index });
for (const Directory::pointer& directory : directories)
{
// Legacy behavior: all sub directories are considered as artists (even if this is just containing an album, or just an intermediary directory)
Response::Node childNode;
childNode.setAttribute("id", idToString(directory->getId()));
childNode.setAttribute("name", directory->getName());
indexNode.addArrayChild("artist", std::move(childNode));
}
}
return response;
}
Response handleGetMusicDirectoryRequest(RequestContext& context)
{
// Mandatory params
const auto directoryId{ getMandatoryParameterAs<DirectoryId>(context.parameters, "id") };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& directoryNode{ response.createNode("directory") };
auto transaction{ context.dbSession.createReadTransaction() };
const Directory::pointer directory{ Directory::find(context.dbSession, directoryId) };
if (!directory)
throw RequestedDataNotFoundError{};
if (const Release::pointer release{ getReleaseFromDirectory(context.dbSession, directoryId) })
{
directoryNode.setAttribute("playCount", core::Service<scrobbling::IScrobblingService>::get()->getCount(context.user->getId(), release->getId()));
if (const Wt::WDateTime dateTime{ core::Service<feedback::IFeedbackService>::get()->getStarredDateTime(context.user->getId(), release->getId()) }; dateTime.isValid())
directoryNode.setAttribute("starred", core::stringUtils::toISO8601String(dateTime));
}
directoryNode.setAttribute("id", idToString(directory->getId()));
directoryNode.setAttribute("name", directory->getName());
// Original Subsonic does not report parent if this the parent directory is the root directory
if (const Directory::pointer parentDirectory{ directory->getParentDirectory() })
directoryNode.setAttribute("parent", idToString(parentDirectory->getId()));
// list all sub directories
{
Directory::FindParameters params;
params.setParentDirectory(directory->getId());
Directory::find(context.dbSession, params, [&](const Directory::pointer& subDirectory) {
const Release::pointer release{ getReleaseFromDirectory(context.dbSession, subDirectory->getId()) };
if (release)
{
directoryNode.addArrayChild("child", createAlbumNode(context, release, false, subDirectory));
}
else
{
Response::Node childNode;
childNode.setAttribute("id", idToString(subDirectory->getId()));
childNode.setAttribute("title", subDirectory->getName());
childNode.setAttribute("isDir", true);
childNode.setAttribute("parent", idToString(directory->getId()));
directoryNode.addArrayChild("child", std::move(childNode));
}
});
}
// list all tracks
{
Track::FindParameters params;
params.setDirectory(directory->getId());
Track::find(context.dbSession, params, [&](const Track::pointer& track) {
directoryNode.addArrayChild("child", createSongNode(context, track, context.user));
});
}
return response;
}
Response handleGetGenresRequest(RequestContext& context)
{
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& genresNode{ response.createNode("genres") };
auto transaction{ context.dbSession.createReadTransaction() };
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(context, cluster));
}
return response;
}
Response handleGetArtistsRequest(RequestContext& context)
{
// Optional params
const MediaLibraryId mediaLibrary{ getParameterAs<MediaLibraryId>(context.parameters, "musicFolderId").value_or(MediaLibraryId{}) };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& artistsNode{ response.createNode("artists") };
artistsNode.setAttribute("ignoredArticles", "");
artistsNode.setAttribute("lastModified", reportedDummyDateULong); // TODO report last file write?
Artist::FindParameters parameters;
{
auto transaction{ context.dbSession.createReadTransaction() };
parameters.setSortMethod(ArtistSortMethod::SortName);
switch (context.user->getSubsonicArtistListMode())
{
case SubsonicArtistListMode::AllArtists:
break;
case SubsonicArtistListMode::ReleaseArtists:
parameters.setLinkType(TrackArtistLinkType::ReleaseArtist);
break;
case SubsonicArtistListMode::TrackArtists:
parameters.setLinkType(TrackArtistLinkType::Artist);
break;
}
}
parameters.setMediaLibrary(mediaLibrary);
// This endpoint does not scale: make sort lived transactions in order not to block the whole application
// first pass: dispatch the artists by first letter
LMS_LOG(API_SUBSONIC, DEBUG, "GetArtists: fetching all artists...");
std::map<char, std::vector<ArtistId>> artistsSortedByFirstChar;
std::size_t currentArtistOffset{ 0 };
constexpr std::size_t batchSize{ 100 };
bool hasMoreArtists{ true };
while (hasMoreArtists)
{
auto transaction{ context.dbSession.createReadTransaction() };
parameters.setRange(Range{ currentArtistOffset, batchSize });
const auto artists{ Artist::find(context.dbSession, parameters) };
for (const Artist::pointer& artist : artists.results)
{
std::string_view sortName{ artist->getSortName() };
char sortChar;
if (sortName.empty() || !std::isalpha(sortName[0]))
sortChar = '#';
else
sortChar = std::toupper(sortName[0]);
artistsSortedByFirstChar[sortChar].push_back(artist->getId());
}
hasMoreArtists = artists.moreResults;
currentArtistOffset += artists.results.size();
}
// second pass: add each artist
LMS_LOG(API_SUBSONIC, DEBUG, "GetArtists: constructing response...");
for (const auto& [sortChar, artistIds] : artistsSortedByFirstChar)
{
Response::Node& indexNode{ artistsNode.createArrayChild("index") };
indexNode.setAttribute("name", std::string{ sortChar });
for (const ArtistId artistId : artistIds)
{
auto transaction{ context.dbSession.createReadTransaction() };
if (const Artist::pointer artist{ Artist::find(context.dbSession, artistId) })
indexNode.addArrayChild("artist", createArtistNode(context, artist));
}
}
return response;
}
Response handleGetArtistRequest(RequestContext& context)
{
// Mandatory params
ArtistId id{ getMandatoryParameterAs<ArtistId>(context.parameters, "id") };
auto transaction{ context.dbSession.createReadTransaction() };
const Artist::pointer artist{ Artist::find(context.dbSession, id) };
if (!artist)
throw RequestedDataNotFoundError{};
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node artistNode{ createArtistNode(context, artist) };
const auto releases{ Release::find(context.dbSession, Release::FindParameters{}.setArtist(artist->getId())) };
for (const Release::pointer& release : releases.results)
artistNode.addArrayChild("album", createAlbumNode(context, release, true /* id3 */));
response.addNode("artist", std::move(artistNode));
return response;
}
Response handleGetAlbumRequest(RequestContext& context)
{
// Mandatory params
ReleaseId id{ getMandatoryParameterAs<ReleaseId>(context.parameters, "id") };
auto transaction{ context.dbSession.createReadTransaction() };
Release::pointer release{ Release::find(context.dbSession, id) };
if (!release)
throw RequestedDataNotFoundError{};
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node albumNode{ createAlbumNode(context, release, true /* id3 */) };
const auto tracks{ Track::find(context.dbSession, Track::FindParameters{}.setRelease(id).setSortMethod(TrackSortMethod::Release)) };
for (const Track::pointer& track : tracks.results)
albumNode.addArrayChild("song", createSongNode(context, track, true /* id3 */));
response.addNode("album", std::move(albumNode));
return response;
}
Response handleGetSongRequest(RequestContext& context)
{
// Mandatory params
TrackId id{ getMandatoryParameterAs<TrackId>(context.parameters, "id") };
auto transaction{ context.dbSession.createReadTransaction() };
const Track::pointer track{ Track::find(context.dbSession, id) };
if (!track)
throw RequestedDataNotFoundError{};
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
response.addNode("song", createSongNode(context, track, context.user));
return response;
}
Response handleGetArtistInfo2Request(RequestContext& context)
{
// Mandatory params
ArtistId id{ getMandatoryParameterAs<ArtistId>(context.parameters, "id") };
// Optional params
std::size_t count{ getParameterAs<std::size_t>(context.parameters, "count").value_or(20) };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& artistInfoNode{ response.createNode(Response::Node::Key{ "artistInfo2" }) };
{
auto transaction{ context.dbSession.createReadTransaction() };
const Artist::pointer artist{ Artist::find(context.dbSession, id) };
if (!artist)
throw RequestedDataNotFoundError{};
std::optional<core::UUID> artistMBID{ artist->getMBID() };
if (artistMBID)
{
switch (context.responseFormat)
{
case ResponseFormat::json:
artistInfoNode.setAttribute("musicBrainzId", artistMBID->getAsString());
break;
case ResponseFormat::xml:
artistInfoNode.createChild("musicBrainzId").setValue(artistMBID->getAsString());
break;
}
}
}
auto similarArtistsId{ core::Service<recommendation::IRecommendationService>::get()->getSimilarArtists(id, { TrackArtistLinkType::Artist, TrackArtistLinkType::ReleaseArtist }, count) };
{
auto transaction{ context.dbSession.createReadTransaction() };
for (const ArtistId similarArtistId : similarArtistsId)
{
const Artist::pointer similarArtist{ Artist::find(context.dbSession, similarArtistId) };
if (similarArtist)
artistInfoNode.addArrayChild("similarArtist", createArtistNode(context, similarArtist));
}
}
return response;
}
Response handleGetAlbumInfo(RequestContext& context)
{
const db::DirectoryId directoryId{ getMandatoryParameterAs<db::DirectoryId>(context.parameters, "id") };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
{
auto transaction{ context.dbSession.createReadTransaction() };
if (db::Release::pointer release{ getReleaseFromDirectory(context.dbSession, directoryId) })
response.addNode("albumInfo", createAlbumInfoNode(context, release));
}
return response;
}
Response handleGetAlbumInfo2(RequestContext& context)
{
const db::ReleaseId releaseId{ getMandatoryParameterAs<db::ReleaseId>(context.parameters, "id") };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
{
auto transaction{ context.dbSession.createReadTransaction() };
if (db::Release::pointer release{ db::Release::find(context.dbSession, releaseId) })
response.addNode("albumInfo", createAlbumInfoNode(context, release));
}
return response;
}
Response handleGetSimilarSongsRequest(RequestContext& context)
{
return handleGetSimilarSongsRequestCommon(context, false /* no id3 */);
}
Response handleGetSimilarSongs2Request(RequestContext& context)
{
return handleGetSimilarSongsRequestCommon(context, true /* id3 */);
}
Response handleGetTopSongs(RequestContext& context)
{
// Mandatory params
std::string_view artistName{ getMandatoryParameterAs<std::string_view>(context.parameters, "artist") };
std::size_t count{ getParameterAs<std::size_t>(context.parameters, "count").value_or(50) };
if (count > defaultMaxCountSize)
throw ParameterValueTooHighGenericError{ "count", defaultMaxCountSize };
auto transaction{ context.dbSession.createReadTransaction() };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& topSongs{ response.createNode("topSongs") };
const auto artists{ Artist::find(context.dbSession, artistName) };
if (artists.size() == 1)
{
scrobbling::IScrobblingService::FindParameters params;
params.setUser(context.user->getId());
params.setRange(db::Range{ 0, count });
params.setArtist(artists.front()->getId());
const auto trackIds{ core::Service<scrobbling::IScrobblingService>::get()->getTopTracks(params) };
for (const TrackId trackId : trackIds.results)
{
if (Track::pointer track{ Track::find(context.dbSession, trackId) })
topSongs.addArrayChild("song", createSongNode(context, track, context.user));
}
}
return response;
}
} // namespace lms::api::subsonic
@@ -0,0 +1,42 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "RequestContext.hpp"
#include "SubsonicResponse.hpp"
namespace lms::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 handleGetAlbumInfo(RequestContext& context);
Response handleGetAlbumInfo2(RequestContext& context);
Response handleGetSimilarSongsRequest(RequestContext& context);
Response handleGetSimilarSongs2Request(RequestContext& context);
Response handleGetTopSongs(RequestContext& context);
} // namespace lms::api::subsonic
@@ -0,0 +1,214 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "MediaAnnotation.hpp"
#include <variant>
#include <vector>
#include "core/Service.hpp"
#include "database/ArtistId.hpp"
#include "database/Release.hpp"
#include "database/ReleaseId.hpp"
#include "database/Session.hpp"
#include "database/TrackId.hpp"
#include "database/User.hpp"
#include "services/feedback/IFeedbackService.hpp"
#include "services/scrobbling/IScrobblingService.hpp"
#include "ParameterParsing.hpp"
#include "SubsonicId.hpp"
namespace lms::api::subsonic
{
using namespace db;
namespace
{
struct StarParameters
{
std::vector<ArtistId> artistIds;
std::vector<ReleaseId> releaseIds;
std::vector<TrackId> trackIds;
std::vector<DirectoryId> directoryIds;
};
StarParameters getStarParameters(const Wt::Http::ParameterMap& parameters)
{
StarParameters res;
// id could be either a trackId or a directory id
res.directoryIds = getMultiParametersAs<DirectoryId>(parameters, "id");
res.trackIds = getMultiParametersAs<TrackId>(parameters, "id");
res.artistIds = getMultiParametersAs<ArtistId>(parameters, "artistId");
res.releaseIds = getMultiParametersAs<ReleaseId>(parameters, "albumId");
return res;
}
ReleaseId getReleaseFromDirectory(Session& session, DirectoryId directory)
{
auto transaction{ session.createReadTransaction() };
Release::FindParameters params;
params.setDirectory(directory);
params.setRange(Range{ 0, 1 }); // consider one directory <-> one release
Release::pointer res;
Release::find(session, params, [&](const Release::pointer& release) {
res = release;
});
return res ? res->getId() : ReleaseId{};
}
struct RatingParameters
{
std::variant<ArtistId, ReleaseId, TrackId, DirectoryId> id;
std::optional<Rating> rating;
};
RatingParameters getRatingParameters(const Wt::Http::ParameterMap& parameters)
{
RatingParameters res;
if (const auto artistId{ getParameterAs<ArtistId>(parameters, "id") })
res.id = *artistId;
else if (const auto releaseId{ getParameterAs<ReleaseId>(parameters, "id") })
res.id = *releaseId;
else if (const auto trackId{ getParameterAs<TrackId>(parameters, "id") })
res.id = *trackId;
else if (const auto directoryId{ getParameterAs<DirectoryId>(parameters, "id") })
res.id = *directoryId;
else
throw RequiredParameterMissingError{ "id" };
const int rating = getMandatoryParameterAs<int>(parameters, "rating"); // The rating between 1 and 5 (inclusive), or 0 to remove the rating
if (rating < 0 || rating > 5)
throw BadParameterGenericError{ "rating must be 0 or in range 1-5" };
if (rating > 0)
res.rating = rating;
return res;
}
} // namespace
Response handleStarRequest(RequestContext& context)
{
StarParameters params{ getStarParameters(context.parameters) };
for (const DirectoryId id : params.directoryIds)
{
if (const ReleaseId releaseId{ getReleaseFromDirectory(context.dbSession, id) }; releaseId.isValid())
core::Service<feedback::IFeedbackService>::get()->star(context.user->getId(), releaseId);
}
for (const ArtistId id : params.artistIds)
core::Service<feedback::IFeedbackService>::get()->star(context.user->getId(), id);
for (const ReleaseId id : params.releaseIds)
core::Service<feedback::IFeedbackService>::get()->star(context.user->getId(), id);
for (const TrackId id : params.trackIds)
core::Service<feedback::IFeedbackService>::get()->star(context.user->getId(), id);
return Response::createOkResponse(context.serverProtocolVersion);
}
Response handleUnstarRequest(RequestContext& context)
{
const StarParameters params{ getStarParameters(context.parameters) };
for (const DirectoryId id : params.directoryIds)
{
if (const ReleaseId releaseId{ getReleaseFromDirectory(context.dbSession, id) }; releaseId.isValid())
core::Service<feedback::IFeedbackService>::get()->unstar(context.user->getId(), releaseId);
}
for (const ArtistId id : params.artistIds)
core::Service<feedback::IFeedbackService>::get()->unstar(context.user->getId(), id);
for (const ReleaseId id : params.releaseIds)
core::Service<feedback::IFeedbackService>::get()->unstar(context.user->getId(), id);
for (const TrackId id : params.trackIds)
core::Service<feedback::IFeedbackService>::get()->unstar(context.user->getId(), id);
return Response::createOkResponse(context.serverProtocolVersion);
}
Response handleSetRating(RequestContext& context)
{
const RatingParameters params{ getRatingParameters(context.parameters) };
if (const ArtistId * artistId{ std::get_if<ArtistId>(&params.id) })
core::Service<feedback::IFeedbackService>::get()->setRating(context.user->getId(), *artistId, params.rating);
else if (const DirectoryId * directoryId{ std::get_if<DirectoryId>(&params.id) })
{
if (const ReleaseId releaseId{ getReleaseFromDirectory(context.dbSession, *directoryId) }; releaseId.isValid())
core::Service<feedback::IFeedbackService>::get()->setRating(context.user->getId(), releaseId, params.rating);
}
else if (const ReleaseId * releaseId{ std::get_if<ReleaseId>(&params.id) })
core::Service<feedback::IFeedbackService>::get()->setRating(context.user->getId(), *releaseId, params.rating);
else if (const TrackId * trackId{ std::get_if<TrackId>(&params.id) })
core::Service<feedback::IFeedbackService>::get()->setRating(context.user->getId(), *trackId, params.rating);
return Response::createOkResponse(context.serverProtocolVersion);
}
Response handleScrobble(RequestContext& context)
{
const std::vector<TrackId> ids{ getMandatoryMultiParametersAs<TrackId>(context.parameters, "id") };
const std::vector<unsigned long> times{ getMultiParametersAs<unsigned long>(context.parameters, "time") };
const bool submission{ getParameterAs<bool>(context.parameters, "submission").value_or(true) };
// playing now => only one at a time
if (!submission && ids.size() > 1)
throw BadParameterGenericError{ "id" };
// if multiple submissions, must have all times
if (ids.size() > 1 && ids.size() != times.size())
throw BadParameterGenericError{ "time" };
if (!submission)
{
core::Service<scrobbling::IScrobblingService>::get()->listenStarted({ context.user->getId(), ids.front() });
}
else
{
if (times.empty())
{
core::Service<scrobbling::IScrobblingService>::get()->listenFinished({ context.user->getId(), ids.front() });
}
else
{
for (std::size_t i{}; i < ids.size(); ++i)
{
const TrackId trackId{ ids[i] };
const unsigned long time{ times[i] };
core::Service<scrobbling::IScrobblingService>::get()->addTimedListen({ { context.user->getId(), trackId }, Wt::WDateTime::fromTime_t(static_cast<std::time_t>(time / 1000)) });
}
}
}
return Response::createOkResponse(context.serverProtocolVersion);
}
} // namespace lms::api::subsonic
@@ -0,0 +1,31 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "RequestContext.hpp"
#include "SubsonicResponse.hpp"
namespace lms::api::subsonic
{
Response handleStarRequest(RequestContext& context);
Response handleUnstarRequest(RequestContext& context);
Response handleSetRating(RequestContext& context);
Response handleScrobble(RequestContext& context);
} // namespace lms::api::subsonic
@@ -0,0 +1,69 @@
/*
* Copyright (C) 2020 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "MediaLibraryScanning.hpp"
#include "core/Service.hpp"
#include "services/scanner/IScannerService.hpp"
namespace lms::api::subsonic::Scan
{
using namespace scanner;
namespace
{
Response::Node createStatusResponseNode()
{
Response::Node statusResponse;
const IScannerService::Status scanStatus{ core::Service<IScannerService>::get()->getStatus() };
statusResponse.setAttribute("scanning", scanStatus.currentState == IScannerService::State::InProgress);
if (scanStatus.currentState == IScannerService::State::InProgress)
{
std::size_t count{};
if (scanStatus.currentScanStepStats && scanStatus.currentScanStepStats->currentStep == ScanStep::ScanFiles)
count = scanStatus.currentScanStepStats->processedElems;
statusResponse.setAttribute("count", count);
}
return statusResponse;
}
} // namespace
Response handleGetScanStatus(RequestContext& context)
{
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
response.addNode("scanStatus", createStatusResponseNode());
return response;
}
Response handleStartScan(RequestContext& context)
{
core::Service<IScannerService>::get()->requestImmediateScan();
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
response.addNode("scanStatus", createStatusResponseNode());
return response;
}
} // namespace lms::api::subsonic::Scan
@@ -0,0 +1,29 @@
/*
* Copyright (C) 2020 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "RequestContext.hpp"
#include "SubsonicResponse.hpp"
namespace lms::api::subsonic::Scan
{
Response handleGetScanStatus(RequestContext& context);
Response handleStartScan(RequestContext& context);
} // namespace lms::api::subsonic::Scan
@@ -0,0 +1,359 @@
/*
* Copyright (C) 2020 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "MediaRetrieval.hpp"
#include "av/IAudioFile.hpp"
#include "av/RawResourceHandlerCreator.hpp"
#include "av/TranscodingParameters.hpp"
#include "av/TranscodingResourceHandlerCreator.hpp"
#include "av/Types.hpp"
#include "core/FileResourceHandlerCreator.hpp"
#include "core/ILogger.hpp"
#include "core/IResourceHandler.hpp"
#include "core/String.hpp"
#include "core/Utils.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/TrackLyrics.hpp"
#include "database/User.hpp"
#include "services/artwork/IArtworkService.hpp"
#include "ParameterParsing.hpp"
#include "SubsonicId.hpp"
#include "responses/Lyrics.hpp"
namespace lms::api::subsonic
{
using namespace db;
namespace
{
std::optional<av::transcoding::OutputFormat> subsonicStreamFormatToAvOutputFormat(std::string_view format)
{
for (const auto& [str, avFormat] : std::initializer_list<std::pair<std::string_view, av::transcoding::OutputFormat>>{
{ "mp3", av::transcoding::OutputFormat::MP3 },
{ "opus", av::transcoding::OutputFormat::OGG_OPUS },
{ "vorbis", av::transcoding::OutputFormat::OGG_VORBIS },
})
{
if (core::stringUtils::stringCaseInsensitiveEqual(str, format))
return avFormat;
}
return std::nullopt;
}
av::transcoding::OutputFormat userTranscodeFormatToAvFormat(db::TranscodingOutputFormat format)
{
switch (format)
{
case db::TranscodingOutputFormat::MP3:
return av::transcoding::OutputFormat::MP3;
case db::TranscodingOutputFormat::OGG_OPUS:
return av::transcoding::OutputFormat::OGG_OPUS;
case db::TranscodingOutputFormat::MATROSKA_OPUS:
return av::transcoding::OutputFormat::MATROSKA_OPUS;
case db::TranscodingOutputFormat::OGG_VORBIS:
return av::transcoding::OutputFormat::OGG_VORBIS;
case db::TranscodingOutputFormat::WEBM_VORBIS:
return av::transcoding::OutputFormat::WEBM_VORBIS;
}
return av::transcoding::OutputFormat::OGG_OPUS;
}
bool isCodecCompatibleWithOutputFormat(av::DecodingCodec codec, av::transcoding::OutputFormat outputFormat)
{
switch (outputFormat)
{
case av::transcoding::OutputFormat::MP3:
return codec == av::DecodingCodec::MP3;
case av::transcoding::OutputFormat::OGG_OPUS:
case av::transcoding::OutputFormat::MATROSKA_OPUS:
return codec == av::DecodingCodec::OPUS;
case av::transcoding::OutputFormat::OGG_VORBIS:
case av::transcoding::OutputFormat::WEBM_VORBIS:
return codec == av::DecodingCodec::VORBIS;
}
return true;
}
struct StreamParameters
{
av::transcoding::InputParameters inputParameters;
std::optional<av::transcoding::OutputParameters> outputParameters;
bool estimateContentLength{};
};
bool isOutputFormatCompatible(const std::filesystem::path& trackPath, av::transcoding::OutputFormat outputFormat)
{
try
{
const auto audioFile{ av::parseAudioFile(trackPath) };
const auto streamInfo{ audioFile->getBestStreamInfo() };
if (!streamInfo)
throw RequestedDataNotFoundError{}; // TODO 404?
return isCodecCompatibleWithOutputFormat(streamInfo->codec, outputFormat);
}
catch (const av::Exception& e)
{
// TODO 404?
throw RequestedDataNotFoundError{};
}
}
StreamParameters getStreamParameters(RequestContext& context)
{
// Mandatory params
const TrackId id{ getMandatoryParameterAs<TrackId>(context.parameters, "id") };
// Optional params
std::size_t maxBitRate{ getParameterAs<std::size_t>(context.parameters, "maxBitRate").value_or(0) * 1000 }; // "If set to zero, no limit is imposed", given in kpbs
const std::string format{ getParameterAs<std::string>(context.parameters, "format").value_or("") };
std::size_t timeOffset{ getParameterAs<std::size_t>(context.parameters, "timeOffset").value_or(0) };
bool estimateContentLength{ getParameterAs<bool>(context.parameters, "estimateContentLength").value_or(false) };
StreamParameters parameters;
auto transaction{ context.dbSession.createReadTransaction() };
const auto track{ Track::find(context.dbSession, id) };
if (!track)
throw RequestedDataNotFoundError{};
parameters.inputParameters.trackPath = track->getAbsoluteFilePath();
parameters.inputParameters.duration = track->getDuration();
parameters.estimateContentLength = estimateContentLength;
if (format == "raw") // raw => no transcoding
return parameters;
std::optional<av::transcoding::OutputFormat> requestedFormat{ subsonicStreamFormatToAvOutputFormat(format) };
if (!requestedFormat)
{
if (context.user->getSubsonicEnableTranscodingByDefault())
requestedFormat = userTranscodeFormatToAvFormat(context.user->getSubsonicDefaultTranscodingOutputFormat());
}
if (!requestedFormat && (maxBitRate == 0 || track->getBitrate() <= maxBitRate))
{
LMS_LOG(API_SUBSONIC, DEBUG, "File's bitrate is compatible with parameters => no transcoding");
return parameters; // no transcoding needed
}
// scan the file to check if its format is compatible with the actual requested format
// same codec => apply max bitrate
// otherwise => apply default bitrate (because we can't really compare bitrates between formats) + max bitrate)
std::size_t bitrate{};
if (requestedFormat && isOutputFormatCompatible(track->getAbsoluteFilePath(), *requestedFormat))
{
if (maxBitRate == 0 || track->getBitrate() <= maxBitRate)
{
LMS_LOG(API_SUBSONIC, DEBUG, "File's bitrate and format are compatible with parameters => no transcoding");
return parameters; // no transcoding needed
}
bitrate = maxBitRate;
}
if (!requestedFormat)
requestedFormat = userTranscodeFormatToAvFormat(context.user->getSubsonicDefaultTranscodingOutputFormat());
if (!bitrate)
bitrate = std::min<std::size_t>(context.user->getSubsonicDefaultTranscodingOutputBitrate(), maxBitRate);
av::transcoding::OutputParameters& outputParameters{ parameters.outputParameters.emplace() };
outputParameters.stripMetadata = false; // We want clients to use metadata (offline use, replay gain, etc.)
outputParameters.offset = std::chrono::seconds{ timeOffset };
outputParameters.format = *requestedFormat;
outputParameters.bitrate = bitrate;
return parameters;
}
} // namespace
Response handleGetLyrics(RequestContext& context)
{
std::string artistName{ getParameterAs<std::string>(context.parameters, "artist").value_or("") };
std::string titleName{ getParameterAs<std::string>(context.parameters, "title").value_or("") };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
// best effort search, as this API is really limited
auto transaction{ context.dbSession.createReadTransaction() };
db::Track::FindParameters params;
params.name = titleName;
params.artistName = artistName;
params.range = Range{ 0, 2 };
// Choice: we return nothing if there are too many results
const auto tracks{ db::Track::findIds(context.dbSession, params) };
if (tracks.results.size() == 1)
{
// Choice: we return only the first lyrics if the track has many lyrics
db::TrackLyrics::FindParameters lyricsParams;
lyricsParams.setTrack(tracks.results[0]);
lyricsParams.setSortMethod(TrackLyricsSortMethod::ExternalFirst);
lyricsParams.setRange(db::Range{ 0, 1 });
db::TrackLyrics::find(context.dbSession, lyricsParams, [&](const db::TrackLyrics::pointer& lyrics) {
response.addNode("lyrics", createLyricsNode(context, lyrics));
});
}
return response;
}
Response handleGetLyricsBySongId(RequestContext& context)
{
// mandatory params
db::TrackId id{ getMandatoryParameterAs<db::TrackId>(context.parameters, "id") };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& lyricsList{ response.createNode("lyricsList") };
lyricsList.createEmptyArrayChild("structuredLyrics");
auto transaction{ context.dbSession.createReadTransaction() };
const db::Track::pointer track{ db::Track::find(context.dbSession, id) };
if (track)
{
db::TrackLyrics::FindParameters params;
params.setTrack(track->getId());
params.setExternal(true); // First try to only report external lyrics as they are often duplicate of embedded lyrics and support more features
bool hasExternalLyrics{};
db::TrackLyrics::find(context.dbSession, params, [&](const db::TrackLyrics::pointer& lyrics) {
lyricsList.addArrayChild("structuredLyrics", createStructuredLyricsNode(context, lyrics));
hasExternalLyrics = true;
});
if (!hasExternalLyrics)
{
params.setExternal(false);
db::TrackLyrics::find(context.dbSession, params, [&](const db::TrackLyrics::pointer& lyrics) {
lyricsList.addArrayChild("structuredLyrics", createStructuredLyricsNode(context, lyrics));
});
}
}
return response;
}
void handleDownload(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response)
{
std::shared_ptr<IResourceHandler> resourceHandler;
Wt::Http::ResponseContinuation* continuation{ request.continuation() };
if (!continuation)
{
// Mandatory params
db::TrackId id{ getMandatoryParameterAs<db::TrackId>(context.parameters, "id") };
std::filesystem::path trackPath;
{
auto transaction{ context.dbSession.createReadTransaction() };
auto track{ Track::find(context.dbSession, id) };
if (!track)
throw RequestedDataNotFoundError{};
trackPath = track->getAbsoluteFilePath();
}
resourceHandler = av::createRawResourceHandler(trackPath);
}
else
{
resourceHandler = Wt::cpp17::any_cast<std::shared_ptr<IResourceHandler>>(continuation->data());
}
continuation = resourceHandler->processRequest(request, response);
if (continuation)
continuation->setData(resourceHandler);
}
void handleStream(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response)
{
std::shared_ptr<IResourceHandler> resourceHandler;
try
{
Wt::Http::ResponseContinuation* continuation = request.continuation();
if (!continuation)
{
StreamParameters streamParameters{ getStreamParameters(context) };
if (streamParameters.outputParameters)
resourceHandler = av::transcoding::createResourceHandler(streamParameters.inputParameters, *streamParameters.outputParameters, streamParameters.estimateContentLength);
else
resourceHandler = av::createRawResourceHandler(streamParameters.inputParameters.trackPath);
}
else
{
resourceHandler = Wt::cpp17::any_cast<std::shared_ptr<IResourceHandler>>(continuation->data());
}
continuation = resourceHandler->processRequest(request, response);
if (continuation)
continuation->setData(resourceHandler);
}
catch (const av::Exception& e)
{
LMS_LOG(API_SUBSONIC, ERROR, "Caught Av exception: " << e.what());
}
}
void handleGetCoverArt(RequestContext& context, const Wt::Http::Request& /*request*/, Wt::Http::Response& response)
{
// Mandatory params
const auto trackId{ getParameterAs<TrackId>(context.parameters, "id") };
const auto releaseId{ getParameterAs<ReleaseId>(context.parameters, "id") };
const auto artistId{ getParameterAs<ArtistId>(context.parameters, "id") };
if (!trackId && !releaseId && !artistId)
throw BadParameterGenericError{ "id" };
std::size_t size{ getParameterAs<std::size_t>(context.parameters, "size").value_or(1024) };
size = core::utils::clamp(size, std::size_t{ 32 }, std::size_t{ 2048 });
std::shared_ptr<image::IEncodedImage> cover;
if (trackId)
cover = core::Service<cover::IArtworkService>::get()->getTrackImage(*trackId, size);
else if (releaseId)
cover = core::Service<cover::IArtworkService>::get()->getReleaseCover(*releaseId, size);
else if (artistId)
cover = core::Service<cover::IArtworkService>::get()->getArtistImage(*artistId, size);
if (!cover && context.enableDefaultCover && !artistId)
cover = core::Service<cover::IArtworkService>::get()->getDefaultReleaseCover();
if (!cover)
{
response.setStatus(404);
return;
}
response.out().write(reinterpret_cast<const char*>(cover->getData()), cover->getDataSize());
response.setMimeType(std::string{ cover->getMimeType() });
}
} // namespace lms::api::subsonic
@@ -0,0 +1,35 @@
/*
* Copyright (C) 2020 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <Wt/Http/Request.h>
#include <Wt/Http/Response.h>
#include "RequestContext.hpp"
namespace lms::api::subsonic
{
Response handleGetLyrics(RequestContext& context);
Response handleGetLyricsBySongId(RequestContext& context);
void handleDownload(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response);
void handleStream(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response);
void handleGetCoverArt(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response);
} // namespace lms::api::subsonic
@@ -0,0 +1,204 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "Playlists.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/TrackList.hpp"
#include "database/User.hpp"
#include "ParameterParsing.hpp"
#include "SubsonicId.hpp"
#include "responses/Playlist.hpp"
#include "responses/Song.hpp"
namespace lms::api::subsonic
{
using namespace db;
Response handleGetPlaylistsRequest(RequestContext& context)
{
auto transaction{ context.dbSession.createReadTransaction() };
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& playlistsNode{ response.createNode("playlists") };
TrackList::FindParameters params;
params.setUser(context.user->getId());
params.setType(TrackListType::Playlist);
auto tracklistIds{ TrackList::find(context.dbSession, params) };
for (const TrackListId trackListId : tracklistIds.results)
{
const TrackList::pointer trackList{ TrackList::find(context.dbSession, trackListId) };
playlistsNode.addArrayChild("playlist", createPlaylistNode(trackList, context.dbSession));
}
return response;
}
Response handleGetPlaylistRequest(RequestContext& context)
{
// Mandatory params
TrackListId trackListId{ getMandatoryParameterAs<TrackListId>(context.parameters, "id") };
auto transaction{ context.dbSession.createReadTransaction() };
TrackList::pointer tracklist{ TrackList::find(context.dbSession, trackListId) };
if (!tracklist)
throw RequestedDataNotFoundError{};
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node playlistNode{ createPlaylistNode(tracklist, context.dbSession) };
auto entries{ tracklist->getEntries() };
for (const TrackListEntry::pointer& entry : entries.results)
playlistNode.addArrayChild("entry", createSongNode(context, entry->getTrack(), context.user));
response.addNode("playlist", std::move(playlistNode));
return response;
}
Response handleCreatePlaylistRequest(RequestContext& context)
{
// Optional params
const auto id{ getParameterAs<TrackListId>(context.parameters, "playlistId") };
auto name{ getParameterAs<std::string>(context.parameters, "name") };
std::vector<TrackId> trackIds{ getMultiParametersAs<TrackId>(context.parameters, "songId") };
if (!name && !id)
throw RequiredParameterMissingError{ "name or playlistId" };
auto transaction{ context.dbSession.createWriteTransaction() };
TrackList::pointer tracklist;
if (id)
{
tracklist = TrackList::find(context.dbSession, *id);
if (!tracklist
|| tracklist->getUser() != context.user
|| tracklist->getType() != TrackListType::Playlist)
{
throw RequestedDataNotFoundError{};
}
if (name)
tracklist.modify()->setName(*name);
}
else
{
tracklist = context.dbSession.create<TrackList>(*name, TrackListType::Playlist, false, context.user);
}
for (const TrackId trackId : trackIds)
{
Track::pointer track{ Track::find(context.dbSession, trackId) };
if (!track)
continue;
context.dbSession.create<TrackListEntry>(track, tracklist);
}
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node playlistNode{ createPlaylistNode(tracklist, context.dbSession) };
auto entries{ tracklist->getEntries() };
for (const TrackListEntry::pointer& entry : entries.results)
playlistNode.addArrayChild("entry", createSongNode(context, entry->getTrack(), context.user));
response.addNode("playlist", std::move(playlistNode));
return response;
}
Response handleUpdatePlaylistRequest(RequestContext& context)
{
// Mandatory params
TrackListId id{ getMandatoryParameterAs<TrackListId>(context.parameters, "playlistId") };
// Optional parameters
auto name{ getParameterAs<std::string>(context.parameters, "name") };
auto isPublic{ getParameterAs<bool>(context.parameters, "public") };
std::vector<TrackId> trackIdsToAdd{ getMultiParametersAs<TrackId>(context.parameters, "songIdToAdd") };
std::vector<std::size_t> trackPositionsToRemove{ getMultiParametersAs<std::size_t>(context.parameters, "songIndexToRemove") };
auto transaction{ context.dbSession.createWriteTransaction() };
TrackList::pointer tracklist{ TrackList::find(context.dbSession, id) };
if (!tracklist
|| tracklist->getUser() != context.user
|| tracklist->getType() != TrackListType::Playlist)
{
throw RequestedDataNotFoundError{};
}
if (name)
tracklist.modify()->setName(*name);
if (isPublic)
tracklist.modify()->setIsPublic(*isPublic);
{
// Remove from end to make indexes stable
std::sort(std::begin(trackPositionsToRemove), std::end(trackPositionsToRemove), std::greater<std::size_t>());
for (std::size_t trackPositionToRemove : trackPositionsToRemove)
{
auto entry{ tracklist->getEntry(trackPositionToRemove) };
if (entry)
entry.remove();
}
}
// Add tracks
for (const TrackId trackIdToAdd : trackIdsToAdd)
{
Track::pointer track{ Track::find(context.dbSession, trackIdToAdd) };
if (!track)
continue;
context.dbSession.create<TrackListEntry>(track, tracklist);
}
return Response::createOkResponse(context.serverProtocolVersion);
}
Response handleDeletePlaylistRequest(RequestContext& context)
{
TrackListId id{ getMandatoryParameterAs<TrackListId>(context.parameters, "id") };
auto transaction{ context.dbSession.createWriteTransaction() };
TrackList::pointer tracklist{ TrackList::find(context.dbSession, id) };
if (!tracklist
|| tracklist->getUser() != context.user
|| tracklist->getType() != TrackListType::Playlist)
{
throw RequestedDataNotFoundError{};
}
tracklist.remove();
return Response::createOkResponse(context.serverProtocolVersion);
}
} // namespace lms::api::subsonic
@@ -0,0 +1,32 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "RequestContext.hpp"
#include "SubsonicResponse.hpp"
namespace lms::api::subsonic
{
Response handleGetPlaylistsRequest(RequestContext& context);
Response handleGetPlaylistRequest(RequestContext& context);
Response handleCreatePlaylistRequest(RequestContext& context);
Response handleUpdatePlaylistRequest(RequestContext& context);
Response handleDeletePlaylistRequest(RequestContext& context);
} // namespace lms::api::subsonic
@@ -0,0 +1,379 @@
/*
* Copyright (C) 2020 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "Searching.hpp"
#include <chrono>
#include <map>
#include <mutex>
#include "core/Random.hpp"
#include "database/Artist.hpp"
#include "database/Directory.hpp"
#include "database/Release.hpp"
#include "database/Session.hpp"
#include "database/Track.hpp"
#include "database/User.hpp"
#include "ParameterParsing.hpp"
#include "SubsonicId.hpp"
#include "responses/Album.hpp"
#include "responses/Artist.hpp"
#include "responses/Song.hpp"
namespace lms::api::subsonic
{
using namespace db;
namespace
{
// Search endpoints can be used to scan/sync the database
// This class is used to keep track of the current scans, in order to retrieve the last objectId
// to speed up the query of the following range (avoid the 'offset' cost)
template<typename ObjectId>
class ScanTracker
{
public:
struct ScanInfo
{
std::string clientAddress;
std::string clientName;
UserId user;
MediaLibraryId library;
std::size_t offset{};
auto operator<=>(const ScanInfo&) const = default;
};
ObjectId extractLastRetrievedObjectId(const ScanInfo& info);
void setObjectId(const ScanInfo& info, ObjectId lastRetrievedId);
private:
using ClockType = std::chrono::steady_clock;
struct Entry
{
ClockType::time_point timePoint;
ObjectId objectId;
};
static constexpr std::size_t maxScanCount{ 50 };
static constexpr ClockType::duration maxEntryDuration{ std::chrono::seconds{ 30 } };
std::mutex _mutex;
std::map<ScanInfo, Entry> _ongoingScans;
};
template<typename ObjectId>
ObjectId ScanTracker<ObjectId>::extractLastRetrievedObjectId(const ScanInfo& scanInfo)
{
ObjectId res;
{
const std::scoped_lock lock{ _mutex };
auto it{ _ongoingScans.find(scanInfo) };
if (it != _ongoingScans.end())
{
res = it->second.objectId;
_ongoingScans.erase(it);
}
}
return res;
}
template<typename ObjectId>
void ScanTracker<ObjectId>::setObjectId(const ScanInfo& scanInfo, ObjectId lastRetrievedId)
{
const ClockType::time_point now{ ClockType::now() };
const std::scoped_lock lock{ _mutex };
// clean outdated scan entries; we do this to not have to flush everything each time we add/remove entries in the database
std::erase_if(_ongoingScans, [&](const auto& entry) { return now > entry.second.timePoint + maxEntryDuration; });
// prevent the cache size from going out of control
if (_ongoingScans.size() == maxScanCount)
_ongoingScans.erase(core::random::pickRandom(_ongoingScans));
_ongoingScans[scanInfo] = { now, lastRetrievedId };
}
void findRequestedArtistDirectories(RequestContext& context, const std::vector<std::string_view>& keywords, MediaLibraryId mediaLibrary, Response::Node& searchResultNode)
{
// For now, no need to optimize all this
// Find all the directories that match the name and that do not contain any track (considered by the legacy API as artists)
const std::size_t artistCount{ getParameterAs<std::size_t>(context.parameters, "artistCount").value_or(20) };
if (artistCount == 0)
return;
if (artistCount > defaultMaxCountSize)
throw ParameterValueTooHighGenericError{ "artistCount", defaultMaxCountSize };
const std::size_t artistOffset{ getParameterAs<std::size_t>(context.parameters, "artistOffset").value_or(0) };
Directory::FindParameters params;
params.setKeywords(keywords);
params.setRange(Range{ artistOffset, artistCount });
params.setWithNoTrack(true);
params.setMediaLibrary(mediaLibrary);
Directory::find(context.dbSession, params, [&](const Directory::pointer& directory) {
Response::Node childNode;
childNode.setAttribute("id", idToString(directory->getId()));
childNode.setAttribute("name", directory->getName());
childNode.setAttribute("isDir", true);
searchResultNode.addArrayChild("artist", std::move(childNode));
});
}
void findRequestedArtists(RequestContext& context, const std::vector<std::string_view>& keywords, MediaLibraryId mediaLibrary, Response::Node& searchResultNode)
{
static ScanTracker<ArtistId> currentScansInProgress;
const std::size_t artistCount{ getParameterAs<std::size_t>(context.parameters, "artistCount").value_or(20) };
if (artistCount == 0)
return;
if (artistCount > defaultMaxCountSize)
throw ParameterValueTooHighGenericError{ "artistCount", defaultMaxCountSize };
const std::size_t artistOffset{ getParameterAs<std::size_t>(context.parameters, "artistOffset").value_or(0) };
ArtistId lastRetrievedId;
auto findArtists{ [&] {
Artist::FindParameters params;
params.setKeywords(keywords);
params.setRange(Range{ artistOffset, artistCount });
params.setMediaLibrary(mediaLibrary);
params.setSortMethod(ArtistSortMethod::Id); // must be consistent with both methods
Artist::find(context.dbSession, params, [&](const Artist::pointer& artist) {
searchResultNode.addArrayChild("artist", createArtistNode(context, artist));
lastRetrievedId = artist->getId();
});
} };
if (!keywords.empty())
{
findArtists();
}
else
{
ScanTracker<ArtistId>::ScanInfo scanInfo{
.clientAddress = context.clientIpAddr,
.clientName = context.clientInfo.name,
.user = context.user->getId(),
.library = mediaLibrary,
.offset = artistOffset
};
if (ArtistId cachedLastRetrievedId{ currentScansInProgress.extractLastRetrievedObjectId(scanInfo) }; cachedLastRetrievedId.isValid())
{
Artist::find(
context.dbSession, cachedLastRetrievedId, artistCount, [&](const Artist::pointer& artist) {
searchResultNode.addArrayChild("artist", createArtistNode(context, artist));
},
mediaLibrary);
lastRetrievedId = cachedLastRetrievedId;
}
else
{
findArtists();
}
if (lastRetrievedId.isValid())
{
scanInfo.offset = artistOffset + artistCount;
currentScansInProgress.setObjectId(scanInfo, lastRetrievedId);
}
}
}
void findRequestedAlbums(RequestContext& context, bool id3, const std::vector<std::string_view>& keywords, MediaLibraryId mediaLibrary, Response::Node& searchResultNode)
{
static ScanTracker<ReleaseId> currentScansInProgress;
const std::size_t albumCount{ getParameterAs<std::size_t>(context.parameters, "albumCount").value_or(20) };
if (albumCount == 0)
return;
if (albumCount > defaultMaxCountSize)
throw ParameterValueTooHighGenericError{ "albumCount", defaultMaxCountSize };
const std::size_t albumOffset{ getParameterAs<std::size_t>(context.parameters, "albumOffset").value_or(0) };
ReleaseId lastRetrievedId;
auto findReleases{ [&] {
Release::FindParameters params;
params.setKeywords(keywords);
params.setRange(Range{ albumOffset, albumCount });
params.setMediaLibrary(mediaLibrary);
params.setSortMethod(ReleaseSortMethod::Id); // must be consistent with both methods
Release::find(context.dbSession, params, [&](const Release::pointer& release) {
searchResultNode.addArrayChild("album", createAlbumNode(context, release, id3));
lastRetrievedId = release->getId();
});
} };
if (!keywords.empty())
{
findReleases();
}
else
{
ScanTracker<ReleaseId>::ScanInfo scanInfo{
.clientAddress = context.clientIpAddr,
.clientName = context.clientInfo.name,
.user = context.user->getId(),
.library = mediaLibrary,
.offset = albumOffset
};
if (ReleaseId cachedLastRetrievedId{ currentScansInProgress.extractLastRetrievedObjectId(scanInfo) }; cachedLastRetrievedId.isValid())
{
Release::find(
context.dbSession, cachedLastRetrievedId, albumCount, [&](const Release::pointer& release) {
searchResultNode.addArrayChild("album", createAlbumNode(context, release, id3));
},
mediaLibrary);
lastRetrievedId = cachedLastRetrievedId;
}
else
{
findReleases();
}
if (lastRetrievedId.isValid())
{
scanInfo.offset = albumOffset + albumCount;
currentScansInProgress.setObjectId(scanInfo, lastRetrievedId);
}
}
}
void findRequestedTracks(RequestContext& context, bool id3, const std::vector<std::string_view>& keywords, MediaLibraryId mediaLibrary, Response::Node& searchResultNode)
{
static ScanTracker<TrackId> currentScansInProgress;
const std::size_t songCount{ getParameterAs<std::size_t>(context.parameters, "songCount").value_or(20) };
if (songCount == 0)
return;
if (songCount > defaultMaxCountSize)
throw ParameterValueTooHighGenericError{ "songCount", defaultMaxCountSize };
const std::size_t songOffset{ getParameterAs<std::size_t>(context.parameters, "songOffset").value_or(0) };
TrackId lastRetrievedId;
auto findTracks{ [&] {
Track::FindParameters params;
params.setKeywords(keywords);
params.setRange(Range{ songOffset, songCount });
params.setMediaLibrary(mediaLibrary);
params.setSortMethod(TrackSortMethod::Id); // must be consistent with both methods
Track::find(context.dbSession, params, [&](const Track::pointer& track) {
searchResultNode.addArrayChild("song", createSongNode(context, track, id3));
lastRetrievedId = track->getId();
});
} };
if (!keywords.empty())
{
findTracks();
}
else
{
ScanTracker<TrackId>::ScanInfo scanInfo{
.clientAddress = context.clientIpAddr,
.clientName = context.clientInfo.name,
.user = context.user->getId(),
.library = mediaLibrary,
.offset = songOffset
};
if (TrackId cachedLastRetrievedId{ currentScansInProgress.extractLastRetrievedObjectId(scanInfo) }; cachedLastRetrievedId.isValid())
{
Track::find(
context.dbSession, cachedLastRetrievedId, songCount, [&](const Track::pointer& track) {
searchResultNode.addArrayChild("song", createSongNode(context, track, id3));
},
mediaLibrary);
lastRetrievedId = cachedLastRetrievedId;
}
else
{
findTracks();
}
if (lastRetrievedId.isValid())
{
scanInfo.offset = songOffset + songCount;
currentScansInProgress.setObjectId(scanInfo, lastRetrievedId);
}
}
}
Response handleSearchRequestCommon(RequestContext& context, bool id3)
{
// Mandatory params
const std::string queryString{ getMandatoryParameterAs<std::string>(context.parameters, "query") };
std::string_view query{ queryString };
// Optional params
const MediaLibraryId mediaLibrary{ getParameterAs<MediaLibraryId>(context.parameters, "musicFolderId").value_or(MediaLibraryId{}) };
// Symfonium adds extra ""
if (context.clientInfo.name == "Symfonium")
query = core::stringUtils::stringTrim(query, "\"");
std::vector<std::string_view> keywords;
if (!query.empty())
keywords = core::stringUtils::splitString(query, ' ');
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& searchResultNode{ response.createNode(id3 ? "searchResult3" : "searchResult2") };
auto transaction{ context.dbSession.createReadTransaction() };
if (id3)
findRequestedArtists(context, keywords, mediaLibrary, searchResultNode);
else
findRequestedArtistDirectories(context, keywords, mediaLibrary, searchResultNode);
findRequestedAlbums(context, id3, keywords, mediaLibrary, searchResultNode);
findRequestedTracks(context, id3, keywords, mediaLibrary, searchResultNode);
return response;
}
} // namespace
Response handleSearch2Request(RequestContext& context)
{
return handleSearchRequestCommon(context, false /* no id3 */);
}
Response handleSearch3Request(RequestContext& context)
{
return handleSearchRequestCommon(context, true /* id3 */);
}
} // namespace lms::api::subsonic
@@ -0,0 +1,29 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "RequestContext.hpp"
#include "SubsonicResponse.hpp"
namespace lms::api::subsonic
{
Response handleSearch2Request(RequestContext& context);
Response handleSearch3Request(RequestContext& context);
} // namespace lms::api::subsonic
@@ -0,0 +1,52 @@
#include "endpoints/System.hpp"
namespace lms::api::subsonic
{
Response handlePingRequest(RequestContext& context)
{
return Response::createOkResponse(context.serverProtocolVersion);
}
Response handleGetLicenseRequest(RequestContext& context)
{
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& licenseNode{ response.createNode("license") };
licenseNode.setAttribute("licenseExpires", "2035-09-03T14:46:43");
licenseNode.setAttribute("email", "foo@bar.com");
licenseNode.setAttribute("valid", true);
return response;
}
Response handleGetOpenSubsonicExtensions(RequestContext& context)
{
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
{
Response::Node& transcodeOffsetNode{ response.createArrayNode("openSubsonicExtensions") };
transcodeOffsetNode.setAttribute("name", "transcodeOffset");
transcodeOffsetNode.addArrayValue("versions", 1);
}
{
Response::Node& formPostNode{ response.createArrayNode("openSubsonicExtensions") };
formPostNode.setAttribute("name", "formPost");
formPostNode.addArrayValue("versions", 1);
}
{
Response::Node& songLyricsNode{ response.createArrayNode("openSubsonicExtensions") };
songLyricsNode.setAttribute("name", "songLyrics");
songLyricsNode.addArrayValue("versions", 1);
}
{
Response::Node& apiKeyAuthentication{ response.createArrayNode("openSubsonicExtensions") };
apiKeyAuthentication.setAttribute("name", "apiKeyAuthentication");
apiKeyAuthentication.addArrayValue("versions", 1);
}
return response;
};
} // namespace lms::api::subsonic
@@ -0,0 +1,30 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "RequestContext.hpp"
#include "SubsonicResponse.hpp"
namespace lms::api::subsonic
{
Response handlePingRequest(RequestContext& context);
Response handleGetLicenseRequest(RequestContext& context);
Response handleGetOpenSubsonicExtensions(RequestContext& context);
} // namespace lms::api::subsonic
@@ -0,0 +1,54 @@
#include "UserManagement.hpp"
#include "core/Service.hpp"
#include "database/Session.hpp"
#include "database/User.hpp"
#include "services/auth/IPasswordService.hpp"
#include "ParameterParsing.hpp"
#include "responses/User.hpp"
namespace lms::api::subsonic
{
using namespace db;
namespace
{
void checkUserIsMySelfOrAdmin(RequestContext& context, const std::string& username)
{
if (context.user->getLoginName() != username && !context.user->isAdmin())
throw UserNotAuthorizedError{};
}
} // namespace
Response handleGetUserRequest(RequestContext& context)
{
std::string username{ getMandatoryParameterAs<std::string>(context.parameters, "username") };
auto transaction{ context.dbSession.createReadTransaction() };
checkUserIsMySelfOrAdmin(context, username);
const User::pointer user{ User::find(context.dbSession, username) };
if (!user)
throw RequestedDataNotFoundError{};
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
response.addNode("user", createUserNode(context, user));
return response;
}
Response handleGetUsersRequest(RequestContext& context)
{
Response response{ Response::createOkResponse(context.serverProtocolVersion) };
Response::Node& usersNode{ response.createNode("users") };
auto transaction{ context.dbSession.createReadTransaction() };
User::find(context.dbSession, User::FindParameters{}, [&](const User::pointer& user) {
usersNode.addArrayChild("user", createUserNode(context, user));
});
return response;
}
} // namespace lms::api::subsonic
@@ -0,0 +1,33 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "RequestContext.hpp"
#include "SubsonicResponse.hpp"
namespace lms::api::subsonic
{
Response handleGetUserRequest(RequestContext& context);
Response handleGetUsersRequest(RequestContext& context);
Response handleCreateUserRequest(RequestContext& context);
Response handleUpdateUserRequest(RequestContext& context);
Response handleDeleteUserRequest(RequestContext& context);
Response handleChangePassword(RequestContext& context);
} // namespace lms::api::subsonic