API Subsonic: first working version, still WIP (missing commands, no authentication)

This commit is contained in:
emeric
2019-04-01 13:57:14 +02:00
parent be6ca7cc1b
commit ab2ed24f75
22 changed files with 1245 additions and 34 deletions
+3
View File
@@ -1,6 +1,9 @@
bin_PROGRAMS = lms
lms_SOURCES = \
$(srcdir)/api/subsonic/SubsonicId.cpp \
$(srcdir)/api/subsonic/SubsonicResource.cpp \
$(srcdir)/api/subsonic/SubsonicResponse.cpp \
$(srcdir)/av/AvInfo.cpp \
$(srcdir)/av/AvTranscoder.cpp \
$(srcdir)/cover/CoverArtGrabber.cpp \
+86
View File
@@ -0,0 +1,86 @@
/*
* Copyright (C) 2019 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 "SubsonicId.hpp"
#include "utils/Logger.hpp"
#include "utils/Utils.hpp"
namespace API::Subsonic
{
boost::optional<Id>
IdFromString(const std::string& id)
{
std::vector<std::string> values {splitString(id, "-")};
if (values.size() != 2)
{
LMS_LOG(API_SUBSONIC, ERROR) << "Bad id format";
return {};
}
Id res;
std::string type {std::move(values[0])};
if (type == "artist")
res.type = Id::Type::Artist;
else if (type == "album")
res.type = Id::Type::Release;
else if (type == "track")
res.type = Id::Type::Track;
else
{
LMS_LOG(API_SUBSONIC, ERROR) << "Bad id format";
return {};
}
auto optId {readAs<Database::IdType>(values[1])};
if (!optId)
{
LMS_LOG(API_SUBSONIC, ERROR) << "Bad id format";
return {};
}
res.id = *optId;
return res;
}
std::string
IdToString(const Id& id)
{
std::string res;
switch (id.type)
{
case Id::Type::Artist:
res = "artist-";
break;
case Id::Type::Release:
res = "album-";
break;
case Id::Type::Track:
res = "track-";
break;
}
return res + std::to_string(id.id);
}
} // namespace API::Subsonic
+43
View File
@@ -0,0 +1,43 @@
/*
* Copyright (C) 2019 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 <boost/optional.hpp>
#include "database/Types.hpp"
namespace API::Subsonic
{
struct Id
{
enum class Type
{
Track,
Release,
Artist,
};
Type type;
Database::IdType id;
};
boost::optional<Id> IdFromString(const std::string& id);
std::string IdToString(const Id& id);
} // namespace API::Subsonic
+657
View File
@@ -0,0 +1,657 @@
/*
* Copyright (C) 2019 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 "SubsonicResource.hpp"
#include <mutex>
#include <numeric>
#include <random>
#include <Wt/WLocalDateTime.h>
#include "av/AvTranscoder.hpp"
#include "cover/CoverArtGrabber.hpp"
#include "database/Artist.hpp"
#include "database/Release.hpp"
#include "database/Track.hpp"
#include "main/Services.hpp"
#include "utils/Logger.hpp"
#include "utils/Utils.hpp"
#include "SubsonicId.hpp"
#include "SubsonicResponse.hpp"
// Requests
#define PING_URL "/rest/ping.view"
#define GET_LICENSE_URL "/rest/getLicense.view"
#define GET_RANDOM_SONGS_URL "/rest/getRandomSongs.view"
#define GET_ALBUM_LIST_URL "/rest/getAlbumList.view"
#define GET_MUSIC_DIRECTORY_URL "/rest/getMusicDirectory.view"
#define GET_STARRED_URL "/rest/getStarred.view"
#define GET_MUSIC_FOLDERS_URL "/rest/getMusicFolders.view"
#define GET_INDEXES_URL "/rest/getIndexes.view"
#define GET_ARTISTS_URL "/rest/getArtists.view"
// MediaRetrievals
#define STREAM_URL "/rest/stream.view"
#define GET_COVER_ART_URL "/rest/getCoverArt.view"
namespace API::Subsonic
{
// requests
using RequestHandlerFunc = std::function<Response(const Wt::Http::ParameterMap&, Database::Handler&)>;
static Response handlePingRequest(const Wt::Http::ParameterMap& request, Database::Handler& db);
static Response handleGetLicenseRequest(const Wt::Http::ParameterMap& request, Database::Handler& db);
static Response handleGetRandomSongsRequest(const Wt::Http::ParameterMap& request, Database::Handler& db);
static Response handleGetAlbumListRequest(const Wt::Http::ParameterMap& request, Database::Handler& db);
static Response handleGetMusicDirectoryRequest(const Wt::Http::ParameterMap& request, Database::Handler& db);
static Response handleGetMusicFoldersRequest(const Wt::Http::ParameterMap& request, Database::Handler& db);
static Response handleGetIndexesRequest(const Wt::Http::ParameterMap& request, Database::Handler& db);
static Response handleGetArtistsRequest(const Wt::Http::ParameterMap& request, Database::Handler& db);
// MediaRetrievals
using MediaRetrivalHandlerFunc = std::function<void(const Wt::Http::Request&, Database::Handler&, Wt::Http::Response& response)>;
void handleStream(const Wt::Http::Request& request, Database::Handler& db, Wt::Http::Response& response);
void handleGetCoverArt(const Wt::Http::Request& request, Database::Handler& db, Wt::Http::Response& response);
static std::map<std::string, RequestHandlerFunc> requestHandlers
{
{PING_URL, handlePingRequest},
{GET_LICENSE_URL, handleGetLicenseRequest},
{GET_RANDOM_SONGS_URL, handleGetRandomSongsRequest},
{GET_ALBUM_LIST_URL, handleGetAlbumListRequest},
{GET_MUSIC_DIRECTORY_URL, handleGetMusicDirectoryRequest},
{GET_STARRED_URL, handlePingRequest}, // TODO
{GET_MUSIC_FOLDERS_URL, handleGetMusicFoldersRequest},
{GET_INDEXES_URL, handleGetIndexesRequest},
{GET_ARTISTS_URL, handleGetArtistsRequest},
};
static std::map<std::string, MediaRetrivalHandlerFunc> mediaRetrievalHandlers
{
{STREAM_URL, handleStream},
{GET_COVER_ART_URL, handleGetCoverArt},
};
template<typename T>
boost::optional<T>
getParameterAs(const Wt::Http::ParameterMap& parameterMap, const std::string& param)
{
boost::optional<T> res;
auto it = parameterMap.find(param);
if (it == parameterMap.end())
return res;
if (it->second.size() != 1)
return res;
return readAs<T>(it->second.front());
}
struct ClientInfo
{
std::string name;
ResponseFormat format;
std::string user;
std::string password;
};
boost::optional<ClientInfo>
getClientInfo(const Wt::Http::ParameterMap& parameters)
{
boost::optional<ClientInfo> res {ClientInfo {}};
// Mandatory parameters
auto param {getParameterAs<std::string>(parameters, "c")};
if (!param)
return {};
res->name = *param;
param = getParameterAs<std::string>(parameters, "u");
if (!param)
return {};
res->user = *param;
param = getParameterAs<std::string>(parameters, "p");
if (!param)
return {};
res->password = *param;
// Optional parameters
param = getParameterAs<std::string>(parameters, "f");
res->format = (param ? ResponseFormat::json : ResponseFormat::xml); // TODO
return res;
}
SubsonicResource::SubsonicResource(Wt::Dbo::SqlConnectionPool& connectionPool)
: _db {connectionPool}
{
}
std::vector<std::string>
SubsonicResource::getPaths()
{
std::vector<std::string> paths;
for (auto it : requestHandlers)
paths.emplace_back(it.first);
for (auto it : mediaRetrievalHandlers)
paths.emplace_back(it.first);
return paths;
}
void
SubsonicResource::handleRequest(const Wt::Http::Request &request, Wt::Http::Response &response)
{
LMS_LOG(API_SUBSONIC, DEBUG) << "REQUEST. Path = '" << request.path() << "', pathInfo = '" << request.pathInfo() << "', queryString = '" << request.queryString() << "'";
const Wt::Http::ParameterMap parameters {request.getParameterMap()};
for (const auto it : parameters)
{
LMS_LOG(API_SUBSONIC, DEBUG) << "Found param '" << it.first << "'";
for (const std::string& value : it.second)
LMS_LOG(API_SUBSONIC, DEBUG) << "\t'" << value << "'";
}
std::string s{std::istreambuf_iterator<char>(request.in()), {}};
LMS_LOG(API_SUBSONIC, DEBUG) << "BODY = '" << s << "'";
auto clientInfo {getClientInfo(parameters)};
if (!clientInfo)
{
LMS_LOG(API_SUBSONIC, ERROR) << "Failed to parse client info";
return;
}
try
{
static std::mutex mutex;
std::unique_lock<std::mutex> lock{mutex}; // For now just handle request s one by one
auto itHandler {requestHandlers.find(request.path())};
if (itHandler != requestHandlers.end())
{
Response resp {(itHandler->second)(request.getParameterMap(), _db)};
responseToStream(resp, clientInfo->format, response.out());
response.setMimeType(ResponseFormatToMimeType(clientInfo->format));
return;
}
auto itStreamHandler {mediaRetrievalHandlers.find(request.path())};
if (itStreamHandler != mediaRetrievalHandlers.end())
{
itStreamHandler->second(request, _db, response);
return;
}
LMS_LOG(API_SUBSONIC, ERROR) << "Unhandled command '" << request.path() << "'";
}
catch (const Error& e)
{
Response resp {Response::createFailedResponse(e)};
responseToStream(resp, clientInfo->format, response.out());
response.setMimeType(ResponseFormatToMimeType(clientInfo->format));
}
}
static
std::string
getArtistNames(const std::vector<Database::Artist::pointer> artists)
{
if (artists.size() == 1)
return artists.front()->getName();
std::vector<std::string> names;
names.resize(artists.size());
std::transform(std::cbegin(artists), std::cend(artists), std::begin(names),
[](const Database::Artist::pointer& artist)
{
return artist->getName();
});
return joinStrings(names, ", ");
}
static
Response::Node
trackToResponseNode(const Database::Track::pointer& track)
{
Response::Node trackResponse;
trackResponse.setAttribute("title", track->getName());
trackResponse.setAttribute("id", IdToString({Id::Type::Track, track.id()}));
trackResponse.setAttribute("coverArt", IdToString({Id::Type::Track, track.id()}));
trackResponse.setAttribute("isDir", "false");
auto artists {track->getArtists()};
if (!artists.empty())
trackResponse.setAttribute("artist", getArtistNames(artists));
trackResponse.setAttribute("path", track->getName() + ".mp3");
trackResponse.setAttribute("bitrate", "128");
trackResponse.setAttribute("duration", std::to_string(std::chrono::duration_cast<std::chrono::seconds>(track->getDuration()).count()));
trackResponse.setAttribute("suffix", "mp3");
trackResponse.setAttribute("contentType", "audio/mpeg");
trackResponse.setAttribute("size", std::to_string(128000/8 * std::chrono::duration_cast<std::chrono::seconds>(track->getDuration()).count()));
if (track->getYear())
trackResponse.setAttribute("year", std::to_string(*track->getYear()));
if (track->getTrackNumber())
trackResponse.setAttribute("track", std::to_string(*track->getTrackNumber()));
if (track->getRelease())
{
trackResponse.setAttribute("parent", IdToString({Id::Type::Release, track->getRelease().id()}));
trackResponse.setAttribute("album", track->getRelease()->getName());
}
return trackResponse;
}
static
Response::Node
releaseToResponseNode(const Database::Release::pointer& release)
{
Response::Node albumNode;
albumNode.setAttribute("title", release->getName());
albumNode.setAttribute("id", IdToString({Id::Type::Release, release.id()}));
albumNode.setAttribute("isDir", "true");
albumNode.setAttribute("coverArt", IdToString({Id::Type::Release, release.id()}));
auto artists {release->getArtists()};
if (!artists.empty())
{
if (artists.size() > 1)
albumNode.setAttribute("artist", "Various Artists");
else
albumNode.setAttribute("artist", artists.front()->getName());
}
return albumNode;
}
static
Response::Node
artistToResponseNode(const Database::Artist::pointer& artist)
{
Response::Node artistResponse;
artistResponse.setAttribute("id", IdToString({Id::Type::Artist, artist.id()}));
artistResponse.setAttribute("name", artist->getName());
artistResponse.setAttribute("albumCount", std::to_string(artist->getReleases().size()));
return artistResponse;
}
// Handlers
Response
handlePingRequest(const Wt::Http::ParameterMap &parameters, Database::Handler& handler)
{
return Response::createOkResponse();
}
Response
handleGetLicenseRequest(const Wt::Http::ParameterMap &parameters, Database::Handler& handler)
{
Response response {Response::createOkResponse()};
Response::Node& licenseNode {response.createNode("license")};
licenseNode.setAttribute("licenseExpires", "2019-09-03T14:46:43");
licenseNode.setAttribute("email", "foo@bar.com");
licenseNode.setAttribute("valid", "true");
return response;
}
Response
handleGetRandomSongsRequest(const Wt::Http::ParameterMap& parameters, Database::Handler& db)
{
// Optional params
auto size {getParameterAs<std::size_t>(parameters, "size")};
if (!size)
size = 50;
Wt::Dbo::Transaction transaction {db.getSession()};
auto tracks {Database::Track::getAllRandom(db.getSession(), *size)};
LMS_LOG(API_SUBSONIC, DEBUG) << "Got " << tracks.size() << " tracks";
Response response {Response::createOkResponse()};
Response::Node& randomSongsNode {response.createNode("randomSongs")};
for (const Database::Track::pointer& track : tracks)
randomSongsNode.addArrayChild("song", trackToResponseNode(track));
return response;
}
static
std::vector<Database::Release::pointer> getRandomAlbums(Wt::Dbo::Session& session, std::size_t offset, std::size_t size)
{
std::vector<Database::Release::pointer> res;
std::size_t nbReleases {Database::Release::getCount(session)};
if (offset > nbReleases)
return res;
if (offset + size > nbReleases)
size = nbReleases - offset;
std::vector<size_t> indexes;
indexes.resize(nbReleases);
std::iota(std::begin(indexes), std::end(indexes), 1);
// As random results are paginated, we need to set a seed for it
std::random_device r;
std::seed_seq seed {1337};
std::mt19937 generator{seed};
std::shuffle(std::begin(indexes), std::end(indexes), generator);
std::for_each(std::next(std::begin(indexes), offset), std::next(std::begin(indexes), offset + size),
[&](std::size_t offset)
{
auto release {Database::Release::getAll(session, offset, 1)};
if (!release.empty())
res.emplace_back(release.front());
});
return res;
}
Response
handleGetAlbumListRequest(const Wt::Http::ParameterMap& request, Database::Handler& db)
{
// Mandatory params
auto type {getParameterAs<std::string>(request, "type")};
if (!type)
throw Error {Error::Code::RequiredParameterMissing};
// Optional params
auto size {getParameterAs<std::size_t>(request, "size")};
if (!size)
size = 10;
auto offset {getParameterAs<std::size_t>(request, "offset")};
if (!offset)
offset = 0;
std::vector<Database::Release::pointer> releases;
Wt::Dbo::Transaction transaction {db.getSession()};
if (*type == "random")
{
releases = getRandomAlbums(db.getSession(), *offset, *size);
}
else if (*type == "newest")
{
auto after {Wt::WLocalDateTime::currentServerDateTime().toUTC().addMonths(-1)};
releases = Database::Release::getLastAdded(db.getSession(), after, offset, size);
}
else
throw Error {"Unsupported request"};
LMS_LOG(API_SUBSONIC, DEBUG) << "Got " << releases.size() << " albums";
Response response {Response::createOkResponse()};
Response::Node& albumListNode {response.createNode("albumList")};
for (const Database::Release::pointer& release : releases)
albumListNode.addArrayChild("album", releaseToResponseNode(release));
return response;
}
Response
handleGetMusicDirectoryRequest(const Wt::Http::ParameterMap& request, Database::Handler& db)
{
// Mandatory params
auto idParam {getParameterAs<std::string>(request, "id")};
if (!idParam)
throw Error {Error::Code::RequiredParameterMissing};
auto id {IdFromString(*idParam)};
if (!id)
throw Error {"Bad id"};
Wt::Dbo::Transaction transaction {db.getSession()};
Response response {Response::createOkResponse()};
Response::Node& directoryNode {response.createNode("directory")};
switch (id->type)
{
case Id::Type::Artist:
{
auto artist {Database::Artist::getById(db.getSession(), id->id)};
if (!artist)
throw Error {Error::Code::RequestedDataNotFound};
auto releases {artist->getReleases()};
for (const Database::Release::pointer& release : releases)
directoryNode.addArrayChild("child", releaseToResponseNode(release));
break;
}
case Id::Type::Release:
{
auto release {Database::Release::getById(db.getSession(), id->id)};
if (!release)
throw Error {Error::Code::RequestedDataNotFound};
auto tracks {release->getTracks()};
for (const Database::Track::pointer& track : tracks)
directoryNode.addArrayChild("child", trackToResponseNode(track));
break;
}
default:
throw Error {"Bad id"};
}
return response;
}
Response
handleGetMusicFoldersRequest(const Wt::Http::ParameterMap& request, Database::Handler& db)
{
Response response {Response::createOkResponse()};
Response::Node& musicFoldersNode {response.createNode("musicFolders")};
Response::Node& musicFolderNode {musicFoldersNode.createArrayChild("musicFolder")};
musicFolderNode.setAttribute("id", "1");
musicFolderNode.setAttribute("name", "Music");
return response;
}
Response
handleGetIndexesRequest(const Wt::Http::ParameterMap& request, Database::Handler& db)
{
Wt::Dbo::Transaction transaction {db.getSession()};
Response response {Response::createOkResponse()};
Response::Node& artistsNode {response.createNode("indexes")};
Response::Node& indexNode {artistsNode.createArrayChild("index")};
auto artists {Database::Artist::getAll(db.getSession())};
for (const Database::Artist::pointer& artist : artists)
indexNode.addArrayChild("artist", artistToResponseNode(artist));
return response;
}
Response
handleGetArtistsRequest(const Wt::Http::ParameterMap& request, Database::Handler& db)
{
Wt::Dbo::Transaction transaction {db.getSession()};
// TODO factorize with handleGetIndexesRequest
Response response {Response::createOkResponse()};
Response::Node& artistsNode {response.createNode("artists")};
auto artists {Database::Artist::getAll(db.getSession())};
for (const Database::Artist::pointer& artist : artists)
artistsNode.addArrayChild("artist", artistToResponseNode(artist));
return response;
}
static
std::shared_ptr<Av::Transcoder>
createTranscoder(const Wt::Http::ParameterMap& request, Database::Handler& db)
{
// Mandatory params
auto idParam {getParameterAs<std::string>(request, "id")};
if (!idParam)
throw Error {Error::Code::RequiredParameterMissing};
auto id {IdFromString(*idParam)};
if (!id || id->type != Id::Type::Track)
throw Error {"bad id format"};
boost::filesystem::path trackPath;
{
Wt::Dbo::Transaction transaction {db.getSession()};
auto track {Database::Track::getById(db.getSession(), id->id)};
if (!track)
{
LMS_LOG(API_SUBSONIC, ERROR) << "Bad track id";
throw Error {Error::Code::RequestedDataNotFound};
}
trackPath = track->getPath();
}
Av::TranscodeParameters parameters {};
parameters.bitrate = 128000;
parameters.encoding = Av::Encoding::MP3;
return std::make_shared<Av::Transcoder>(trackPath, parameters);
}
void
handleStream(const Wt::Http::Request& request, Database::Handler& db, Wt::Http::Response& response)
{
LMS_LOG(API_SUBSONIC, DEBUG) << "STREAM";
std::shared_ptr<Av::Transcoder> transcoder;
Wt::Http::ResponseContinuation* continuation {request.continuation()};
if (!continuation)
{
transcoder = createTranscoder(request.getParameterMap(), db);
response.setMimeType(Av::encodingToMimetype(Av::Encoding::MP3));
transcoder->start();
}
else
{
LMS_LOG(UI, DEBUG) << "Continuation! ";
transcoder = Wt::cpp17::any_cast<std::shared_ptr<Av::Transcoder>>(continuation->data());
}
if (!transcoder)
throw Error {"transcoding failed"};
if (!transcoder->isComplete())
{
static constexpr std::size_t chunkSize {65536*4};
std::vector<unsigned char> data;
data.reserve(chunkSize);
transcoder->process(data, chunkSize);
LMS_LOG(API_SUBSONIC, DEBUG) << "Writing " << data.size() << " bytes...";
response.out().write(reinterpret_cast<char*>(&data[0]), data.size());
if (!response.out())
{
LMS_LOG(UI, ERROR) << "Write failed!";
return;
}
}
if (!transcoder->isComplete())
{
continuation = response.createContinuation();
continuation->setData(transcoder);
}
else
LMS_LOG(API_SUBSONIC, DEBUG) << "No more data!";
}
void
handleGetCoverArt(const Wt::Http::Request& request, Database::Handler& db, Wt::Http::Response& response)
{
LMS_LOG(API_SUBSONIC, DEBUG) << "STREAM";
// Mandatory params
auto idParam {getParameterAs<std::string>(request.getParameterMap(), "id")};
if (!idParam)
throw Error {Error::Code::RequiredParameterMissing};
auto id {IdFromString(*idParam)};
if (!id)
throw Error {"bad id format"};
auto size {getParameterAs<std::size_t>(request.getParameterMap(), "size")};
if (!size || *size == 0)
size = 256;
std::vector<uint8_t> cover;
switch (id->type)
{
case Id::Type::Track:
cover = getServices().coverArtGrabber->getFromTrack(db.getSession(), id->id, Image::Format::JPEG, *size);
break;
case Id::Type::Release:
cover = getServices().coverArtGrabber->getFromRelease(db.getSession(), id->id, Image::Format::JPEG, *size);
break;
default:
throw Error {"bad id format"};
}
response.setMimeType( Image::format_to_mimeType(Image::Format::JPEG) );
response.out().write(reinterpret_cast<const char *>(&cover[0]), cover.size());
}
} // namespace api::subsonic
+45
View File
@@ -0,0 +1,45 @@
/*
* Copyright (C) 2019 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 <boost/optional.hpp>
#include <Wt/WResource.h>
#include <Wt/Http/Response.h>
#include "database/DatabaseHandler.hpp"
namespace API::Subsonic
{
class SubsonicResource final : public Wt::WResource
{
public:
SubsonicResource(Wt::Dbo::SqlConnectionPool& connectionPool);
static std::vector<std::string> getPaths();
private:
void handleRequest(const Wt::Http::Request &request, Wt::Http::Response &response) override;
Database::Handler _db;
};
} // namespace
+204
View File
@@ -0,0 +1,204 @@
/*
* Copyright (C) 2019 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 "SubsonicResponse.hpp"
#include <boost/property_tree/json_parser.hpp>
#include <boost/property_tree/xml_parser.hpp>
#define API_VERSION "1.12.0"
namespace API::Subsonic
{
std::string
ResponseFormatToMimeType(ResponseFormat format)
{
switch (format)
{
case ResponseFormat::xml: return "text/xml";
case ResponseFormat::json: return "application/json";
}
return "";
}
static
const char*
ErrorCodeToString(Error::Code error)
{
switch (error)
{
case Error::Code::RequiredParameterMissing:
return "Required parameter is missing.";
case Error::Code::ClientMustUpgrade:
return "Incompatible Subsonic REST protocol version. Client must upgrade.";
case Error::Code::ServerMustUpgrade:
return "Incompatible Subsonic REST protocol version. Server must upgrade.";
case Error::Code::WrongUsernameOrPassword:
return "Wrong username or password.";
case Error::Code::UserNotAuthorized:
return "User is not authorized for the given operation.";
case Error::Code::RequestedDataNotFound:
return "The requested data was not found.";
default:
return "Unknown error";
}
}
Error::Error(Code code)
: _code {code},
_message {ErrorCodeToString(code)}
{
}
Error::Error(const std::string& message)
: _code {Code::Generic},
_message {message}
{
}
void
Response::Node::setAttribute(const std::string& key, const std::string& value)
{
attributes[key] = value;
}
void
Response::Node::addChild(const std::string& key, Node node)
{
children[key].emplace_back(std::move(node));
}
void
Response::Node::addArrayChild(const std::string& key, Node node)
{
childrenArrays[key].emplace_back(std::move(node));
}
Response::Node&
Response::Node::createChild(const std::string& key)
{
children[key].emplace_back();
return children[key].back();
}
Response::Node&
Response::Node::createArrayChild(const std::string& key)
{
childrenArrays[key].emplace_back();
return childrenArrays[key].back();
}
Response
Response::createOkResponse()
{
Response response;
Node& responseNode {response._root.createChild("subsonic-response")};
responseNode.setAttribute("status", "ok");
responseNode.setAttribute("version", API_VERSION);
return response;
}
Response
Response::createFailedResponse(const Error& error)
{
Response response;
Node& responseNode {response._root.createChild("subsonic-response")};
responseNode.setAttribute("status", "failed");
responseNode.setAttribute("version", API_VERSION);
Node& errorNode {responseNode.createChild("error")};
errorNode.setAttribute("code", std::to_string(static_cast<int>(error.getCode())));
errorNode.setAttribute("message", error.getMessage());
return response;
}
Response::Node&
Response::createNode(const std::string& key)
{
return _root.children["subsonic-response"].front().createChild(key);
}
boost::property_tree::ptree
NodeToPropertyTree(const Response::Node& node, ResponseFormat format)
{
boost::property_tree::ptree res;
for (auto itChildNode : node.children)
{
for (const Response::Node& childNode : itChildNode.second)
res.add_child(itChildNode.first, NodeToPropertyTree(childNode, format));
}
for (auto itChildArrayNode : node.childrenArrays)
{
const std::vector<Response::Node>& childArrayNodes {itChildArrayNode .second};
if (format == ResponseFormat::json)
{
boost::property_tree::ptree array;
for (const Response::Node& childNode : childArrayNodes )
array.push_back(std::make_pair("", NodeToPropertyTree(childNode, format)));
res.add_child(itChildArrayNode.first, array);
}
else
{
for (const Response::Node& childNode : childArrayNodes )
res.add_child(itChildArrayNode.first, NodeToPropertyTree(childNode, format));
}
}
for (auto itAttribute : node.attributes)
{
std::string key {format == ResponseFormat::xml ? "<xmlattr>." : ""};
key += itAttribute.first;
res.put(key, itAttribute.second);
}
return res;
}
void responseToStream(const Response& response, ResponseFormat format, std::ostream& os)
{
boost::property_tree::ptree root {NodeToPropertyTree(response._root, format)};
switch (format)
{
case ResponseFormat::xml:
boost::property_tree::write_xml(os, root);
break;
case ResponseFormat::json:
boost::property_tree::write_json(os, root);
break;
}
}
} // namespace
+98
View File
@@ -0,0 +1,98 @@
/*
* Copyright (C) 2019 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 <map>
#include <string>
#include <vector>
namespace API::Subsonic
{
enum class ResponseFormat
{
xml,
json,
};
std::string ResponseFormatToMimeType(ResponseFormat format);
class Error
{
public:
enum class Code
{
Generic = 0,
RequiredParameterMissing = 10,
ClientMustUpgrade = 20,
ServerMustUpgrade = 30,
WrongUsernameOrPassword = 40,
UserNotAuthorized = 50,
RequestedDataNotFound = 70,
};
Error(Code code);
Error(const std::string& message);
Code getCode() const { return _code; }
const std::string& getMessage() const { return _message; }
private:
Code _code;
std::string _message;
};
class Response
{
public:
struct Node
{
std::map<std::string, std::string> attributes;
std::map<std::string, std::vector<Node>> children;
std::map<std::string, std::vector<Node>> childrenArrays;
// Helpers
void setAttribute(const std::string& key, const std::string& value);
Node& createChild(const std::string& key);
Node& createArrayChild(const std::string& key);
void addChild(const std::string& key, Node node);
void addArrayChild(const std::string& key, Node node);
};
static Response createOkResponse();
static Response createFailedResponse(const Error& error);
virtual ~Response() {}
Response(const Response&) = delete;
Response& operator=(const Response&) = delete;
Response(Response&&) = default;
Response& operator=(Response&&) = default;
Node& createNode(const std::string& key);
private:
friend void responseToStream(const Response& response, ResponseFormat format, std::ostream& os);
Response() = default;
Node _root;
};
} // namespace
+8 -3
View File
@@ -62,6 +62,7 @@ Grabber::setDefaultCover(boost::filesystem::path p)
Image::Image
Grabber::getDefaultCover(std::size_t size)
{
LMS_LOG(COVER, DEBUG) << "Getting a default cover using size = " << size;
std::unique_lock<std::mutex> lock(_mutex);
auto it = _defaultCovers.find(size);
@@ -69,7 +70,11 @@ Grabber::getDefaultCover(std::size_t size)
{
Image::Image cover = _defaultCover;
cover.scale(size);
LMS_LOG(COVER, DEBUG) << "default cover size = " << cover.getSize().width << " x " << cover.getSize().height;
LMS_LOG(COVER, DEBUG) << "Scaling cover to size = " << size;
cover.scale(Image::Geometry{size, size});
LMS_LOG(COVER, DEBUG) << "Scaling DONE";
auto res = _defaultCovers.insert(std::make_pair(size, cover));
assert(res.second);
it = res.first;
@@ -192,7 +197,7 @@ Grabber::getFromTrack(Wt::Dbo::Session& session, Database::IdType trackId, std::
if (!cover)
cover = getDefaultCover(size);
else
cover->scale(size);
cover->scale(Image::Geometry {size, size});
return *cover;
}
@@ -225,7 +230,7 @@ Grabber::getFromRelease(Wt::Dbo::Session& session, Database::IdType releaseId, s
if (!cover)
cover = getDefaultCover(size);
else
cover->scale(size);
cover->scale(Image::Geometry {size, size});
return *cover;
}
+26 -5
View File
@@ -61,10 +61,30 @@ Release::create(Wt::Dbo::Session& session, const std::string& name, const std::s
return session.add(std::make_unique<Release>(name, MBID));
}
std::vector<Release::pointer>
Release::getAll(Wt::Dbo::Session& session, int offset, int size)
std::size_t
Release::getCount(Wt::Dbo::Session& session)
{
Wt::Dbo::collection<pointer> res = session.find<Release>().offset(offset).limit(size);
Wt::Dbo::collection<pointer> releases {session.find<Release>()};
return releases.size();
}
std::vector<Release::pointer>
Release::getAll(Wt::Dbo::Session& session, boost::optional<std::size_t> offset, boost::optional<std::size_t> size)
{
Wt::Dbo::collection<pointer> res = session.find<Release>()
.offset(offset ? static_cast<int>(*offset) : -1)
.limit(size ? static_cast<int>(*size) : - 1);
return std::vector<pointer>(res.begin(), res.end());
}
std::vector<Release::pointer>
Release::getAllRandom(Wt::Dbo::Session& session, boost::optional<std::size_t> size)
{
Wt::Dbo::collection<pointer> res = session.find<Release>()
.limit(size ? static_cast<int>(*size) : - 1)
.orderBy("RANDOM()");
return std::vector<pointer>(res.begin(), res.end());
}
@@ -77,13 +97,14 @@ Release::getAllOrphans(Wt::Dbo::Session& session)
}
std::vector<Release::pointer>
Release::getLastAdded(Wt::Dbo::Session& session, Wt::WDateTime after, int limit)
Release::getLastAdded(Wt::Dbo::Session& session, Wt::WDateTime after, boost::optional<std::size_t> offset, boost::optional<std::size_t> limit)
{
Wt::Dbo::collection<Release::pointer> res = session.query<Release::pointer>("SELECT r from release r INNER JOIN track t ON r.id = t.release_id")
.where("t.file_added > ?").bind(after)
.groupBy("r.id")
.orderBy("t.file_added DESC")
.limit(limit);
.offset(offset ? static_cast<int>(*offset) : -1)
.limit(limit ? static_cast<int>(*limit) : -1);
return std::vector<pointer>(res.begin(), res.end());
}
+4 -2
View File
@@ -44,12 +44,14 @@ class Release : public Wt::Dbo::Dbo<Release>
Release(const std::string& name, const std::string& MBID = "");
// Accessors
static std::size_t getCount(Wt::Dbo::Session& session);
static pointer getByMBID(Wt::Dbo::Session& session, const std::string& MBID);
static std::vector<pointer> getByName(Wt::Dbo::Session& session, const std::string& name);
static pointer getById(Wt::Dbo::Session& session, IdType id);
static std::vector<pointer> getAllOrphans(Wt::Dbo::Session& session); // no track related
static std::vector<pointer> getAll(Wt::Dbo::Session& session, int offset, int size);
static std::vector<pointer> getLastAdded(Wt::Dbo::Session& session, Wt::WDateTime after, int size = 1);
static std::vector<pointer> getAll(Wt::Dbo::Session& session, boost::optional<std::size_t> offset = {}, boost::optional<std::size_t> size = {});
static std::vector<pointer> getAllRandom(Wt::Dbo::Session& session, boost::optional<std::size_t> size = {});
static std::vector<pointer> getLastAdded(Wt::Dbo::Session& session, Wt::WDateTime after, boost::optional<std::size_t> offset = {}, boost::optional<std::size_t> size = {});
static std::vector<pointer> getByFilter(Wt::Dbo::Session& session,
const std::set<IdType>& clusters, // at least one track that belongs to these clusters
+14 -2
View File
@@ -38,9 +38,21 @@ _filePath( p.string() )
}
Wt::Dbo::collection< Track::pointer >
Track::getAll(Wt::Dbo::Session& session)
Track::getAll(Wt::Dbo::Session& session, boost::optional<std::size_t> limit)
{
return session.find<Track>();
int size {limit ? static_cast<int>(*limit) : -1};
return session.find<Track>().limit(size);
}
std::vector<Track::pointer>
Track::getAllRandom(Wt::Dbo::Session& session, boost::optional<std::size_t> limit)
{
Wt::Dbo::collection<Track::pointer> res {session.find<Track>()
.limit(limit ? static_cast<int>(*limit) : -1)
.orderBy("RANDOM()")};
return std::vector<Track::pointer>(std::cbegin(res), std::cend(res));
}
std::vector<IdType>
+2 -1
View File
@@ -63,7 +63,8 @@ class Track : public Wt::Dbo::Dbo<Track>
int size,
bool& moreExpected);
static Wt::Dbo::collection< pointer > getAll(Wt::Dbo::Session& session);
static Wt::Dbo::collection< pointer > getAll(Wt::Dbo::Session& session, boost::optional<std::size_t> limit = {});
static std::vector<pointer> getAllRandom(Wt::Dbo::Session& session, boost::optional<std::size_t> limit = {});
static std::vector<IdType> getAllIds(Wt::Dbo::Session& session); // nested transaction
static std::vector<boost::filesystem::path> getAllPaths(Wt::Dbo::Session& session); // nested transaction
static std::vector<pointer> getMBIDDuplicates(Wt::Dbo::Session& session);
+28 -10
View File
@@ -83,15 +83,22 @@ Image::load(boost::filesystem::path p)
}
}
bool
Image::scale(std::size_t size)
Geometry
Image::getSize() const
{
if (!size)
Magick::Geometry geometry {_image.size()};
return {geometry.width(), geometry.height()};
}
bool
Image::scale(Geometry geometry)
{
if (geometry.width == 0 || geometry.height == 0)
return false;
try
{
_image.resize( Magick::Geometry(size, size ) );
_image.resize( Magick::Geometry(geometry.width, geometry.height ) );
return true;
}
@@ -105,16 +112,27 @@ Image::scale(std::size_t size)
std::vector<uint8_t>
Image::save(Format format) const
{
Magick::Image outputImage(_image);
std::vector<uint8_t> res;
outputImage.magick( format_to_magick(format));
try
{
Magick::Image outputImage(_image);
Magick::Blob blob;
outputImage.write(&blob);
outputImage.magick( format_to_magick(format));
auto begin = static_cast<const uint8_t*>(blob.data());
Magick::Blob blob;
outputImage.write(&blob);
return std::vector<uint8_t>(begin, begin + blob.length());
auto begin = static_cast<const uint8_t*>(blob.data());
std::copy(begin, begin + blob.length(), std::back_inserter(res));
return res;
}
catch (Magick::Exception& e)
{
LMS_LOG(COVER, ERROR) << "Caught Magick exception during save:" << e.what();
res.clear();
return res;
}
}
} // namespace Image
+9 -1
View File
@@ -37,6 +37,12 @@ std::string format_to_mimeType(Format format);
void init(const char *path);
struct Geometry
{
std::size_t width;
std::size_t height;
};
class Image
{
public:
@@ -45,8 +51,10 @@ class Image
bool load(const std::vector<unsigned char>& rawData);
bool load(boost::filesystem::path p);
Geometry getSize() const;
// Operations
bool scale(std::size_t size);
bool scale(Geometry geometry);
// output
std::vector<uint8_t> save(Format format) const;
+10 -1
View File
@@ -23,6 +23,7 @@
#include <Wt/WServer.h>
#include <Wt/WApplication.h>
#include "api/subsonic/SubsonicResource.hpp"
#include "av/AvInfo.hpp"
#include "av/AvTranscoder.hpp"
#include "cover/CoverArtGrabber.hpp"
@@ -132,9 +133,17 @@ int main(int argc, char* argv[])
getServices().mediaScanner->setAddon(similarityFeaturesScannerAddon);
getServices().coverArtGrabber = std::make_unique<CoverArt::Grabber>();
getServices().coverArtGrabber->setDefaultCover(server.appRoot() + "/images/unknown-cover.jpg");
getServices().similaritySearcher = std::make_unique<Similarity::Searcher>(similarityFeaturesScannerAddon);
// bind entry point
API::Subsonic::SubsonicResource subsonicResource {*connectionPool};
// bind API resources
for (const std::string& path : API::Subsonic::SubsonicResource::getPaths())
server.addResource(&subsonicResource, path);
// bind UI entry point
server.addEntryPoint(Wt::EntryPointType::Application,
std::bind(UserInterface::LmsApplication::create,
std::placeholders::_1, std::ref(*connectionPool), std::ref(appGroups)));
-3
View File
@@ -109,9 +109,6 @@ LmsApplication::LmsApplication(const Wt::WEnvironment& env,
messageResourceBundle().use(appRoot() + "tracks");
messageResourceBundle().use(appRoot() + "tracksinfo");
// hack since Server does not expose the docRoot
getServices().coverArtGrabber->setDefaultCover(Wt::WApplication::instance()->docRoot() + "/images/unknown-cover.jpg");
// Require js here to avoid async problems
requireJQuery("/js/jquery-1.10.2.min.js");
require("/js/mediaplayer.js");
+1 -1
View File
@@ -60,7 +60,7 @@ ReleasesInfo::refreshRecentlyAdded()
Wt::Dbo::Transaction transaction(LmsApp->getDboSession());
auto releases = Release::getLastAdded(LmsApp->getDboSession(), after, 5);
auto releases {Release::getLastAdded(LmsApp->getDboSession(), after, 0, 5)};
_recentlyAddedContainer->clear();
for (auto release : releases)
+1
View File
@@ -23,6 +23,7 @@ std::string getModuleName(Module mod)
{
switch (mod)
{
case Module::API_SUBSONIC: return "API_SUBSONIC";
case Module::AV: return "AV";
case Module::COVER: return "COVER";
case Module::DB: return "DB";
+1
View File
@@ -35,6 +35,7 @@ enum class Severity
enum class Module
{
API_SUBSONIC,
AV,
COVER,
DB,