diff --git a/Makefile.am b/Makefile.am index 954821e3..ba2c63cd 100644 --- a/Makefile.am +++ b/Makefile.am @@ -7,21 +7,21 @@ lms_docrootdir=$(pkgdatadir)/docroot lms_approotdir=$(pkgdatadir)/approot lms_cssdir=$(lms_docrootdir)/css -lms_imagesdir=$(lms_docrootdir)/images lms_jsdir=$(lms_docrootdir)/js +lms_imagesdir=$(lms_approotdir)/images lms_css_DATA = \ docroot/css/lms.css -lms_images_DATA = \ - docroot/images/unknown-cover.jpg \ - docroot/images/unknown-artist.jpg - lms_js_DATA = \ docroot/js/bootstrap-notify.js \ docroot/js/jquery-1.10.2.min.js \ docroot/js/mediaplayer.js +lms_images_DATA = \ + approot/images/unknown-cover.jpg \ + approot/images/unknown-artist.jpg + lms_approot_DATA = \ approot/admin-database.xml \ approot/admin-user.xml \ diff --git a/docroot/images/unknown-artist.jpg b/approot/images/unknown-artist.jpg similarity index 100% rename from docroot/images/unknown-artist.jpg rename to approot/images/unknown-artist.jpg diff --git a/docroot/images/unknown-cover.jpg b/approot/images/unknown-cover.jpg similarity index 100% rename from docroot/images/unknown-cover.jpg rename to approot/images/unknown-cover.jpg diff --git a/src/Makefile.am b/src/Makefile.am index 6eb370e4..1e8f7830 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -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 \ diff --git a/src/api/subsonic/SubsonicId.cpp b/src/api/subsonic/SubsonicId.cpp new file mode 100644 index 00000000..02e270e7 --- /dev/null +++ b/src/api/subsonic/SubsonicId.cpp @@ -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 . + */ + +#include "SubsonicId.hpp" + +#include "utils/Logger.hpp" +#include "utils/Utils.hpp" + +namespace API::Subsonic +{ + +boost::optional +IdFromString(const std::string& id) +{ + std::vector 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(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 diff --git a/src/api/subsonic/SubsonicId.hpp b/src/api/subsonic/SubsonicId.hpp new file mode 100644 index 00000000..60604dbf --- /dev/null +++ b/src/api/subsonic/SubsonicId.hpp @@ -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 . + */ + +#include + +#include "database/Types.hpp" + +namespace API::Subsonic +{ + +struct Id +{ + enum class Type + { + Track, + Release, + Artist, + }; + + Type type; + Database::IdType id; +}; + +boost::optional IdFromString(const std::string& id); +std::string IdToString(const Id& id); + +} // namespace API::Subsonic diff --git a/src/api/subsonic/SubsonicResource.cpp b/src/api/subsonic/SubsonicResource.cpp new file mode 100644 index 00000000..91c0bbd2 --- /dev/null +++ b/src/api/subsonic/SubsonicResource.cpp @@ -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 . + */ +#include "SubsonicResource.hpp" + +#include +#include +#include + +#include + +#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; +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 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 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 mediaRetrievalHandlers +{ + {STREAM_URL, handleStream}, + {GET_COVER_ART_URL, handleGetCoverArt}, +}; + +template +boost::optional +getParameterAs(const Wt::Http::ParameterMap& parameterMap, const std::string& param) +{ + boost::optional res; + + auto it = parameterMap.find(param); + if (it == parameterMap.end()) + return res; + + if (it->second.size() != 1) + return res; + + return readAs(it->second.front()); +} + + +struct ClientInfo +{ + std::string name; + ResponseFormat format; + std::string user; + std::string password; +}; + +boost::optional +getClientInfo(const Wt::Http::ParameterMap& parameters) +{ + boost::optional res {ClientInfo {}}; + + // Mandatory parameters + auto param {getParameterAs(parameters, "c")}; + if (!param) + return {}; + res->name = *param; + + param = getParameterAs(parameters, "u"); + if (!param) + return {}; + res->user = *param; + + param = getParameterAs(parameters, "p"); + if (!param) + return {}; + res->password = *param; + + // Optional parameters + param = getParameterAs(parameters, "f"); + res->format = (param ? ResponseFormat::json : ResponseFormat::xml); // TODO + + return res; +} + +SubsonicResource::SubsonicResource(Wt::Dbo::SqlConnectionPool& connectionPool) +: _db {connectionPool} +{ +} + +std::vector +SubsonicResource::getPaths() +{ + std::vector 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(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 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 artists) +{ + if (artists.size() == 1) + return artists.front()->getName(); + + std::vector 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(track->getDuration()).count())); + trackResponse.setAttribute("suffix", "mp3"); + trackResponse.setAttribute("contentType", "audio/mpeg"); + trackResponse.setAttribute("size", std::to_string(128000/8 * std::chrono::duration_cast(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 ¶meters, Database::Handler& handler) +{ + return Response::createOkResponse(); +} + +Response +handleGetLicenseRequest(const Wt::Http::ParameterMap ¶meters, 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(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 getRandomAlbums(Wt::Dbo::Session& session, std::size_t offset, std::size_t size) +{ + std::vector res; + + std::size_t nbReleases {Database::Release::getCount(session)}; + if (offset > nbReleases) + return res; + + if (offset + size > nbReleases) + size = nbReleases - offset; + + std::vector 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(request, "type")}; + if (!type) + throw Error {Error::Code::RequiredParameterMissing}; + + // Optional params + auto size {getParameterAs(request, "size")}; + if (!size) + size = 10; + + auto offset {getParameterAs(request, "offset")}; + if (!offset) + offset = 0; + + std::vector 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(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 +createTranscoder(const Wt::Http::ParameterMap& request, Database::Handler& db) +{ + // Mandatory params + auto idParam {getParameterAs(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(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 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>(continuation->data()); + } + + if (!transcoder) + throw Error {"transcoding failed"}; + + if (!transcoder->isComplete()) + { + static constexpr std::size_t chunkSize {65536*4}; + + std::vector data; + data.reserve(chunkSize); + + transcoder->process(data, chunkSize); + + LMS_LOG(API_SUBSONIC, DEBUG) << "Writing " << data.size() << " bytes..."; + response.out().write(reinterpret_cast(&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(request.getParameterMap(), "id")}; + if (!idParam) + throw Error {Error::Code::RequiredParameterMissing}; + + auto id {IdFromString(*idParam)}; + if (!id) + throw Error {"bad id format"}; + + auto size {getParameterAs(request.getParameterMap(), "size")}; + if (!size || *size == 0) + size = 256; + + std::vector 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(&cover[0]), cover.size()); +} + +} // namespace api::subsonic + diff --git a/src/api/subsonic/SubsonicResource.hpp b/src/api/subsonic/SubsonicResource.hpp new file mode 100644 index 00000000..ca0ac427 --- /dev/null +++ b/src/api/subsonic/SubsonicResource.hpp @@ -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 . + */ +#pragma once + +#include + +#include +#include + +#include "database/DatabaseHandler.hpp" + +namespace API::Subsonic +{ + +class SubsonicResource final : public Wt::WResource +{ + public: + SubsonicResource(Wt::Dbo::SqlConnectionPool& connectionPool); + + static std::vector getPaths(); + private: + + + void handleRequest(const Wt::Http::Request &request, Wt::Http::Response &response) override; + + Database::Handler _db; +}; + +} // namespace diff --git a/src/api/subsonic/SubsonicResponse.cpp b/src/api/subsonic/SubsonicResponse.cpp new file mode 100644 index 00000000..fbc830e6 --- /dev/null +++ b/src/api/subsonic/SubsonicResponse.cpp @@ -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 . + */ + +#include "SubsonicResponse.hpp" + +#include +#include + +#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(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& 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 ? "." : ""}; + + 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 + diff --git a/src/api/subsonic/SubsonicResponse.hpp b/src/api/subsonic/SubsonicResponse.hpp new file mode 100644 index 00000000..7154e4a1 --- /dev/null +++ b/src/api/subsonic/SubsonicResponse.hpp @@ -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 . + */ + +#include +#include +#include + +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 attributes; + std::map> children; + std::map> 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 + diff --git a/src/cover/CoverArtGrabber.cpp b/src/cover/CoverArtGrabber.cpp index 5ab9a7d1..7bdb8df1 100644 --- a/src/cover/CoverArtGrabber.cpp +++ b/src/cover/CoverArtGrabber.cpp @@ -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 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; } diff --git a/src/database/Release.cpp b/src/database/Release.cpp index 66063557..60060479 100644 --- a/src/database/Release.cpp +++ b/src/database/Release.cpp @@ -61,10 +61,30 @@ Release::create(Wt::Dbo::Session& session, const std::string& name, const std::s return session.add(std::make_unique(name, MBID)); } -std::vector -Release::getAll(Wt::Dbo::Session& session, int offset, int size) +std::size_t +Release::getCount(Wt::Dbo::Session& session) { - Wt::Dbo::collection res = session.find().offset(offset).limit(size); + Wt::Dbo::collection releases {session.find()}; + return releases.size(); +} + +std::vector +Release::getAll(Wt::Dbo::Session& session, boost::optional offset, boost::optional size) +{ + Wt::Dbo::collection res = session.find() + .offset(offset ? static_cast(*offset) : -1) + .limit(size ? static_cast(*size) : - 1); + + return std::vector(res.begin(), res.end()); +} + +std::vector +Release::getAllRandom(Wt::Dbo::Session& session, boost::optional size) +{ + Wt::Dbo::collection res = session.find() + .limit(size ? static_cast(*size) : - 1) + .orderBy("RANDOM()"); + return std::vector(res.begin(), res.end()); } @@ -77,13 +97,14 @@ Release::getAllOrphans(Wt::Dbo::Session& session) } std::vector -Release::getLastAdded(Wt::Dbo::Session& session, Wt::WDateTime after, int limit) +Release::getLastAdded(Wt::Dbo::Session& session, Wt::WDateTime after, boost::optional offset, boost::optional limit) { Wt::Dbo::collection res = session.query("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(*offset) : -1) + .limit(limit ? static_cast(*limit) : -1); return std::vector(res.begin(), res.end()); } diff --git a/src/database/Release.hpp b/src/database/Release.hpp index fe71a6f8..588612ec 100644 --- a/src/database/Release.hpp +++ b/src/database/Release.hpp @@ -44,12 +44,14 @@ class Release : public Wt::Dbo::Dbo 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 getByName(Wt::Dbo::Session& session, const std::string& name); static pointer getById(Wt::Dbo::Session& session, IdType id); static std::vector getAllOrphans(Wt::Dbo::Session& session); // no track related - static std::vector getAll(Wt::Dbo::Session& session, int offset, int size); - static std::vector getLastAdded(Wt::Dbo::Session& session, Wt::WDateTime after, int size = 1); + static std::vector getAll(Wt::Dbo::Session& session, boost::optional offset = {}, boost::optional size = {}); + static std::vector getAllRandom(Wt::Dbo::Session& session, boost::optional size = {}); + static std::vector getLastAdded(Wt::Dbo::Session& session, Wt::WDateTime after, boost::optional offset = {}, boost::optional size = {}); static std::vector getByFilter(Wt::Dbo::Session& session, const std::set& clusters, // at least one track that belongs to these clusters diff --git a/src/database/Track.cpp b/src/database/Track.cpp index 8548798d..4a14d2c8 100644 --- a/src/database/Track.cpp +++ b/src/database/Track.cpp @@ -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 limit) { - return session.find(); + int size {limit ? static_cast(*limit) : -1}; + + return session.find().limit(size); +} + +std::vector +Track::getAllRandom(Wt::Dbo::Session& session, boost::optional limit) +{ + Wt::Dbo::collection res {session.find() + .limit(limit ? static_cast(*limit) : -1) + .orderBy("RANDOM()")}; + + return std::vector(std::cbegin(res), std::cend(res)); } std::vector diff --git a/src/database/Track.hpp b/src/database/Track.hpp index 1f42f27e..c272c537 100644 --- a/src/database/Track.hpp +++ b/src/database/Track.hpp @@ -63,7 +63,8 @@ class Track : public Wt::Dbo::Dbo 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 limit = {}); + static std::vector getAllRandom(Wt::Dbo::Session& session, boost::optional limit = {}); static std::vector getAllIds(Wt::Dbo::Session& session); // nested transaction static std::vector getAllPaths(Wt::Dbo::Session& session); // nested transaction static std::vector getMBIDDuplicates(Wt::Dbo::Session& session); diff --git a/src/image/Image.cpp b/src/image/Image.cpp index 2822936e..a4a7ad98 100644 --- a/src/image/Image.cpp +++ b/src/image/Image.cpp @@ -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 Image::save(Format format) const { - Magick::Image outputImage(_image); + std::vector 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(blob.data()); + Magick::Blob blob; + outputImage.write(&blob); - return std::vector(begin, begin + blob.length()); + auto begin = static_cast(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 diff --git a/src/image/Image.hpp b/src/image/Image.hpp index 0a12c8cf..89e9a6ba 100644 --- a/src/image/Image.hpp +++ b/src/image/Image.hpp @@ -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& rawData); bool load(boost::filesystem::path p); + Geometry getSize() const; + // Operations - bool scale(std::size_t size); + bool scale(Geometry geometry); // output std::vector save(Format format) const; diff --git a/src/main/main.cpp b/src/main/main.cpp index aa8e5354..bc88f2c1 100644 --- a/src/main/main.cpp +++ b/src/main/main.cpp @@ -23,6 +23,7 @@ #include #include +#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(); + getServices().coverArtGrabber->setDefaultCover(server.appRoot() + "/images/unknown-cover.jpg"); + getServices().similaritySearcher = std::make_unique(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))); diff --git a/src/ui/LmsApplication.cpp b/src/ui/LmsApplication.cpp index 9e95055d..4fcef0a3 100644 --- a/src/ui/LmsApplication.cpp +++ b/src/ui/LmsApplication.cpp @@ -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"); diff --git a/src/ui/explore/ReleasesInfoView.cpp b/src/ui/explore/ReleasesInfoView.cpp index 2c5effce..2c84277e 100644 --- a/src/ui/explore/ReleasesInfoView.cpp +++ b/src/ui/explore/ReleasesInfoView.cpp @@ -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) diff --git a/src/utils/Logger.cpp b/src/utils/Logger.cpp index 10ed9026..d1103a6d 100644 --- a/src/utils/Logger.cpp +++ b/src/utils/Logger.cpp @@ -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"; diff --git a/src/utils/Logger.hpp b/src/utils/Logger.hpp index 837c14eb..5a7585f8 100644 --- a/src/utils/Logger.hpp +++ b/src/utils/Logger.hpp @@ -35,6 +35,7 @@ enum class Severity enum class Module { + API_SUBSONIC, AV, COVER, DB,