Implement the new OpenSubsonic API transcoding extension fixes #787
This commit is contained in:
+2
-2
@@ -70,9 +70,9 @@ RUN \
|
|||||||
--enable-zlib \
|
--enable-zlib \
|
||||||
--disable-everything \
|
--disable-everything \
|
||||||
--enable-decoder=aac*,ac3*,alac,als,ape,asf,dsd*,flac,libopus,pcm*,libvorbis,mp3*,mpc7,mpc8,shorten,tta,wavpack,wma*,libopenjpg,png \
|
--enable-decoder=aac*,ac3*,alac,als,ape,asf,dsd*,flac,libopus,pcm*,libvorbis,mp3*,mpc7,mpc8,shorten,tta,wavpack,wma*,libopenjpg,png \
|
||||||
--enable-encoder=libmp3lame,libopus,libvorbis \
|
--enable-encoder=flac,libmp3lame,libopus,libvorbis \
|
||||||
--enable-demuxer=aac,aiff,ape,asf,dsf,flac,m4a,mp3,mov,mpc,mpc8,ogg,shn,tta,wav,wv \
|
--enable-demuxer=aac,aiff,ape,asf,dsf,flac,m4a,mp3,mov,mpc,mpc8,ogg,shn,tta,wav,wv \
|
||||||
--enable-muxer=ogg,mp3 \
|
--enable-muxer=flac,mp3,ogg \
|
||||||
--enable-protocol=file,pipe \
|
--enable-protocol=file,pipe \
|
||||||
--enable-filter=aresample \
|
--enable-filter=aresample \
|
||||||
--enable-lto \
|
--enable-lto \
|
||||||
|
|||||||
@@ -136,6 +136,25 @@ namespace lms::audio::ffmpeg
|
|||||||
args.emplace_back(std::to_string(*_outputParams.bitrate));
|
args.emplace_back(std::to_string(*_outputParams.bitrate));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_outputParams.channelCount)
|
||||||
|
{
|
||||||
|
args.emplace_back("-ac");
|
||||||
|
args.emplace_back(std::to_string(*_outputParams.channelCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_outputParams.sampleRate)
|
||||||
|
{
|
||||||
|
args.emplace_back("-ar");
|
||||||
|
args.emplace_back(std::to_string(*_outputParams.sampleRate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_outputParams.bitsPerSample)
|
||||||
|
{
|
||||||
|
args.emplace_back("-sample_fmt");
|
||||||
|
args.emplace_back("s" + std::to_string(*_outputParams.bitsPerSample));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codecs and formats
|
||||||
if (_outputParams.format)
|
if (_outputParams.format)
|
||||||
{
|
{
|
||||||
args.emplace_back("-f");
|
args.emplace_back("-f");
|
||||||
|
|||||||
@@ -71,4 +71,38 @@ namespace lms::core::media
|
|||||||
|
|
||||||
return "Unknown";
|
return "Unknown";
|
||||||
}
|
}
|
||||||
} // namespace lms::core::media
|
|
||||||
|
bool isCodecLossless(CodecType type)
|
||||||
|
{
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case CodecType::ALAC:
|
||||||
|
case CodecType::APE:
|
||||||
|
case CodecType::DSD:
|
||||||
|
|
||||||
|
case CodecType::FLAC:
|
||||||
|
case CodecType::MP4ALS:
|
||||||
|
case CodecType::PCM:
|
||||||
|
case CodecType::Shorten:
|
||||||
|
case CodecType::TrueAudio:
|
||||||
|
case CodecType::WavPack:
|
||||||
|
case CodecType::WMA9Lossless:
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case CodecType::AAC:
|
||||||
|
case CodecType::AC3:
|
||||||
|
case CodecType::EAC3:
|
||||||
|
case CodecType::MP3:
|
||||||
|
case CodecType::MPC7:
|
||||||
|
case CodecType::MPC8:
|
||||||
|
case CodecType::Opus:
|
||||||
|
case CodecType::Vorbis:
|
||||||
|
case CodecType::WMA1:
|
||||||
|
case CodecType::WMA2:
|
||||||
|
case CodecType::WMA9Pro:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} // namespace lms::core::media
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
@@ -49,3 +50,15 @@ namespace lms::core::stringUtils
|
|||||||
std::optional<UUID>
|
std::optional<UUID>
|
||||||
readAs(std::string_view str);
|
readAs(std::string_view str);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace std
|
||||||
|
{
|
||||||
|
template<>
|
||||||
|
struct hash<lms::core::UUID>
|
||||||
|
{
|
||||||
|
size_t operator()(const lms::core::UUID& str) const
|
||||||
|
{
|
||||||
|
return hash<std::string_view>{}(str.getAsString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} // namespace std
|
||||||
|
|||||||
@@ -49,4 +49,6 @@ namespace lms::core::media
|
|||||||
};
|
};
|
||||||
|
|
||||||
core::LiteralString codecTypeToString(CodecType type);
|
core::LiteralString codecTypeToString(CodecType type);
|
||||||
} // namespace lms::core::media
|
|
||||||
|
bool isCodecLossless(CodecType type);
|
||||||
|
} // namespace lms::core::media
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
|
||||||
add_library(lmssubsonic STATIC
|
add_library(lmssubsonic STATIC
|
||||||
|
impl/endpoints/transcoding/AudioFileInfo.cpp
|
||||||
|
impl/endpoints/transcoding/TranscodeDecision.cpp
|
||||||
|
impl/endpoints/transcoding/TranscodeDecisionTracker.cpp
|
||||||
impl/endpoints/AlbumSongLists.cpp
|
impl/endpoints/AlbumSongLists.cpp
|
||||||
impl/endpoints/Bookmarks.cpp
|
impl/endpoints/Bookmarks.cpp
|
||||||
impl/endpoints/Browsing.cpp
|
impl/endpoints/Browsing.cpp
|
||||||
@@ -10,7 +13,10 @@ add_library(lmssubsonic STATIC
|
|||||||
impl/endpoints/Podcast.cpp
|
impl/endpoints/Podcast.cpp
|
||||||
impl/endpoints/Searching.cpp
|
impl/endpoints/Searching.cpp
|
||||||
impl/endpoints/System.cpp
|
impl/endpoints/System.cpp
|
||||||
|
impl/endpoints/Transcoding.cpp
|
||||||
impl/endpoints/UserManagement.cpp
|
impl/endpoints/UserManagement.cpp
|
||||||
|
impl/payloads/ClientInfo.cpp
|
||||||
|
impl/payloads/StreamDetails.cpp
|
||||||
impl/responses/Album.cpp
|
impl/responses/Album.cpp
|
||||||
impl/responses/AlbumInfo.cpp
|
impl/responses/AlbumInfo.cpp
|
||||||
impl/responses/Artist.cpp
|
impl/responses/Artist.cpp
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
#include "endpoints/Podcast.hpp"
|
#include "endpoints/Podcast.hpp"
|
||||||
#include "endpoints/Searching.hpp"
|
#include "endpoints/Searching.hpp"
|
||||||
#include "endpoints/System.hpp"
|
#include "endpoints/System.hpp"
|
||||||
|
#include "endpoints/Transcoding.hpp"
|
||||||
#include "endpoints/UserManagement.hpp"
|
#include "endpoints/UserManagement.hpp"
|
||||||
|
|
||||||
namespace lms::api::subsonic
|
namespace lms::api::subsonic
|
||||||
@@ -181,6 +182,9 @@ namespace lms::api::subsonic
|
|||||||
{ "/getLyricsBySongId", { handleGetLyricsBySongId } },
|
{ "/getLyricsBySongId", { handleGetLyricsBySongId } },
|
||||||
{ "/getAvatar", { handleNotImplemented } },
|
{ "/getAvatar", { handleNotImplemented } },
|
||||||
|
|
||||||
|
// Transcoding extensions
|
||||||
|
{ "/getTranscodeDecision", { handleGetTranscodeDecision } },
|
||||||
|
|
||||||
// Media annotation
|
// Media annotation
|
||||||
{ "/star", { handleStarRequest } },
|
{ "/star", { handleStarRequest } },
|
||||||
{ "/unstar", { handleUnstarRequest } },
|
{ "/unstar", { handleUnstarRequest } },
|
||||||
@@ -244,6 +248,9 @@ namespace lms::api::subsonic
|
|||||||
{ "/download", handleDownload },
|
{ "/download", handleDownload },
|
||||||
{ "/stream", handleStream },
|
{ "/stream", handleStream },
|
||||||
{ "/getCoverArt", handleGetCoverArt },
|
{ "/getCoverArt", handleGetCoverArt },
|
||||||
|
|
||||||
|
// Transcoding extension
|
||||||
|
{ "/getTranscodeStream", handleGetTranscodeStream },
|
||||||
};
|
};
|
||||||
|
|
||||||
struct TLSMonotonicMemoryResourceCleaner
|
struct TLSMonotonicMemoryResourceCleaner
|
||||||
@@ -352,9 +359,7 @@ namespace lms::api::subsonic
|
|||||||
}
|
}
|
||||||
catch (const Error& e)
|
catch (const Error& e)
|
||||||
{
|
{
|
||||||
LMS_LOG(API_SUBSONIC, ERROR, "Error while processing request '" << requestPath << "'"
|
LMS_LOG(API_SUBSONIC, ERROR, "Error while processing request '" << requestPath << "'" << ", params = [" << parameterMapToDebugString(request.getParameterMap()) << "]" << ", code = " << static_cast<int>(e.getCode()) << ", msg = '" << e.getMessage() << "'");
|
||||||
<< ", params = [" << parameterMapToDebugString(request.getParameterMap()) << "]"
|
|
||||||
<< ", code = " << static_cast<int>(e.getCode()) << ", msg = '" << e.getMessage() << "'");
|
|
||||||
Response resp{ Response::createFailedResponse(protocolVersion, e) };
|
Response resp{ Response::createFailedResponse(protocolVersion, e) };
|
||||||
resp.write(response.out(), format);
|
resp.write(response.out(), format);
|
||||||
response.setMimeType(std::string{ ResponseFormatToMimeType(format) });
|
response.setMimeType(std::string{ ResponseFormatToMimeType(format) });
|
||||||
|
|||||||
@@ -219,13 +219,24 @@ namespace lms::api::subsonic
|
|||||||
class BadParameterGenericError : public GenericError
|
class BadParameterGenericError : public GenericError
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
BadParameterGenericError(const std::string& parameterName)
|
BadParameterGenericError(std::string_view parameterName, std::string_view details = {})
|
||||||
: _parameterName{ parameterName } {}
|
: _parameterName{ parameterName }
|
||||||
|
, _details{ details }
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string_view getParameterName() const { return _parameterName; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::string getMessage() const override { return "Parameter '" + _parameterName + "': bad value"; }
|
std::string getMessage() const override
|
||||||
|
{
|
||||||
|
std::string res{ "Parameter '" + _parameterName + "': " };
|
||||||
|
res += _details.empty() ? "bad value" : _details;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
const std::string _parameterName;
|
const std::string _parameterName;
|
||||||
|
const std::string _details;
|
||||||
};
|
};
|
||||||
|
|
||||||
class ParameterValueTooHighGenericError : public GenericError
|
class ParameterValueTooHighGenericError : public GenericError
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ namespace lms::api::subsonic
|
|||||||
|
|
||||||
const int rating = getMandatoryParameterAs<int>(parameters, "rating"); // The rating between 1 and 5 (inclusive), or 0 to remove the rating
|
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)
|
if (rating < 0 || rating > 5)
|
||||||
throw BadParameterGenericError{ "rating must be 0 or in range 1-5" };
|
throw BadParameterGenericError{ "rating", "must be 0 or in range 1-5" };
|
||||||
|
|
||||||
if (rating > 0)
|
if (rating > 0)
|
||||||
res.rating = rating;
|
res.rating = rating;
|
||||||
|
|||||||
@@ -28,13 +28,7 @@
|
|||||||
#include "core/String.hpp"
|
#include "core/String.hpp"
|
||||||
#include "core/media/MimeType.hpp"
|
#include "core/media/MimeType.hpp"
|
||||||
|
|
||||||
#include "audio/AudioProperties.hpp"
|
|
||||||
#include "audio/Exception.hpp"
|
|
||||||
#include "audio/IAudioFileInfo.hpp"
|
|
||||||
#include "audio/IAudioFileInfoParser.hpp"
|
|
||||||
|
|
||||||
#include "database/Session.hpp"
|
#include "database/Session.hpp"
|
||||||
#include "database/objects/PodcastEpisode.hpp"
|
|
||||||
#include "database/objects/PodcastEpisodeId.hpp"
|
#include "database/objects/PodcastEpisodeId.hpp"
|
||||||
#include "database/objects/Track.hpp"
|
#include "database/objects/Track.hpp"
|
||||||
#include "database/objects/TrackLyrics.hpp"
|
#include "database/objects/TrackLyrics.hpp"
|
||||||
@@ -42,7 +36,6 @@
|
|||||||
#include "database/objects/User.hpp"
|
#include "database/objects/User.hpp"
|
||||||
|
|
||||||
#include "services/artwork/IArtworkService.hpp"
|
#include "services/artwork/IArtworkService.hpp"
|
||||||
#include "services/podcast/IPodcastService.hpp"
|
|
||||||
#include "services/transcoding/ITranscodeService.hpp"
|
#include "services/transcoding/ITranscodeService.hpp"
|
||||||
|
|
||||||
#include "CoverArtId.hpp"
|
#include "CoverArtId.hpp"
|
||||||
@@ -51,6 +44,7 @@
|
|||||||
#include "SubsonicId.hpp"
|
#include "SubsonicId.hpp"
|
||||||
#include "SubsonicResponse.hpp"
|
#include "SubsonicResponse.hpp"
|
||||||
#include "responses/Lyrics.hpp"
|
#include "responses/Lyrics.hpp"
|
||||||
|
#include "transcoding/AudioFileInfo.hpp"
|
||||||
|
|
||||||
namespace lms::api::subsonic
|
namespace lms::api::subsonic
|
||||||
{
|
{
|
||||||
@@ -106,93 +100,6 @@ namespace lms::api::subsonic
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
audio::AudioProperties getAudioProperties(const std::filesystem::path& trackPath)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const auto parser{ audio::createAudioFileInfoParser(audio::AudioFileInfoParserBackend::FFmpeg) };
|
|
||||||
|
|
||||||
audio::AudioFileInfoParseOptions parseOptions;
|
|
||||||
parseOptions.audioPropertiesReadStyle = audio::AudioFileInfoParseOptions::AudioPropertiesReadStyle::Average;
|
|
||||||
parseOptions.readImages = false;
|
|
||||||
parseOptions.readTags = false;
|
|
||||||
const auto audioFile{ parser->parse(trackPath, parseOptions) };
|
|
||||||
|
|
||||||
const audio::AudioProperties* properties{ audioFile->getAudioProperties() };
|
|
||||||
if (!properties)
|
|
||||||
throw RequestedDataNotFoundError{};
|
|
||||||
|
|
||||||
return *properties;
|
|
||||||
}
|
|
||||||
catch (const audio::Exception& e)
|
|
||||||
{
|
|
||||||
throw RequestedDataNotFoundError{};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
using AudioFileId = std::variant<db::TrackId, db::PodcastEpisodeId>;
|
|
||||||
struct AudioFileInfo
|
|
||||||
{
|
|
||||||
std::filesystem::path path;
|
|
||||||
audio::AudioProperties audioProperties;
|
|
||||||
};
|
|
||||||
|
|
||||||
AudioFileInfo getAudioFileInfo(db::Session& session, AudioFileId audioFileId)
|
|
||||||
{
|
|
||||||
AudioFileInfo res;
|
|
||||||
|
|
||||||
auto transaction{ session.createReadTransaction() };
|
|
||||||
|
|
||||||
if (const db::TrackId * trackId{ std::get_if<db::TrackId>(&audioFileId) })
|
|
||||||
{
|
|
||||||
const db::Track::pointer track{ db::Track::find(session, *trackId) };
|
|
||||||
if (!track)
|
|
||||||
throw RequestedDataNotFoundError{};
|
|
||||||
|
|
||||||
res.path = track->getAbsoluteFilePath();
|
|
||||||
if (track->getContainer() && track->getCodec())
|
|
||||||
{
|
|
||||||
res.audioProperties.container = *track->getContainer();
|
|
||||||
res.audioProperties.codec = *track->getCodec();
|
|
||||||
res.audioProperties.duration = track->getDuration();
|
|
||||||
res.audioProperties.bitrate = track->getBitrate();
|
|
||||||
res.audioProperties.channelCount = track->getChannelCount();
|
|
||||||
res.audioProperties.sampleRate = track->getSampleRate();
|
|
||||||
res.audioProperties.bitsPerSample = track->getBitsPerSample();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
res.audioProperties = getAudioProperties(res.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (const db::PodcastEpisodeId * episodeId{ std::get_if<db::PodcastEpisodeId>(&audioFileId) })
|
|
||||||
{
|
|
||||||
const db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(session, *episodeId) };
|
|
||||||
if (!episode)
|
|
||||||
throw RequestedDataNotFoundError{};
|
|
||||||
|
|
||||||
std::filesystem::path podcastCachePath{ core::Service<podcast::IPodcastService>::get()->getCachePath() };
|
|
||||||
|
|
||||||
res.path = podcastCachePath / episode->getAudioRelativeFilePath();
|
|
||||||
if (episode->getContainer() && episode->getCodec())
|
|
||||||
{
|
|
||||||
res.audioProperties.container = *episode->getContainer();
|
|
||||||
res.audioProperties.codec = *episode->getCodec();
|
|
||||||
res.audioProperties.duration = episode->getDuration();
|
|
||||||
res.audioProperties.bitrate = episode->getBitrate();
|
|
||||||
res.audioProperties.channelCount = episode->getChannelCount();
|
|
||||||
res.audioProperties.sampleRate = episode->getSampleRate();
|
|
||||||
res.audioProperties.bitsPerSample = episode->getBitsPerSample();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
res.audioProperties = getAudioProperties(res.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StreamParameters
|
struct StreamParameters
|
||||||
{
|
{
|
||||||
std::filesystem::path filePath;
|
std::filesystem::path filePath;
|
||||||
@@ -383,31 +290,23 @@ namespace lms::api::subsonic
|
|||||||
{
|
{
|
||||||
std::shared_ptr<core::IResourceHandler> resourceHandler;
|
std::shared_ptr<core::IResourceHandler> resourceHandler;
|
||||||
|
|
||||||
try
|
Wt::Http::ResponseContinuation* continuation = request.continuation();
|
||||||
|
if (!continuation)
|
||||||
{
|
{
|
||||||
Wt::Http::ResponseContinuation* continuation = request.continuation();
|
StreamParameters streamParameters{ getStreamParameters(context) };
|
||||||
if (!continuation)
|
if (streamParameters.transcodeParameters)
|
||||||
{
|
resourceHandler = core::Service<transcoding::ITranscodeService>::get()->createTranscodeResourceHandler(*streamParameters.transcodeParameters, streamParameters.estimateContentLength);
|
||||||
StreamParameters streamParameters{ getStreamParameters(context) };
|
|
||||||
if (streamParameters.transcodeParameters)
|
|
||||||
resourceHandler = core::Service<transcoding::ITranscodeService>::get()->createTranscodeResourceHandler(*streamParameters.transcodeParameters, streamParameters.estimateContentLength);
|
|
||||||
else
|
|
||||||
resourceHandler = core::createFileResourceHandler(streamParameters.filePath, core::media::getMimeType(streamParameters.audioProperties.container, streamParameters.audioProperties.codec).str());
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
resourceHandler = core::createFileResourceHandler(streamParameters.filePath, core::media::getMimeType(streamParameters.audioProperties.container, streamParameters.audioProperties.codec).str());
|
||||||
resourceHandler = Wt::cpp17::any_cast<std::shared_ptr<core::IResourceHandler>>(continuation->data());
|
|
||||||
}
|
|
||||||
|
|
||||||
continuation = resourceHandler->processRequest(request, response);
|
|
||||||
if (continuation)
|
|
||||||
continuation->setData(resourceHandler);
|
|
||||||
}
|
}
|
||||||
catch (const audio::Exception& e)
|
else
|
||||||
{
|
{
|
||||||
response.setStatus(404); // report not found if something wrong happened
|
resourceHandler = Wt::cpp17::any_cast<std::shared_ptr<core::IResourceHandler>>(continuation->data());
|
||||||
LMS_LOG(API_SUBSONIC, ERROR, "Caught Av exception: " << e.what());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
continuation = resourceHandler->processRequest(request, response);
|
||||||
|
if (continuation)
|
||||||
|
continuation->setData(resourceHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleGetCoverArt(RequestContext& context, const Wt::Http::Request& /*request*/, Wt::Http::Response& response)
|
void handleGetCoverArt(RequestContext& context, const Wt::Http::Request& /*request*/, Wt::Http::Response& response)
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ namespace lms::api::subsonic
|
|||||||
const std::string url{ getMandatoryParameterAs<std::string>(context.getParameters(), "url") };
|
const std::string url{ getMandatoryParameterAs<std::string>(context.getParameters(), "url") };
|
||||||
|
|
||||||
if (url.empty() || !(url.starts_with("http://") || url.starts_with("https://")))
|
if (url.empty() || !(url.starts_with("http://") || url.starts_with("https://")))
|
||||||
throw BadParameterGenericError{ "Invalid url" };
|
throw BadParameterGenericError{ "url", "must start by http:// or https://" };
|
||||||
|
|
||||||
// no effect if podcast already exists
|
// no effect if podcast already exists
|
||||||
core::Service<podcast::IPodcastService>::get()->addPodcast(url);
|
core::Service<podcast::IPodcastService>::get()->addPodcast(url);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
|
||||||
#include "endpoints/System.hpp"
|
#include "endpoints/System.hpp"
|
||||||
|
|
||||||
namespace lms::api::subsonic
|
namespace lms::api::subsonic
|
||||||
@@ -42,40 +44,27 @@ namespace lms::api::subsonic
|
|||||||
{
|
{
|
||||||
Response response{ Response::createOkResponse(context.getServerProtocolVersion()) };
|
Response response{ Response::createOkResponse(context.getServerProtocolVersion()) };
|
||||||
|
|
||||||
|
struct Extension
|
||||||
{
|
{
|
||||||
Response::Node& transcodeOffsetNode{ response.createArrayNode("openSubsonicExtensions") };
|
core::LiteralString name;
|
||||||
transcodeOffsetNode.setAttribute("name", "transcodeOffset");
|
int version;
|
||||||
transcodeOffsetNode.addArrayValue("versions", 1);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
{
|
constexpr std::array extensions{
|
||||||
Response::Node& formPostNode{ response.createArrayNode("openSubsonicExtensions") };
|
Extension{ "apiKeyAuthentication", 1 },
|
||||||
formPostNode.setAttribute("name", "formPost");
|
Extension{ "getPodcastEpisode", 1 },
|
||||||
formPostNode.addArrayValue("versions", 1);
|
Extension{ "formPost", 1 },
|
||||||
}
|
Extension{ "indexBasedQueue", 1 },
|
||||||
|
Extension{ "songLyrics", 1 },
|
||||||
|
Extension{ "transcodeOffset", 1 },
|
||||||
|
Extension{ "transcoding", 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const Extension& extension : extensions)
|
||||||
{
|
{
|
||||||
Response::Node& songLyricsNode{ response.createArrayNode("openSubsonicExtensions") };
|
Response::Node& extensionNode{ response.createArrayNode("openSubsonicExtensions") };
|
||||||
songLyricsNode.setAttribute("name", "songLyrics");
|
extensionNode.setAttribute("name", extension.name.str());
|
||||||
songLyricsNode.addArrayValue("versions", 1);
|
extensionNode.addArrayValue("versions", extension.version);
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
Response::Node& apiKeyAuthentication{ response.createArrayNode("openSubsonicExtensions") };
|
|
||||||
apiKeyAuthentication.setAttribute("name", "apiKeyAuthentication");
|
|
||||||
apiKeyAuthentication.addArrayValue("versions", 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
Response::Node& apiKeyAuthentication{ response.createArrayNode("openSubsonicExtensions") };
|
|
||||||
apiKeyAuthentication.setAttribute("name", "getPodcastEpisode");
|
|
||||||
apiKeyAuthentication.addArrayValue("versions", 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
Response::Node& apiKeyAuthentication{ response.createArrayNode("openSubsonicExtensions") };
|
|
||||||
apiKeyAuthentication.setAttribute("name", "indexBasedQueue");
|
|
||||||
apiKeyAuthentication.addArrayValue("versions", 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 "Transcoding.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "core/IResourceHandler.hpp"
|
||||||
|
#include "core/Service.hpp"
|
||||||
|
#include "core/UUID.hpp"
|
||||||
|
#include "core/Utils.hpp"
|
||||||
|
|
||||||
|
#include "database/objects/PodcastEpisodeId.hpp"
|
||||||
|
#include "services/transcoding/ITranscodeService.hpp"
|
||||||
|
|
||||||
|
#include "ParameterParsing.hpp"
|
||||||
|
#include "RequestContext.hpp"
|
||||||
|
#include "SubsonicId.hpp"
|
||||||
|
#include "SubsonicResponse.hpp"
|
||||||
|
#include "payloads/ClientInfo.hpp"
|
||||||
|
#include "payloads/StreamDetails.hpp"
|
||||||
|
#include "transcoding/AudioFileInfo.hpp"
|
||||||
|
#include "transcoding/TranscodeDecision.hpp"
|
||||||
|
#include "transcoding/TranscodeDecisionTracker.hpp"
|
||||||
|
|
||||||
|
namespace lms::api::subsonic
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
StreamDetails createStreamDetailsFromAudioProperties(const audio::AudioProperties& audioProperties)
|
||||||
|
{
|
||||||
|
StreamDetails res;
|
||||||
|
res.protocol = "http";
|
||||||
|
res.container = core::media::containerTypeToString(audioProperties.container).str();
|
||||||
|
res.codec = core::media::codecTypeToString(audioProperties.codec).str();
|
||||||
|
res.audioChannels = audioProperties.channelCount;
|
||||||
|
res.audioBitrate = audioProperties.bitrate;
|
||||||
|
res.audioProfile = ""; // TODO
|
||||||
|
res.audioSamplerate = audioProperties.sampleRate;
|
||||||
|
res.audioBitdepth = audioProperties.bitsPerSample;
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioFileId getMandatoryMediaIdParameter(RequestContext& context)
|
||||||
|
{
|
||||||
|
const std::string mediaType{ getMandatoryParameterAs<std::string>(context.getParameters(), "mediaType") };
|
||||||
|
|
||||||
|
AudioFileId audioFileId;
|
||||||
|
if (mediaType == "song")
|
||||||
|
audioFileId = getMandatoryParameterAs<db::TrackId>(context.getParameters(), "mediaId");
|
||||||
|
else if (mediaType == "podcast")
|
||||||
|
audioFileId = getMandatoryParameterAs<db::PodcastEpisodeId>(context.getParameters(), "mediaId");
|
||||||
|
else
|
||||||
|
throw BadParameterGenericError{ "id", "must be 'song' or 'podcast'" };
|
||||||
|
|
||||||
|
return audioFileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Response handleGetTranscodeDecision(RequestContext& context)
|
||||||
|
{
|
||||||
|
// Parameters
|
||||||
|
const AudioFileId audioFileId{ getMandatoryMediaIdParameter(context) };
|
||||||
|
|
||||||
|
const ClientInfo clientInfo{ parseClientInfoFromJson(context.getBody()) };
|
||||||
|
const AudioFileInfo audioFileInfo{ getAudioFileInfo(context.getDbSession(), audioFileId) };
|
||||||
|
|
||||||
|
Response response{ Response::createOkResponse(context.getServerProtocolVersion()) };
|
||||||
|
Response::Node& transcodeNode{ response.createNode("transcodeDecision") };
|
||||||
|
|
||||||
|
{
|
||||||
|
const StreamDetails sourceStream{ createStreamDetailsFromAudioProperties(audioFileInfo.audioProperties) };
|
||||||
|
transcodeNode.addChild("sourceStream", createStreamDetails(sourceStream));
|
||||||
|
}
|
||||||
|
|
||||||
|
const details::TranscodeDecisionResult transcodeDecision{ details::computeTranscodeDecision(clientInfo, audioFileInfo.audioProperties) };
|
||||||
|
|
||||||
|
std::visit(core::utils::overloads{
|
||||||
|
[&](const details::DirectPlayResult&) {
|
||||||
|
transcodeNode.setAttribute("canDirectPlay", true);
|
||||||
|
transcodeNode.setAttribute("canTranscode", false);
|
||||||
|
},
|
||||||
|
[&](const details::TranscodeResult& transcodeRes) {
|
||||||
|
transcodeNode.setAttribute("canDirectPlay", false);
|
||||||
|
transcodeNode.setAttribute("canTranscode", true);
|
||||||
|
|
||||||
|
for (details::TranscodeReason reason : transcodeRes.reasons)
|
||||||
|
transcodeNode.addArrayValue("transcodeReason", transcodeReasonToString(reason).str());
|
||||||
|
|
||||||
|
const core::UUID uuid{ getTranscodeDecisionTracker().add(audioFileId, transcodeRes.targetStreamInfo) };
|
||||||
|
transcodeNode.addChild("transcodeStream", createStreamDetails(transcodeRes.targetStreamInfo));
|
||||||
|
transcodeNode.setAttribute("transcodeParams", uuid.getAsString());
|
||||||
|
},
|
||||||
|
[&](const details::FailureResult& failureRes) {
|
||||||
|
transcodeNode.setAttribute("canDirectPlay", false);
|
||||||
|
transcodeNode.setAttribute("canTranscode", false);
|
||||||
|
transcodeNode.setAttribute("errorReason", failureRes.reason);
|
||||||
|
} },
|
||||||
|
transcodeDecision);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
audio::TranscodeParameters getTranscodingParameters(RequestContext& context)
|
||||||
|
{
|
||||||
|
// Parameters
|
||||||
|
const AudioFileId audioFileId{ getMandatoryMediaIdParameter(context) };
|
||||||
|
const core::UUID uuid{ getMandatoryParameterAs<core::UUID>(context.getParameters(), "transcodeParams") };
|
||||||
|
const std::chrono::seconds offset{ getParameterAs<std::size_t>(context.getParameters(), "offset").value_or(0) };
|
||||||
|
|
||||||
|
const std::shared_ptr<ITranscodeDecisionTracker::Entry> entry{ getTranscodeDecisionTracker().get(uuid) };
|
||||||
|
if (!entry || entry->audioFileId != audioFileId)
|
||||||
|
throw RequestedDataNotFoundError{};
|
||||||
|
|
||||||
|
const AudioFileInfo audioFileInfo{ getAudioFileInfo(context.getDbSession(), audioFileId) };
|
||||||
|
|
||||||
|
audio::TranscodeParameters params;
|
||||||
|
params.inputParameters.filePath = audioFileInfo.path;
|
||||||
|
params.inputParameters.audioProperties = audioFileInfo.audioProperties;
|
||||||
|
params.inputParameters.offset = offset;
|
||||||
|
|
||||||
|
if (entry->targetStreamInfo.audioChannels)
|
||||||
|
params.outputParameters.channelCount = *entry->targetStreamInfo.audioChannels;
|
||||||
|
if (entry->targetStreamInfo.audioSamplerate)
|
||||||
|
params.outputParameters.sampleRate = *entry->targetStreamInfo.audioSamplerate;
|
||||||
|
if (entry->targetStreamInfo.audioBitrate)
|
||||||
|
params.outputParameters.bitrate = *entry->targetStreamInfo.audioBitrate;
|
||||||
|
if (entry->targetStreamInfo.audioBitdepth)
|
||||||
|
params.outputParameters.bitsPerSample = *entry->targetStreamInfo.audioBitdepth;
|
||||||
|
|
||||||
|
params.outputParameters.stripMetadata = false;
|
||||||
|
|
||||||
|
const audio::TranscodeOutputFormat* transcodeOutputFormat{ details::selectTranscodeOutputFormat(entry->targetStreamInfo.container, entry->targetStreamInfo.codec) };
|
||||||
|
if (!transcodeOutputFormat)
|
||||||
|
throw InternalErrorGenericError{ "Unsupported output format" };
|
||||||
|
|
||||||
|
params.outputParameters.format = *transcodeOutputFormat;
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleGetTranscodeStream(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response)
|
||||||
|
{
|
||||||
|
std::shared_ptr<core::IResourceHandler> resourceHandler;
|
||||||
|
|
||||||
|
Wt::Http::ResponseContinuation* continuation = request.continuation();
|
||||||
|
if (!continuation)
|
||||||
|
{
|
||||||
|
const audio::TranscodeParameters params{ getTranscodingParameters(context) };
|
||||||
|
resourceHandler = core::Service<transcoding::ITranscodeService>::get()->createTranscodeResourceHandler(params, false /* estimate content length */);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
resourceHandler = Wt::cpp17::any_cast<std::shared_ptr<core::IResourceHandler>>(continuation->data());
|
||||||
|
}
|
||||||
|
assert(resourceHandler); // handles errors internally
|
||||||
|
|
||||||
|
continuation = resourceHandler->processRequest(request, response);
|
||||||
|
if (continuation)
|
||||||
|
continuation->setData(resourceHandler);
|
||||||
|
}
|
||||||
|
} // namespace lms::api::subsonic
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 "SubsonicResponse.hpp"
|
||||||
|
|
||||||
|
namespace lms::api::subsonic
|
||||||
|
{
|
||||||
|
struct RequestContext;
|
||||||
|
|
||||||
|
Response handleGetTranscodeDecision(RequestContext& context);
|
||||||
|
void handleGetTranscodeStream(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response);
|
||||||
|
} // namespace lms::api::subsonic
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 <variant>
|
||||||
|
|
||||||
|
#include "database/objects/PodcastEpisodeId.hpp"
|
||||||
|
#include "database/objects/TrackId.hpp"
|
||||||
|
|
||||||
|
namespace lms::api::subsonic
|
||||||
|
{
|
||||||
|
using AudioFileId = std::variant<db::TrackId, db::PodcastEpisodeId>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 "AudioFileInfo.hpp"
|
||||||
|
|
||||||
|
#include "audio/AudioProperties.hpp"
|
||||||
|
#include "audio/Exception.hpp"
|
||||||
|
#include "audio/IAudioFileInfo.hpp"
|
||||||
|
#include "audio/IAudioFileInfoParser.hpp"
|
||||||
|
|
||||||
|
#include "database/Session.hpp"
|
||||||
|
#include "database/objects/PodcastEpisode.hpp"
|
||||||
|
#include "database/objects/Track.hpp"
|
||||||
|
|
||||||
|
#include "services/podcast/IPodcastService.hpp"
|
||||||
|
|
||||||
|
#include "SubsonicResponse.hpp"
|
||||||
|
|
||||||
|
namespace lms::api::subsonic
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
audio::AudioProperties getAudioProperties(const std::filesystem::path& trackPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const auto parser{ audio::createAudioFileInfoParser(audio::AudioFileInfoParserBackend::FFmpeg) };
|
||||||
|
|
||||||
|
audio::AudioFileInfoParseOptions parseOptions;
|
||||||
|
parseOptions.audioPropertiesReadStyle = audio::AudioFileInfoParseOptions::AudioPropertiesReadStyle::Average;
|
||||||
|
parseOptions.readImages = false;
|
||||||
|
parseOptions.readTags = false;
|
||||||
|
const auto audioFile{ parser->parse(trackPath, parseOptions) };
|
||||||
|
|
||||||
|
const audio::AudioProperties* properties{ audioFile->getAudioProperties() };
|
||||||
|
if (!properties)
|
||||||
|
throw RequestedDataNotFoundError{};
|
||||||
|
|
||||||
|
return *properties;
|
||||||
|
}
|
||||||
|
catch (const audio::Exception& e)
|
||||||
|
{
|
||||||
|
throw RequestedDataNotFoundError{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
AudioFileInfo getAudioFileInfo(db::Session& session, AudioFileId audioFileId)
|
||||||
|
{
|
||||||
|
AudioFileInfo res;
|
||||||
|
|
||||||
|
auto transaction{ session.createReadTransaction() };
|
||||||
|
|
||||||
|
if (const db::TrackId * trackId{ std::get_if<db::TrackId>(&audioFileId) })
|
||||||
|
{
|
||||||
|
const db::Track::pointer track{ db::Track::find(session, *trackId) };
|
||||||
|
if (!track)
|
||||||
|
throw RequestedDataNotFoundError{};
|
||||||
|
|
||||||
|
res.path = track->getAbsoluteFilePath();
|
||||||
|
if (track->getContainer() && track->getCodec())
|
||||||
|
{
|
||||||
|
res.audioProperties.container = *track->getContainer();
|
||||||
|
res.audioProperties.codec = *track->getCodec();
|
||||||
|
res.audioProperties.duration = track->getDuration();
|
||||||
|
res.audioProperties.bitrate = track->getBitrate();
|
||||||
|
res.audioProperties.channelCount = track->getChannelCount();
|
||||||
|
res.audioProperties.sampleRate = track->getSampleRate();
|
||||||
|
res.audioProperties.bitsPerSample = track->getBitsPerSample();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
res.audioProperties = getAudioProperties(res.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (const db::PodcastEpisodeId * episodeId{ std::get_if<db::PodcastEpisodeId>(&audioFileId) })
|
||||||
|
{
|
||||||
|
const db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(session, *episodeId) };
|
||||||
|
if (!episode)
|
||||||
|
throw RequestedDataNotFoundError{};
|
||||||
|
|
||||||
|
std::filesystem::path podcastCachePath{ core::Service<podcast::IPodcastService>::get()->getCachePath() };
|
||||||
|
|
||||||
|
res.path = podcastCachePath / episode->getAudioRelativeFilePath();
|
||||||
|
if (episode->getContainer() && episode->getCodec())
|
||||||
|
{
|
||||||
|
res.audioProperties.container = *episode->getContainer();
|
||||||
|
res.audioProperties.codec = *episode->getCodec();
|
||||||
|
res.audioProperties.duration = episode->getDuration();
|
||||||
|
res.audioProperties.bitrate = episode->getBitrate();
|
||||||
|
res.audioProperties.channelCount = episode->getChannelCount();
|
||||||
|
res.audioProperties.sampleRate = episode->getSampleRate();
|
||||||
|
res.audioProperties.bitsPerSample = episode->getBitsPerSample();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
res.audioProperties = getAudioProperties(res.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
} // namespace lms::api::subsonic
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 <filesystem>
|
||||||
|
|
||||||
|
#include "audio/AudioProperties.hpp"
|
||||||
|
|
||||||
|
#include "AudioFileId.hpp"
|
||||||
|
|
||||||
|
namespace lms::db
|
||||||
|
{
|
||||||
|
class Session;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace lms::api::subsonic
|
||||||
|
{
|
||||||
|
struct AudioFileInfo
|
||||||
|
{
|
||||||
|
std::filesystem::path path;
|
||||||
|
audio::AudioProperties audioProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
AudioFileInfo getAudioFileInfo(db::Session& session, AudioFileId audioFileId); // throw RequestedDataNotFoundError on failure
|
||||||
|
} // namespace lms::api::subsonic
|
||||||
@@ -0,0 +1,595 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 "TranscodeDecision.hpp"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
|
||||||
|
#include "core/ILogger.hpp"
|
||||||
|
#include "core/String.hpp"
|
||||||
|
|
||||||
|
#include "audio/TranscodeTypes.hpp"
|
||||||
|
|
||||||
|
#include "SubsonicResponse.hpp"
|
||||||
|
#include "payloads/ClientInfo.hpp"
|
||||||
|
|
||||||
|
namespace lms::api::subsonic::details
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr std::array supportedTranscodeOutputFormats{
|
||||||
|
audio::TranscodeOutputFormat{ .container = core::media::ContainerType::MPEG, .codec = core::media::CodecType::MP3 },
|
||||||
|
audio::TranscodeOutputFormat{ .container = core::media::ContainerType::Ogg, .codec = core::media::CodecType::Vorbis },
|
||||||
|
audio::TranscodeOutputFormat{ .container = core::media::ContainerType::Ogg, .codec = core::media::CodecType::Opus },
|
||||||
|
audio::TranscodeOutputFormat{ .container = core::media::ContainerType::FLAC, .codec = core::media::CodecType::FLAC },
|
||||||
|
};
|
||||||
|
|
||||||
|
bool isMatchingContainerName(core::media::ContainerType container, std::string_view containerStr)
|
||||||
|
{
|
||||||
|
using namespace std::literals; // for "..."sv
|
||||||
|
|
||||||
|
constexpr std::array aiffNames{ "aif"sv, "aiff"sv };
|
||||||
|
constexpr std::array apeNames{ "ape"sv };
|
||||||
|
constexpr std::array asfNames{ "asf"sv, "wma"sv };
|
||||||
|
constexpr std::array dsfNames{ "dsf"sv };
|
||||||
|
constexpr std::array mpcNames{ "mpc"sv, "mpp"sv, "mp"sv };
|
||||||
|
constexpr std::array mpegNames{ "mp3"sv, "mp2"sv, "mpeg"sv };
|
||||||
|
constexpr std::array oggNames{ "ogg"sv, "oga"sv };
|
||||||
|
constexpr std::array flacNames{ "flac"sv };
|
||||||
|
constexpr std::array mp4Names{ "aac"sv, "adts"sv, "m4a"sv, "mp4"sv, "m4b"sv, "m4p"sv };
|
||||||
|
constexpr std::array shortenNames{ "shn"sv };
|
||||||
|
constexpr std::array trueAudioNames{ "tta"sv };
|
||||||
|
constexpr std::array wavNames{ "wav"sv };
|
||||||
|
constexpr std::array wavPackNames{ "wv"sv };
|
||||||
|
|
||||||
|
std::span<const std::string_view> containerNames;
|
||||||
|
|
||||||
|
switch (container)
|
||||||
|
{
|
||||||
|
case core::media::ContainerType::AIFF:
|
||||||
|
containerNames = aiffNames;
|
||||||
|
break;
|
||||||
|
case core::media::ContainerType::APE:
|
||||||
|
containerNames = apeNames;
|
||||||
|
break;
|
||||||
|
case core::media::ContainerType::ASF:
|
||||||
|
containerNames = asfNames;
|
||||||
|
break;
|
||||||
|
case core::media::ContainerType::DSF:
|
||||||
|
containerNames = dsfNames;
|
||||||
|
break;
|
||||||
|
case core::media::ContainerType::MPC:
|
||||||
|
containerNames = mpcNames;
|
||||||
|
break;
|
||||||
|
case core::media::ContainerType::MPEG:
|
||||||
|
containerNames = mpegNames;
|
||||||
|
break;
|
||||||
|
case core::media::ContainerType::Ogg:
|
||||||
|
containerNames = oggNames;
|
||||||
|
break;
|
||||||
|
case core::media::ContainerType::FLAC:
|
||||||
|
containerNames = flacNames;
|
||||||
|
break;
|
||||||
|
case core::media::ContainerType::MP4:
|
||||||
|
containerNames = mp4Names;
|
||||||
|
break;
|
||||||
|
case core::media::ContainerType::Shorten:
|
||||||
|
containerNames = shortenNames;
|
||||||
|
break;
|
||||||
|
case core::media::ContainerType::TrueAudio:
|
||||||
|
containerNames = trueAudioNames;
|
||||||
|
break;
|
||||||
|
case core::media::ContainerType::WAV:
|
||||||
|
containerNames = wavNames;
|
||||||
|
break;
|
||||||
|
case core::media::ContainerType::WavPack:
|
||||||
|
containerNames = wavPackNames;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return std::any_of(std::cbegin(containerNames), std::cend(containerNames), [&](std::string_view containerName) { return core::stringUtils::stringCaseInsensitiveEqual(containerName, containerStr); });
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isMatchingCodecName(core::media::CodecType codec, std::string_view codecStr)
|
||||||
|
{
|
||||||
|
using namespace std::literals; // for "..."sv
|
||||||
|
|
||||||
|
constexpr std::array aacCodecNames{ "aac"sv, "adts"sv };
|
||||||
|
constexpr std::array ac3CodecNames{ "ac3"sv, "ac-3"sv };
|
||||||
|
constexpr std::array alacCodecNames{ "alac"sv };
|
||||||
|
constexpr std::array apeCodecNames{ "ape"sv };
|
||||||
|
constexpr std::array dsdCodecNames{ "dsd"sv };
|
||||||
|
constexpr std::array flacCodecNames{ "flac"sv };
|
||||||
|
constexpr std::array eac3CodecNames{ "eac3"sv, "e-ac3"sv, "e-ac-3"sv, "eac-3"sv };
|
||||||
|
constexpr std::array mp3CodecNames{ "mp3"sv };
|
||||||
|
constexpr std::array mp4alsCodecNames{ "mp4als"sv, "als"sv };
|
||||||
|
constexpr std::array mpc7CodecNames{ "mpc7"sv, "musepack7"sv };
|
||||||
|
constexpr std::array mpc8CodecNames{ "mpc8"sv, "musepack8"sv };
|
||||||
|
constexpr std::array opusCodecNames{ "opus"sv };
|
||||||
|
constexpr std::array pcmCodecNames{ "pcm"sv };
|
||||||
|
constexpr std::array shortenCodecNames{ "shn"sv, "shorten"sv };
|
||||||
|
constexpr std::array trueAudioCodecNames{ "tta"sv };
|
||||||
|
constexpr std::array vorbisCodecNames{ "vorbis"sv };
|
||||||
|
constexpr std::array wavPackCodecNames{ "wv"sv };
|
||||||
|
constexpr std::array wma1CodecNames{ "wma1"sv, "wmav1"sv };
|
||||||
|
constexpr std::array wma2CodecNames{ "wma2"sv, "wmav2"sv };
|
||||||
|
constexpr std::array wma9LosslessCodecNames{ "wmalossless"sv, "wma9lossless"sv };
|
||||||
|
constexpr std::array wma9ProCodecNames{ "wmapro"sv, "wma9pro"sv };
|
||||||
|
|
||||||
|
std::span<const std::string_view> codecNames;
|
||||||
|
switch (codec)
|
||||||
|
{
|
||||||
|
case core::media::CodecType::AAC:
|
||||||
|
codecNames = aacCodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::AC3:
|
||||||
|
codecNames = ac3CodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::ALAC:
|
||||||
|
codecNames = alacCodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::APE:
|
||||||
|
codecNames = apeCodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::DSD:
|
||||||
|
codecNames = dsdCodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::EAC3:
|
||||||
|
codecNames = eac3CodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::FLAC:
|
||||||
|
codecNames = flacCodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::MP3:
|
||||||
|
codecNames = mp3CodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::MP4ALS:
|
||||||
|
codecNames = mp4alsCodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::MPC7:
|
||||||
|
codecNames = mpc7CodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::MPC8:
|
||||||
|
codecNames = mpc8CodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::Opus:
|
||||||
|
codecNames = opusCodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::PCM:
|
||||||
|
codecNames = pcmCodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::Shorten:
|
||||||
|
codecNames = shortenCodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::TrueAudio:
|
||||||
|
codecNames = trueAudioCodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::Vorbis:
|
||||||
|
codecNames = vorbisCodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::WavPack:
|
||||||
|
codecNames = wavPackCodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::WMA1:
|
||||||
|
codecNames = wma1CodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::WMA2:
|
||||||
|
codecNames = wma2CodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::WMA9Lossless:
|
||||||
|
codecNames = wma9LosslessCodecNames;
|
||||||
|
break;
|
||||||
|
case core::media::CodecType::WMA9Pro:
|
||||||
|
codecNames = wma9ProCodecNames;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return std::any_of(std::cbegin(codecNames), std::cend(codecNames), [&](std::string_view codecName) { return core::stringUtils::stringCaseInsensitiveEqual(codecName, codecStr); });
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AdjustResult
|
||||||
|
{
|
||||||
|
enum class Type
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Adjusted,
|
||||||
|
CannotAdjust,
|
||||||
|
};
|
||||||
|
Type type{ Type::None };
|
||||||
|
std::optional<unsigned> newValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
AdjustResult adjustUsingEqualsLimitation(std::span<const std::string> values, unsigned originalValue)
|
||||||
|
{
|
||||||
|
if (values.size() == 1)
|
||||||
|
{
|
||||||
|
const auto value{ core::stringUtils::readAs<unsigned>(values.front()) };
|
||||||
|
assert(value);
|
||||||
|
if (originalValue == *value)
|
||||||
|
return AdjustResult{ .type = AdjustResult::Type::None, .newValue = std::nullopt };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Get the closest allowed value *below* originalValue (we don't want to upscale)
|
||||||
|
// Not sure if this worth doing this?
|
||||||
|
|
||||||
|
std::optional<unsigned> closestValue;
|
||||||
|
for (std::string_view valueStr : values)
|
||||||
|
{
|
||||||
|
const auto value{ core::stringUtils::readAs<unsigned>(valueStr) };
|
||||||
|
assert(value);
|
||||||
|
if (*value == originalValue)
|
||||||
|
return AdjustResult{ .type = AdjustResult::Type::None, .newValue = std::nullopt };
|
||||||
|
if (*value < originalValue && (!closestValue || *value > *closestValue))
|
||||||
|
closestValue = *value;
|
||||||
|
}
|
||||||
|
if (closestValue)
|
||||||
|
return AdjustResult{ .type = AdjustResult::Type::Adjusted, .newValue = *closestValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't really know what to do here
|
||||||
|
return AdjustResult{ .type = AdjustResult::Type::CannotAdjust, .newValue = std::nullopt };
|
||||||
|
}
|
||||||
|
|
||||||
|
AdjustResult adjustUsingNotEqualsLimitation(std::span<const std::string> values, unsigned originalValue)
|
||||||
|
{
|
||||||
|
if (std::none_of(std::cbegin(values), std::cend(values), [&](std::string_view valueStr) {
|
||||||
|
const auto value{ core::stringUtils::readAs<unsigned>(valueStr) };
|
||||||
|
assert(value);
|
||||||
|
return *value == originalValue;
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
return AdjustResult{ .type = AdjustResult::Type::None, .newValue = std::nullopt };
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't really know what to do here
|
||||||
|
return AdjustResult{ .type = AdjustResult::Type::CannotAdjust, .newValue = std::nullopt };
|
||||||
|
}
|
||||||
|
|
||||||
|
AdjustResult adjustUsingLessThanEqualLimitation(std::span<const std::string> values, unsigned originalValue)
|
||||||
|
{
|
||||||
|
// Take only the first value into account
|
||||||
|
const auto value{ core::stringUtils::readAs<unsigned>(values.front()) };
|
||||||
|
assert(value);
|
||||||
|
if (originalValue <= *value)
|
||||||
|
return AdjustResult{ .type = AdjustResult::Type::None, .newValue = std::nullopt };
|
||||||
|
|
||||||
|
return AdjustResult{ .type = AdjustResult::Type::Adjusted, .newValue = *value };
|
||||||
|
}
|
||||||
|
|
||||||
|
AdjustResult adjustUsingGreaterThanEqualLimitation(std::span<const std::string> values, unsigned originalValue)
|
||||||
|
{
|
||||||
|
// Take only the first value into account
|
||||||
|
const auto value{ core::stringUtils::readAs<unsigned>(values.front()) };
|
||||||
|
assert(value);
|
||||||
|
|
||||||
|
if (originalValue >= *value)
|
||||||
|
return AdjustResult{ .type = AdjustResult::Type::None, .newValue = std::nullopt };
|
||||||
|
|
||||||
|
// We don't want to use a higher value than the original one (we don't want to upscale)
|
||||||
|
return AdjustResult{ .type = AdjustResult::Type::CannotAdjust, .newValue = *value };
|
||||||
|
}
|
||||||
|
|
||||||
|
AdjustResult adjustUsingLimitation(Limitation::ComparisonOperator comparisonOp, std::span<const std::string> values, unsigned originalValue)
|
||||||
|
{
|
||||||
|
assert(values.size() >= 1);
|
||||||
|
|
||||||
|
switch (comparisonOp)
|
||||||
|
{
|
||||||
|
case Limitation::ComparisonOperator::Equals:
|
||||||
|
return adjustUsingEqualsLimitation(values, originalValue);
|
||||||
|
|
||||||
|
case Limitation::ComparisonOperator::NotEquals:
|
||||||
|
return adjustUsingNotEqualsLimitation(values, originalValue);
|
||||||
|
|
||||||
|
case Limitation::ComparisonOperator::LessThanEqual:
|
||||||
|
return adjustUsingLessThanEqualLimitation(values, originalValue);
|
||||||
|
|
||||||
|
case Limitation::ComparisonOperator::GreaterThanEqual:
|
||||||
|
return adjustUsingGreaterThanEqualLimitation(values, originalValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw InternalErrorGenericError{ "Unhandled limitation comparison operator" };
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isStreamCompatibleWithLimitation(const audio::AudioProperties& source, const Limitation& limitation)
|
||||||
|
{
|
||||||
|
if (!limitation.required)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// TODO handle strings
|
||||||
|
std::optional<unsigned> valueToCheck{};
|
||||||
|
switch (limitation.name)
|
||||||
|
{
|
||||||
|
case Limitation::Type::AudioBitrate:
|
||||||
|
valueToCheck = static_cast<unsigned>(source.bitrate);
|
||||||
|
break;
|
||||||
|
case Limitation::Type::AudioChannels:
|
||||||
|
valueToCheck = static_cast<unsigned>(source.channelCount);
|
||||||
|
break;
|
||||||
|
break;
|
||||||
|
case Limitation::Type::AudioSamplerate:
|
||||||
|
valueToCheck = static_cast<unsigned>(source.sampleRate);
|
||||||
|
break;
|
||||||
|
case Limitation::Type::AudioProfile:
|
||||||
|
// TODO;
|
||||||
|
break;
|
||||||
|
case Limitation::Type::AudioBitdepth:
|
||||||
|
if (source.bitsPerSample)
|
||||||
|
valueToCheck = static_cast<unsigned>(*source.bitsPerSample);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valueToCheck)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const AdjustResult adjustResult{ adjustUsingLimitation(limitation.comparison, limitation.values, *valueToCheck) };
|
||||||
|
return adjustResult.type == AdjustResult::Type::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodecProfile* getAudioCodecProfile(std::span<const CodecProfile> codecProfiles, core::media::CodecType codec)
|
||||||
|
{
|
||||||
|
for (const CodecProfile& profile : codecProfiles)
|
||||||
|
{
|
||||||
|
if (profile.type != "AudioCodec")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (isMatchingCodecName(codec, profile.name))
|
||||||
|
return &profile;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<TranscodeReason> needsTranscode(const DirectPlayProfile& profile, std::span<const CodecProfile> codecProfiles, const audio::AudioProperties& source)
|
||||||
|
{
|
||||||
|
if (!profile.containers.empty() && std::none_of(std::cbegin(profile.containers), std::cend(profile.containers), [&](const std::string& container) { return isMatchingContainerName(source.container, container); }))
|
||||||
|
return TranscodeReason::ContainerNotSupported;
|
||||||
|
|
||||||
|
if (!profile.audioCodecs.empty() && std::none_of(std::cbegin(profile.audioCodecs), std::cend(profile.audioCodecs), [&](const std::string& audioCodec) { return isMatchingCodecName(source.codec, audioCodec); }))
|
||||||
|
return TranscodeReason::AudioCodecNotSupported;
|
||||||
|
|
||||||
|
if (!profile.protocols.empty() && std::find(std::cbegin(profile.protocols), std::cend(profile.protocols), "http") == std::cend(profile.protocols))
|
||||||
|
return TranscodeReason::ProtocolNotSupported;
|
||||||
|
|
||||||
|
if (profile.maxAudioChannels && source.channelCount > *profile.maxAudioChannels)
|
||||||
|
return TranscodeReason::AudioChannelsNotSupported;
|
||||||
|
|
||||||
|
// check potential codec profiles limitations
|
||||||
|
if (const CodecProfile * codecProfile{ getAudioCodecProfile(codecProfiles, source.codec) })
|
||||||
|
{
|
||||||
|
for (const Limitation& limitation : codecProfile->limitations)
|
||||||
|
{
|
||||||
|
if (!isStreamCompatibleWithLimitation(source, limitation))
|
||||||
|
{
|
||||||
|
switch (limitation.name)
|
||||||
|
{
|
||||||
|
case Limitation::Type::AudioBitrate:
|
||||||
|
return TranscodeReason::AudioBitrateNotSupported;
|
||||||
|
case Limitation::Type::AudioChannels:
|
||||||
|
return TranscodeReason::AudioChannelsNotSupported;
|
||||||
|
case Limitation::Type::AudioSamplerate:
|
||||||
|
return TranscodeReason::AudioSampleRateNotSupported;
|
||||||
|
case Limitation::Type::AudioProfile:
|
||||||
|
return TranscodeReason::AudioCodecNotSupported;
|
||||||
|
case Limitation::Type::AudioBitdepth:
|
||||||
|
return TranscodeReason::AudioBitdepthNotSupported;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdjustResult applyLimitation(const audio::AudioProperties& source, const Limitation& limitation, StreamDetails& transcodedStream)
|
||||||
|
{
|
||||||
|
switch (limitation.name)
|
||||||
|
{
|
||||||
|
case Limitation::Type::AudioChannels:
|
||||||
|
{
|
||||||
|
// transcodedStream.audioChannels may already be set by the transcoding profile maxAudioChannels
|
||||||
|
const AdjustResult adjustResult{ adjustUsingLimitation(limitation.comparison, limitation.values, transcodedStream.audioChannels ? *transcodedStream.audioChannels : source.channelCount) };
|
||||||
|
if (adjustResult.type == AdjustResult::Type::Adjusted)
|
||||||
|
transcodedStream.audioChannels = *adjustResult.newValue;
|
||||||
|
return adjustResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
case Limitation::Type::AudioBitrate:
|
||||||
|
{
|
||||||
|
const AdjustResult adjustResult{ adjustUsingLimitation(limitation.comparison, limitation.values, transcodedStream.audioBitrate ? *transcodedStream.audioBitrate : source.bitrate) };
|
||||||
|
if (adjustResult.type == AdjustResult::Type::Adjusted)
|
||||||
|
transcodedStream.audioBitrate = *adjustResult.newValue;
|
||||||
|
return adjustResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
case Limitation::Type::AudioProfile:
|
||||||
|
{
|
||||||
|
// TODO
|
||||||
|
AdjustResult res{ .type = AdjustResult::Type::None, .newValue = std::nullopt };
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
case Limitation::Type::AudioSamplerate:
|
||||||
|
{
|
||||||
|
const AdjustResult adjustResult{ adjustUsingLimitation(limitation.comparison, limitation.values, source.sampleRate) };
|
||||||
|
if (adjustResult.type == AdjustResult::Type::Adjusted)
|
||||||
|
transcodedStream.audioSamplerate = *adjustResult.newValue;
|
||||||
|
return adjustResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
case Limitation::Type::AudioBitdepth:
|
||||||
|
{
|
||||||
|
if (source.bitsPerSample)
|
||||||
|
{
|
||||||
|
const AdjustResult adjustResult{ adjustUsingLimitation(limitation.comparison, limitation.values, *source.bitsPerSample) };
|
||||||
|
if (adjustResult.type == AdjustResult::Type::Adjusted)
|
||||||
|
transcodedStream.audioBitdepth = *adjustResult.newValue;
|
||||||
|
|
||||||
|
return adjustResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AdjustResult{ .type = AdjustResult::Type::CannotAdjust, .newValue = std::nullopt };
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<StreamDetails> computeTranscodedStream(std::optional<std::size_t> maxAudioBitrate, const TranscodingProfile& profile, std::span<const CodecProfile> codecProfiles, const audio::AudioProperties& source)
|
||||||
|
{
|
||||||
|
if (profile.protocol != "http")
|
||||||
|
return std::nullopt;
|
||||||
|
|
||||||
|
const audio::TranscodeOutputFormat* transcodeFormat{ selectTranscodeOutputFormat(profile.container, profile.audioCodec) };
|
||||||
|
if (!transcodeFormat)
|
||||||
|
return std::nullopt;
|
||||||
|
|
||||||
|
StreamDetails transcodedStream;
|
||||||
|
transcodedStream.protocol = "http";
|
||||||
|
transcodedStream.container = profile.container; // put back what was requested instead of our internal names
|
||||||
|
transcodedStream.codec = profile.audioCodec; // put back what was requested instead of our internal names
|
||||||
|
|
||||||
|
if (core::media::isCodecLossless(source.codec))
|
||||||
|
{
|
||||||
|
if (!core::media::isCodecLossless(transcodeFormat->codec))
|
||||||
|
{
|
||||||
|
// If coming from lossless source, maximize the bitrate if going to a non lossless source
|
||||||
|
// otherwise, pick a good enough value as we don't want to keep the original bitrate which does not make sense for lossy codecs
|
||||||
|
if (maxAudioBitrate)
|
||||||
|
transcodedStream.audioBitrate = maxAudioBitrate;
|
||||||
|
else
|
||||||
|
transcodedStream.audioBitrate = 256'000; // TODO, only if no bitrate limitation found? take channel count into account?
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// If going to a lossless codec, make sure we can respect the original bitrate
|
||||||
|
// technically, we could have a chance to respect the bitrate if we apply limitations, but that's not easy to have a strong garantee
|
||||||
|
if (maxAudioBitrate && source.bitrate > *maxAudioBitrate)
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// source is lossy
|
||||||
|
|
||||||
|
if (core::media::isCodecLossless(transcodeFormat->codec))
|
||||||
|
return std::nullopt; // not compatible with lossless codecs
|
||||||
|
|
||||||
|
// let's pick the same bitrate as the lossy source
|
||||||
|
transcodedStream.audioBitrate = source.bitrate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxAudioBitrate && source.bitrate > *maxAudioBitrate)
|
||||||
|
transcodedStream.audioBitrate = *maxAudioBitrate;
|
||||||
|
|
||||||
|
if (profile.maxAudioChannels && source.channelCount > *profile.maxAudioChannels)
|
||||||
|
transcodedStream.audioChannels = *profile.maxAudioChannels;
|
||||||
|
|
||||||
|
if (const CodecProfile * codecProfile{ getAudioCodecProfile(codecProfiles, transcodeFormat->codec) })
|
||||||
|
{
|
||||||
|
for (const Limitation& limitation : codecProfile->limitations)
|
||||||
|
{
|
||||||
|
const AdjustResult result{ applyLimitation(source, limitation, transcodedStream) };
|
||||||
|
if (limitation.name == Limitation::Type::AudioBitrate && core::media::isCodecLossless(transcodeFormat->codec) && result.type == AdjustResult::Type::Adjusted)
|
||||||
|
return std::nullopt; // not compatible with lossless codecs
|
||||||
|
|
||||||
|
if (result.type == AdjustResult::Type::CannotAdjust)
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return transcodedStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canDirectPlay(const ClientInfo& clientInfo, const audio::AudioProperties& source, std::vector<TranscodeReason>& transcodeReasons)
|
||||||
|
{
|
||||||
|
// Check global constraints
|
||||||
|
if (clientInfo.maxAudioBitrate && *clientInfo.maxAudioBitrate < source.bitrate)
|
||||||
|
{
|
||||||
|
transcodeReasons.push_back(TranscodeReason::AudioBitrateNotSupported);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check direct play profiles
|
||||||
|
for (const DirectPlayProfile& profile : clientInfo.directPlayProfiles)
|
||||||
|
{
|
||||||
|
const std::optional<TranscodeReason> transcodeReason{ needsTranscode(profile, clientInfo.codecProfiles, source) };
|
||||||
|
if (!transcodeReason)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
transcodeReasons.push_back(*transcodeReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
core::LiteralString transcodeReasonToString(TranscodeReason reason)
|
||||||
|
{
|
||||||
|
switch (reason)
|
||||||
|
{
|
||||||
|
case TranscodeReason::AudioCodecNotSupported:
|
||||||
|
return "audio codec not supported";
|
||||||
|
case TranscodeReason::AudioBitrateNotSupported:
|
||||||
|
return "audio bitrate not supported";
|
||||||
|
case TranscodeReason::AudioChannelsNotSupported:
|
||||||
|
return "audio channels not supported";
|
||||||
|
case TranscodeReason::AudioSampleRateNotSupported:
|
||||||
|
return "audio samplerate not supported";
|
||||||
|
case TranscodeReason::AudioBitdepthNotSupported:
|
||||||
|
return "audio bitdepth not supported";
|
||||||
|
case TranscodeReason::ContainerNotSupported:
|
||||||
|
return "container not supported";
|
||||||
|
case TranscodeReason::ProtocolNotSupported:
|
||||||
|
return "protocol not supported";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
const audio::TranscodeOutputFormat* selectTranscodeOutputFormat(std::string_view containerName, std::string_view codecName)
|
||||||
|
{
|
||||||
|
// Find a supported output format
|
||||||
|
const auto it{ std::find_if(std::cbegin(supportedTranscodeOutputFormats), std::cend(supportedTranscodeOutputFormats), [&](const audio::TranscodeOutputFormat& format) {
|
||||||
|
return isMatchingCodecName(format.codec, codecName) && isMatchingContainerName(format.container, containerName);
|
||||||
|
}) };
|
||||||
|
if (it == std::cend(supportedTranscodeOutputFormats))
|
||||||
|
return nullptr;
|
||||||
|
|
||||||
|
return &(*it);
|
||||||
|
}
|
||||||
|
|
||||||
|
TranscodeDecisionResult computeTranscodeDecision(const ClientInfo& clientInfo, const audio::AudioProperties& source)
|
||||||
|
{
|
||||||
|
std::vector<TranscodeReason> transcodeReasons;
|
||||||
|
|
||||||
|
if (canDirectPlay(clientInfo, source, transcodeReasons))
|
||||||
|
return DirectPlayResult{};
|
||||||
|
|
||||||
|
LMS_LOG(API_SUBSONIC, DEBUG, "Direct play not possible: no compatible direct play profile found");
|
||||||
|
|
||||||
|
// Check transcoding profiles, we have to select the first one we can handle, order is important
|
||||||
|
for (const TranscodingProfile& profile : clientInfo.transcodingProfiles)
|
||||||
|
{
|
||||||
|
std::optional<StreamDetails> targetStream{ computeTranscodedStream(clientInfo.maxTranscodingAudioBitrate, profile, clientInfo.codecProfiles, source) };
|
||||||
|
if (targetStream)
|
||||||
|
return TranscodeResult{ .reasons = transcodeReasons, .targetStreamInfo = *targetStream };
|
||||||
|
}
|
||||||
|
|
||||||
|
return FailureResult{ "No compatible direct play or transcoding profile found" };
|
||||||
|
}
|
||||||
|
} // namespace lms::api::subsonic::details
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 <string_view>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/LiteralString.hpp"
|
||||||
|
|
||||||
|
#include "audio/AudioProperties.hpp"
|
||||||
|
#include "audio/TranscodeTypes.hpp"
|
||||||
|
|
||||||
|
#include "payloads/StreamDetails.hpp"
|
||||||
|
|
||||||
|
namespace lms::api::subsonic
|
||||||
|
{
|
||||||
|
struct ClientInfo;
|
||||||
|
|
||||||
|
namespace details
|
||||||
|
{
|
||||||
|
enum class TranscodeReason
|
||||||
|
{
|
||||||
|
AudioCodecNotSupported,
|
||||||
|
AudioBitrateNotSupported,
|
||||||
|
AudioChannelsNotSupported,
|
||||||
|
AudioSampleRateNotSupported,
|
||||||
|
AudioBitdepthNotSupported,
|
||||||
|
ContainerNotSupported,
|
||||||
|
ProtocolNotSupported
|
||||||
|
};
|
||||||
|
|
||||||
|
core::LiteralString transcodeReasonToString(TranscodeReason reason);
|
||||||
|
|
||||||
|
struct DirectPlayResult
|
||||||
|
{
|
||||||
|
bool operator==(const DirectPlayResult&) const = default;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TranscodeResult
|
||||||
|
{
|
||||||
|
std::vector<TranscodeReason> reasons;
|
||||||
|
StreamDetails targetStreamInfo;
|
||||||
|
|
||||||
|
bool operator==(const TranscodeResult&) const = default;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FailureResult
|
||||||
|
{
|
||||||
|
std::string reason;
|
||||||
|
|
||||||
|
bool operator==(const FailureResult&) const = default;
|
||||||
|
};
|
||||||
|
|
||||||
|
using TranscodeDecisionResult = std::variant<DirectPlayResult, TranscodeResult, FailureResult>;
|
||||||
|
TranscodeDecisionResult computeTranscodeDecision(const ClientInfo& clientInfo, const audio::AudioProperties& source);
|
||||||
|
|
||||||
|
const audio::TranscodeOutputFormat* selectTranscodeOutputFormat(std::string_view containerName, std::string_view codecName);
|
||||||
|
} // namespace details
|
||||||
|
} // namespace lms::api::subsonic
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 "TranscodeDecisionTracker.hpp"
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
#include "core/Random.hpp"
|
||||||
|
|
||||||
|
namespace lms::api::subsonic
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
class TranscodeDecisionTracker : public ITranscodeDecisionTracker
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
core::UUID add(AudioFileId audioFileId, const StreamDetails& targetStreamInfo) override
|
||||||
|
{
|
||||||
|
const core::UUID uuid{ core::UUID::generate() };
|
||||||
|
const Clock::time_point now{ Clock::now() };
|
||||||
|
|
||||||
|
auto entry{ std::make_shared<Entry>(now, audioFileId, targetStreamInfo) };
|
||||||
|
|
||||||
|
{
|
||||||
|
std::scoped_lock lock{ mutex };
|
||||||
|
|
||||||
|
purgeOutdatedEntries(now);
|
||||||
|
|
||||||
|
entries.emplace(uuid, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<Entry> get(const core::UUID& uuid) override
|
||||||
|
{
|
||||||
|
const Clock::time_point now{ Clock::now() };
|
||||||
|
std::shared_ptr<Entry> res;
|
||||||
|
|
||||||
|
std::scoped_lock lock{ mutex };
|
||||||
|
|
||||||
|
auto it{ entries.find(uuid) };
|
||||||
|
if (it != entries.end())
|
||||||
|
{
|
||||||
|
if (now > it->second->addedTimePoint + maxEntryDuration)
|
||||||
|
entries.erase(it);
|
||||||
|
else
|
||||||
|
res = it->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void purgeOutdatedEntries(Clock::time_point now)
|
||||||
|
{
|
||||||
|
std::erase_if(entries, [&](const auto& entry) { return now > entry.second->addedTimePoint + maxEntryDuration; });
|
||||||
|
while (entries.size() > maxEntryCount)
|
||||||
|
entries.erase(core::random::pickRandom(entries)); // TODO kill oldest one?
|
||||||
|
}
|
||||||
|
|
||||||
|
std::mutex mutex;
|
||||||
|
std::unordered_map<core::UUID, std::shared_ptr<Entry>> entries;
|
||||||
|
|
||||||
|
static constexpr std::size_t maxEntryCount{ 1'000 };
|
||||||
|
static constexpr std::chrono::hours maxEntryDuration{ 12 };
|
||||||
|
};
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ITranscodeDecisionTracker& getTranscodeDecisionTracker()
|
||||||
|
{
|
||||||
|
static TranscodeDecisionTracker tracker;
|
||||||
|
return tracker;
|
||||||
|
}
|
||||||
|
} // namespace lms::api::subsonic
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 <chrono>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "core/UUID.hpp"
|
||||||
|
|
||||||
|
#include "payloads/StreamDetails.hpp"
|
||||||
|
|
||||||
|
#include "AudioFileId.hpp"
|
||||||
|
|
||||||
|
namespace lms::api::subsonic
|
||||||
|
{
|
||||||
|
// Keeps track of decisions using UUIDs
|
||||||
|
class ITranscodeDecisionTracker
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
virtual ~ITranscodeDecisionTracker() = default;
|
||||||
|
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
|
||||||
|
struct Entry
|
||||||
|
{
|
||||||
|
Clock::time_point addedTimePoint;
|
||||||
|
AudioFileId audioFileId;
|
||||||
|
StreamDetails targetStreamInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
virtual core::UUID add(AudioFileId audioFileId, const StreamDetails& targetStreamInfo) = 0;
|
||||||
|
virtual std::shared_ptr<Entry> get(const core::UUID& uuid) = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
ITranscodeDecisionTracker& getTranscodeDecisionTracker();
|
||||||
|
} // namespace lms::api::subsonic
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 "ClientInfo.hpp"
|
||||||
|
|
||||||
|
#include <type_traits>
|
||||||
|
|
||||||
|
#include <Wt/Json/Array.h>
|
||||||
|
#include <Wt/Json/Object.h>
|
||||||
|
#include <Wt/Json/Parser.h>
|
||||||
|
|
||||||
|
#include "core/String.hpp"
|
||||||
|
|
||||||
|
#include "SubsonicResponse.hpp"
|
||||||
|
|
||||||
|
namespace lms::api::subsonic
|
||||||
|
{
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
template<typename T>
|
||||||
|
std::optional<T> parseValue(const Wt::Json::Object& object, const std::string& entry)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
std::optional<T> res;
|
||||||
|
|
||||||
|
const Wt::Json::Value& value{ object.get(entry) };
|
||||||
|
if (value.isNull())
|
||||||
|
return res;
|
||||||
|
|
||||||
|
if constexpr (std::is_same_v<T, bool>)
|
||||||
|
{
|
||||||
|
if (value.type() != Wt::Json::Type::Bool)
|
||||||
|
throw BadParameterGenericError{ entry, "field must be a boolean" };
|
||||||
|
}
|
||||||
|
else if constexpr (std::is_same_v<T, std::string>)
|
||||||
|
{
|
||||||
|
if (value.type() != Wt::Json::Type::String)
|
||||||
|
throw BadParameterGenericError{ entry, "field must be a string" };
|
||||||
|
}
|
||||||
|
else if constexpr (std::is_integral_v<T>)
|
||||||
|
{
|
||||||
|
if (value.type() != Wt::Json::Type::Number)
|
||||||
|
throw BadParameterGenericError{ entry, "field must be a number" };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
static_assert(false, "Unhandled type");
|
||||||
|
}
|
||||||
|
|
||||||
|
return static_cast<T>(value);
|
||||||
|
}
|
||||||
|
catch (const Wt::WException& e)
|
||||||
|
{
|
||||||
|
throw BadParameterGenericError{ entry, "failed to read value" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename T>
|
||||||
|
std::vector<T> parseMandatoryArrayValues(const Wt::Json::Object& object, const std::string& entry)
|
||||||
|
{
|
||||||
|
std::vector<T> res;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const Wt::Json::Value& value{ object.get(entry) };
|
||||||
|
if (value.isNull())
|
||||||
|
throw BadParameterGenericError{ entry, "missing field" };
|
||||||
|
|
||||||
|
if (value.type() != Wt::Json::Type::Array)
|
||||||
|
throw BadParameterGenericError{ entry, "field must be an array" };
|
||||||
|
|
||||||
|
for (const Wt::Json::Value& item : static_cast<const Wt::Json::Array&>(value))
|
||||||
|
{
|
||||||
|
if constexpr (std::is_same_v<T, bool>)
|
||||||
|
{
|
||||||
|
if (item.type() != Wt::Json::Type::Bool)
|
||||||
|
throw BadParameterGenericError{ entry, "array item must be a boolean" };
|
||||||
|
}
|
||||||
|
else if constexpr (std::is_same_v<T, std::string>)
|
||||||
|
{
|
||||||
|
if (item.type() != Wt::Json::Type::String)
|
||||||
|
throw BadParameterGenericError{ entry, "array item must be a string" };
|
||||||
|
}
|
||||||
|
else if constexpr (std::is_integral_v<T>)
|
||||||
|
{
|
||||||
|
if (item.type() != Wt::Json::Type::Number)
|
||||||
|
throw BadParameterGenericError{ entry, "array item must be a number" };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
static_assert(false, "Unhandled type");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.emplace_back(static_cast<T>(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (const Wt::WException& e)
|
||||||
|
{
|
||||||
|
throw BadParameterGenericError{ entry, "failed to read value" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename T>
|
||||||
|
T parseMandatoryValue(const Wt::Json::Object& object, const std::string& entry)
|
||||||
|
{
|
||||||
|
std::optional<T> res{ parseValue<T>(object, entry) };
|
||||||
|
if (!res)
|
||||||
|
throw BadParameterGenericError{ entry, "field is mandatory" };
|
||||||
|
|
||||||
|
return *res;
|
||||||
|
}
|
||||||
|
|
||||||
|
Limitation::Type parseLimitationType(std::string_view str)
|
||||||
|
{
|
||||||
|
if (str == "audioChannels")
|
||||||
|
return Limitation::Type::AudioChannels;
|
||||||
|
if (str == "audioBitrate")
|
||||||
|
return Limitation::Type::AudioBitrate;
|
||||||
|
if (str == "audioProfile")
|
||||||
|
return Limitation::Type::AudioProfile;
|
||||||
|
if (str == "audioSamplerate")
|
||||||
|
return Limitation::Type::AudioSamplerate;
|
||||||
|
if (str == "audioBitdepth")
|
||||||
|
return Limitation::Type::AudioBitdepth;
|
||||||
|
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::CodecProfile::Limitation::name", "unexpected value '" + std::string{ str } + "'" };
|
||||||
|
}
|
||||||
|
|
||||||
|
Limitation::ComparisonOperator parseComparisonOperator(std::string_view str)
|
||||||
|
{
|
||||||
|
// Equals, NotEquals, LessThanEqual, GreaterThanEqual
|
||||||
|
if (str == "Equals")
|
||||||
|
return Limitation::ComparisonOperator::Equals;
|
||||||
|
if (str == "NotEquals")
|
||||||
|
return Limitation::ComparisonOperator::NotEquals;
|
||||||
|
if (str == "LessThanEqual")
|
||||||
|
return Limitation::ComparisonOperator::LessThanEqual;
|
||||||
|
if (str == "GreaterThanEqual")
|
||||||
|
return Limitation::ComparisonOperator::GreaterThanEqual;
|
||||||
|
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::CodecProfile::Limitation::comparison", "unexpected value '" + std::string{ str } + "'" };
|
||||||
|
}
|
||||||
|
|
||||||
|
void checkLimitationValidity(const Limitation& limitation)
|
||||||
|
{
|
||||||
|
if (limitation.values.empty())
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::CodecProfile::Limitation::values", "must have at least one value" };
|
||||||
|
|
||||||
|
// only numeric values are allowed for some limitation types
|
||||||
|
switch (limitation.name)
|
||||||
|
{
|
||||||
|
case Limitation::Type::AudioChannels:
|
||||||
|
case Limitation::Type::AudioBitrate:
|
||||||
|
case Limitation::Type::AudioSamplerate:
|
||||||
|
case Limitation::Type::AudioBitdepth:
|
||||||
|
{
|
||||||
|
// must be a numeric value
|
||||||
|
std::string_view value{ limitation.values.front() };
|
||||||
|
if (!core::stringUtils::readAs<unsigned>(value))
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::CodecProfile::Limitation::values", "'" + std::string{ value } + "' is not a valid number" };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Limitation::Type::AudioProfile:
|
||||||
|
// any value is allowed
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ClientInfo parseClientInfoFromJson(std::istream& is)
|
||||||
|
{
|
||||||
|
ClientInfo res;
|
||||||
|
|
||||||
|
const std::string msgBody{ std::istreambuf_iterator<char>{ is }, std::istreambuf_iterator<char>{} };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Wt::Json::Object root;
|
||||||
|
Wt::Json::parse(msgBody, root);
|
||||||
|
|
||||||
|
res.name = parseMandatoryValue<std::string>(root, "name");
|
||||||
|
res.platform = parseMandatoryValue<std::string>(root, "platform");
|
||||||
|
{
|
||||||
|
const auto maxAudioBitrate = parseValue<long>(root, "maxAudioBitrate");
|
||||||
|
if (maxAudioBitrate && *maxAudioBitrate > 0)
|
||||||
|
res.maxAudioBitrate = *maxAudioBitrate;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto maxTranscodingAudioBitrate = parseValue<long>(root, "maxTranscodingAudioBitrate");
|
||||||
|
if (maxTranscodingAudioBitrate && *maxTranscodingAudioBitrate > 0)
|
||||||
|
res.maxTranscodingAudioBitrate = *maxTranscodingAudioBitrate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (const Wt::Json::Value & directPlayProfiles{ root.get("directPlayProfiles") }; directPlayProfiles.type() != Wt::Json::Type::Null)
|
||||||
|
{
|
||||||
|
for (const Wt::Json::Object& profile : static_cast<const Wt::Json::Array&>(directPlayProfiles))
|
||||||
|
{
|
||||||
|
DirectPlayProfile directPlayProfile;
|
||||||
|
|
||||||
|
auto checkValues{ [](std::span<const std::string> values) {
|
||||||
|
return std::none_of(std::cbegin(values), std::cend(values), [](const std::string& value) { return value.empty() || value == "*"; });
|
||||||
|
} };
|
||||||
|
|
||||||
|
// containers is stored in an array of string
|
||||||
|
directPlayProfile.containers = parseMandatoryArrayValues<std::string>(profile, "containers");
|
||||||
|
if (!checkValues(directPlayProfile.containers))
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::DirectPlayProfile::containers", "Invalid value" };
|
||||||
|
|
||||||
|
directPlayProfile.audioCodecs = parseMandatoryArrayValues<std::string>(profile, "audioCodecs");
|
||||||
|
if (!checkValues(directPlayProfile.audioCodecs))
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::DirectPlayProfile::containers", "Invalid value" };
|
||||||
|
|
||||||
|
directPlayProfile.protocols = parseMandatoryArrayValues<std::string>(profile, "protocols");
|
||||||
|
if (!checkValues(directPlayProfile.protocols))
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::DirectPlayProfile::protocols", "Invalid value" };
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto maxAudioChannels = parseValue<long>(profile, "maxAudioChannels");
|
||||||
|
if (maxAudioChannels && *maxAudioChannels > 0)
|
||||||
|
directPlayProfile.maxAudioChannels = *maxAudioChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.directPlayProfiles.push_back(directPlayProfile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (const Wt::Json::Value & transcodingProfiles{ root.get("transcodingProfiles") }; transcodingProfiles.type() != Wt::Json::Type::Null)
|
||||||
|
{
|
||||||
|
for (const Wt::Json::Object& profile : static_cast<const Wt::Json::Array&>(transcodingProfiles))
|
||||||
|
{
|
||||||
|
TranscodingProfile transcodingProfile;
|
||||||
|
transcodingProfile.container = parseMandatoryValue<std::string>(profile, "container");
|
||||||
|
if (transcodingProfile.container.empty())
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::TranscodingProfile::container", "cannot be empty" };
|
||||||
|
|
||||||
|
if (transcodingProfile.container.find('*') != std::string::npos)
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::TranscodingProfile::container", "cannot have *" };
|
||||||
|
if (transcodingProfile.container.find(',') != std::string::npos)
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::TranscodingProfile::container", "cannot have ," };
|
||||||
|
|
||||||
|
transcodingProfile.audioCodec = parseMandatoryValue<std::string>(profile, "audioCodec");
|
||||||
|
if (transcodingProfile.audioCodec.empty())
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::TranscodingProfile::audioCodec", "cannot be empty" };
|
||||||
|
if (transcodingProfile.audioCodec.find('*') != std::string::npos)
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::TranscodingProfile::audioCodec", "cannot have *" };
|
||||||
|
if (transcodingProfile.audioCodec.find(',') != std::string::npos)
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::TranscodingProfile::audioCodec", "cannot have ," };
|
||||||
|
|
||||||
|
transcodingProfile.protocol = parseMandatoryValue<std::string>(profile, "protocol");
|
||||||
|
if (transcodingProfile.protocol.empty())
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::TranscodingProfile::protocol", "cannot be empty" };
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto maxAudioChannels{ parseValue<long>(profile, "maxAudioChannels") };
|
||||||
|
if (maxAudioChannels && *maxAudioChannels > 0)
|
||||||
|
transcodingProfile.maxAudioChannels = *maxAudioChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.transcodingProfiles.push_back(transcodingProfile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// codecProfiles
|
||||||
|
if (const Wt::Json::Value & codecProfiles{ root.get("codecProfiles") }; codecProfiles.type() != Wt::Json::Type::Null)
|
||||||
|
{
|
||||||
|
for (const Wt::Json::Object& profile : static_cast<const Wt::Json::Array&>(codecProfiles))
|
||||||
|
{
|
||||||
|
CodecProfile codecProfile;
|
||||||
|
codecProfile.type = parseMandatoryValue<std::string>(profile, "type");
|
||||||
|
if (codecProfile.type != "AudioCodec" && codecProfile.type != "VideoCodec")
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::CodecProfile::type", "unexpected value" };
|
||||||
|
|
||||||
|
codecProfile.name = parseMandatoryValue<std::string>(profile, "name");
|
||||||
|
if (codecProfile.name.empty())
|
||||||
|
throw BadParameterGenericError{ "ClientInfo::CodecProfile::name", "name must not be empty" };
|
||||||
|
|
||||||
|
if (const Wt::Json::Value & limitations{ profile.get("limitations") }; limitations.type() != Wt::Json::Type::Null)
|
||||||
|
{
|
||||||
|
for (const Wt::Json::Object& limitation : static_cast<const Wt::Json::Array&>(limitations))
|
||||||
|
{
|
||||||
|
Limitation lim;
|
||||||
|
lim.name = parseLimitationType(parseMandatoryValue<std::string>(limitation, "name"));
|
||||||
|
lim.comparison = parseComparisonOperator(parseMandatoryValue<std::string>(limitation, "comparison"));
|
||||||
|
lim.values = parseMandatoryArrayValues<std::string>(limitation, "values");
|
||||||
|
lim.required = parseMandatoryValue<bool>(limitation, "required");
|
||||||
|
|
||||||
|
checkLimitationValidity(lim);
|
||||||
|
codecProfile.limitations.push_back(lim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.codecProfiles.push_back(codecProfile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (const Wt::WException& error)
|
||||||
|
{
|
||||||
|
throw BadParameterGenericError{ "ClientInfo", error.what() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
} // namespace lms::api::subsonic
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 <iosfwd>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace lms::api::subsonic
|
||||||
|
{
|
||||||
|
struct DirectPlayProfile
|
||||||
|
{
|
||||||
|
std::vector<std::string> containers; // Supported containers (e.g., mp3, flac).
|
||||||
|
std::vector<std::string> audioCodecs; // Supported audio codecs.
|
||||||
|
std::vector<std::string> protocols; // The streaming protocols. Can be http or hls.
|
||||||
|
std::optional<std::size_t> maxAudioChannels; // The maximum number of audio channels supported.
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TranscodingProfile
|
||||||
|
{
|
||||||
|
std::string container; // The container format (e.g., mp3, flac).
|
||||||
|
std::string audioCodec; // The target audio codec for transcoding.
|
||||||
|
std::string protocol; // The streaming protocol. Can be http or hls.
|
||||||
|
std::optional<std::size_t> maxAudioChannels; // The maximum number of audio channels for the transcoded stream.
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Limitation
|
||||||
|
{
|
||||||
|
enum class Type
|
||||||
|
{
|
||||||
|
AudioChannels,
|
||||||
|
AudioBitrate,
|
||||||
|
AudioProfile,
|
||||||
|
AudioSamplerate,
|
||||||
|
AudioBitdepth,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class ComparisonOperator : unsigned char
|
||||||
|
{
|
||||||
|
Equals,
|
||||||
|
NotEquals,
|
||||||
|
LessThanEqual,
|
||||||
|
GreaterThanEqual,
|
||||||
|
};
|
||||||
|
|
||||||
|
Type name; // The name of the limitation. Can be audioChannels, audioBitrate, audioProfile, audioSamplerate, or audioBitdepth.
|
||||||
|
ComparisonOperator comparison; // The comparison operator. Can be Equals, NotEquals, LessThanEqual, GreaterThanEqual
|
||||||
|
std::vector<std::string> values; // The values to compare against. For LessThanEqual and GreaterThanEqual, only the first value will be used.
|
||||||
|
bool required; // Whether this limitation must be met.
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CodecProfile
|
||||||
|
{
|
||||||
|
std::string type; // The type of codec profile. Currently only AudioCodec is supported
|
||||||
|
std::string name; // The name of the codec (e.g., mp3, flac).
|
||||||
|
std::vector<Limitation> limitations; // A list of limitations for this codec.
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ClientInfo
|
||||||
|
{
|
||||||
|
std::string name; // The name of the client device
|
||||||
|
std::string platform; // The platform of the client (e.g., Android, iOS).
|
||||||
|
std::optional<unsigned> maxAudioBitrate; // The maximum audio bitrate the client can handle.
|
||||||
|
std::optional<unsigned> maxTranscodingAudioBitrate; // The maximum audio bitrate for transcoded content.
|
||||||
|
std::vector<DirectPlayProfile> directPlayProfiles; // A list of profiles for direct playback.
|
||||||
|
std::vector<TranscodingProfile> transcodingProfiles; // A list of profiles for transcoding. The server should evaluate these in the order they are listed, as a priority list.
|
||||||
|
std::vector<CodecProfile> codecProfiles; // A list of codec-specific profiles.
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] ClientInfo parseClientInfoFromJson(std::istream& is);
|
||||||
|
} // namespace lms::api::subsonic
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 "StreamDetails.hpp"
|
||||||
|
|
||||||
|
namespace lms::api::subsonic
|
||||||
|
{
|
||||||
|
Response::Node createStreamDetails(const StreamDetails& streamDetails)
|
||||||
|
{
|
||||||
|
Response::Node streamDetailsNode{};
|
||||||
|
|
||||||
|
streamDetailsNode.setAttribute("protocol", "http");
|
||||||
|
streamDetailsNode.setAttribute("container", streamDetails.container);
|
||||||
|
streamDetailsNode.setAttribute("codec", streamDetails.codec);
|
||||||
|
if (streamDetails.audioChannels)
|
||||||
|
streamDetailsNode.setAttribute("audioChannels", *streamDetails.audioChannels);
|
||||||
|
if (streamDetails.audioBitrate)
|
||||||
|
streamDetailsNode.setAttribute("audioBitrate", *streamDetails.audioBitrate);
|
||||||
|
if (!streamDetails.audioProfile.empty())
|
||||||
|
streamDetailsNode.setAttribute("audioProfile", streamDetails.audioProfile);
|
||||||
|
if (streamDetails.audioSamplerate)
|
||||||
|
streamDetailsNode.setAttribute("audioSamplerate", *streamDetails.audioSamplerate);
|
||||||
|
if (streamDetails.audioBitdepth)
|
||||||
|
streamDetailsNode.setAttribute("audioBitdepth", *streamDetails.audioBitdepth);
|
||||||
|
|
||||||
|
return streamDetailsNode;
|
||||||
|
}
|
||||||
|
} // namespace lms::api::subsonic
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "SubsonicResponse.hpp"
|
||||||
|
|
||||||
|
namespace lms::api::subsonic
|
||||||
|
{
|
||||||
|
struct StreamDetails
|
||||||
|
{
|
||||||
|
std::string protocol; // The streaming protocol. Can be http or hls.
|
||||||
|
std::string container; // The container format (e.g., mp3, flac).
|
||||||
|
std::string codec; // The audio codec (e.g., mp3, aac, flac).
|
||||||
|
std::optional<std::size_t> audioChannels; // The number of audio channels.
|
||||||
|
std::optional<std::size_t> audioBitrate; // The audio bitrate in kbps.
|
||||||
|
std::string audioProfile; // The audio profile (e.g., LC, HE-AAC).
|
||||||
|
std::optional<std::size_t> audioSamplerate; // The audio sample rate in Hz.
|
||||||
|
std::optional<std::size_t> audioBitdepth; // The audio bit depth in bits.
|
||||||
|
|
||||||
|
auto operator<=>(const StreamDetails&) const = default;
|
||||||
|
};
|
||||||
|
|
||||||
|
Response::Node createStreamDetails(const StreamDetails& streamDetails);
|
||||||
|
} // namespace lms::api::subsonic
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
include(GoogleTest)
|
include(GoogleTest)
|
||||||
|
|
||||||
add_executable(test-subsonic
|
add_executable(test-subsonic
|
||||||
SubsonicResponseTest.cpp
|
ClientInfo.cpp
|
||||||
|
Subsonic.cpp
|
||||||
|
SubsonicResponse.cpp
|
||||||
|
TranscodeDecision.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(test-subsonic PRIVATE
|
target_include_directories(test-subsonic PRIVATE
|
||||||
@@ -10,6 +13,7 @@ target_include_directories(test-subsonic PRIVATE
|
|||||||
|
|
||||||
target_link_libraries(test-subsonic PRIVATE
|
target_link_libraries(test-subsonic PRIVATE
|
||||||
lmscore
|
lmscore
|
||||||
|
lmsaudio
|
||||||
lmssubsonic
|
lmssubsonic
|
||||||
GTest::GTest
|
GTest::GTest
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 <Wt/WException.h>
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include "SubsonicResponse.hpp"
|
||||||
|
#include "payloads/ClientInfo.hpp"
|
||||||
|
|
||||||
|
namespace lms::api::subsonic
|
||||||
|
{
|
||||||
|
TEST(ClientInfo, basic)
|
||||||
|
{
|
||||||
|
// Example as in https://opensubsonic.netlify.app/docs/payloads/clientinfo/
|
||||||
|
std::istringstream iss{ R"({
|
||||||
|
"name": "Play:1",
|
||||||
|
"platform": "Sonos",
|
||||||
|
"maxAudioBitrate": 512000,
|
||||||
|
"maxTranscodingAudioBitrate": 256000,
|
||||||
|
"directPlayProfiles": [
|
||||||
|
{
|
||||||
|
"containers": [ "mp3" ],
|
||||||
|
"audioCodecs": [ "mp3" ],
|
||||||
|
"protocols": [ "http" ],
|
||||||
|
"maxAudioChannels": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"containers": [ "flac" ],
|
||||||
|
"audioCodecs": [ "flac" ],
|
||||||
|
"protocols": [],
|
||||||
|
"maxAudioChannels": 2
|
||||||
|
}
|
||||||
|
,
|
||||||
|
{
|
||||||
|
"containers": [ "mp4" ],
|
||||||
|
"audioCodecs": [ "flac", "aac", "alac" ],
|
||||||
|
"protocols": [],
|
||||||
|
"maxAudioChannels": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transcodingProfiles": [
|
||||||
|
{
|
||||||
|
"container": "mp3",
|
||||||
|
"audioCodec": "mp3",
|
||||||
|
"protocol": "http",
|
||||||
|
"maxAudioChannels": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"container": "flac",
|
||||||
|
"audioCodec": "flac",
|
||||||
|
"protocol": "http",
|
||||||
|
"maxAudioChannels": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"codecProfiles": [
|
||||||
|
{
|
||||||
|
"type": "AudioCodec",
|
||||||
|
"name": "mp3",
|
||||||
|
"limitations": [
|
||||||
|
{ "name": "audioBitrate", "comparison": "LessThanEqual", "values": [ "320000" ], "required": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "AudioCodec",
|
||||||
|
"name": "flac",
|
||||||
|
"limitations": [
|
||||||
|
{ "name": "audioSamplerate", "comparison": "LessThanEqual", "values": [ "192000" ], "required": false },
|
||||||
|
{ "name": "audioChannels", "comparison": "Equals", "values": ["1", "2" ], "required": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)" };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const ClientInfo clientInfo{ parseClientInfoFromJson(iss) };
|
||||||
|
|
||||||
|
EXPECT_EQ(clientInfo.name, "Play:1");
|
||||||
|
EXPECT_EQ(clientInfo.platform, "Sonos");
|
||||||
|
EXPECT_TRUE(clientInfo.maxAudioBitrate.has_value());
|
||||||
|
EXPECT_EQ(clientInfo.maxAudioBitrate.value(), 512000);
|
||||||
|
EXPECT_TRUE(clientInfo.maxTranscodingAudioBitrate.has_value());
|
||||||
|
EXPECT_EQ(clientInfo.maxTranscodingAudioBitrate.value(), 256000);
|
||||||
|
|
||||||
|
ASSERT_EQ(clientInfo.directPlayProfiles.size(), 3);
|
||||||
|
ASSERT_EQ(clientInfo.directPlayProfiles[0].containers.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[0], "mp3");
|
||||||
|
ASSERT_EQ(clientInfo.directPlayProfiles[0].audioCodecs.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].audioCodecs[0], "mp3");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].protocols, std::vector<std::string>{ "http" });
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].maxAudioChannels, 2);
|
||||||
|
ASSERT_EQ(clientInfo.directPlayProfiles[1].containers.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[1].containers[0], "flac");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[1].audioCodecs.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[1].audioCodecs[0], "flac");
|
||||||
|
EXPECT_TRUE(clientInfo.directPlayProfiles[1].protocols.empty());
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[1].maxAudioChannels, 2);
|
||||||
|
ASSERT_EQ(clientInfo.directPlayProfiles[2].containers.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[2].containers[0], "mp4");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[2].audioCodecs.size(), 3);
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[2].audioCodecs[0], "flac");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[2].audioCodecs[1], "aac");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[2].audioCodecs[2], "alac");
|
||||||
|
EXPECT_TRUE(clientInfo.directPlayProfiles[2].protocols.empty());
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[2].maxAudioChannels, 2);
|
||||||
|
ASSERT_EQ(clientInfo.transcodingProfiles.size(), 2);
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[0].container, "mp3");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[0].audioCodec, "mp3");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[0].protocol, "http");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[0].maxAudioChannels, 2);
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[1].container, "flac");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[1].audioCodec, "flac");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[1].protocol, "http");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[1].maxAudioChannels, 2);
|
||||||
|
ASSERT_EQ(clientInfo.codecProfiles.size(), 2);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].type, "AudioCodec");
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].name, "mp3");
|
||||||
|
ASSERT_EQ(clientInfo.codecProfiles[0].limitations.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].limitations[0].name, Limitation::Type::AudioBitrate);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].limitations[0].comparison, Limitation::ComparisonOperator::LessThanEqual);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].limitations[0].values.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].limitations[0].values[0], "320000");
|
||||||
|
EXPECT_TRUE(clientInfo.codecProfiles[0].limitations[0].required);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].type, "AudioCodec");
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].name, "flac");
|
||||||
|
ASSERT_EQ(clientInfo.codecProfiles[1].limitations.size(), 2);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[0].name, Limitation::Type::AudioSamplerate);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[0].comparison, Limitation::ComparisonOperator::LessThanEqual);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[0].values.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[0].values[0], "192000");
|
||||||
|
EXPECT_FALSE(clientInfo.codecProfiles[1].limitations[0].required);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[1].name, Limitation::Type::AudioChannels);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[1].comparison, Limitation::ComparisonOperator::Equals);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[1].values.size(), 2);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[1].values[0], "1");
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[1].values[1], "2");
|
||||||
|
EXPECT_FALSE(clientInfo.codecProfiles[1].limitations[1].required);
|
||||||
|
}
|
||||||
|
catch (const Error& e)
|
||||||
|
{
|
||||||
|
GTEST_FAIL() << e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ClientInfo, multi)
|
||||||
|
{
|
||||||
|
std::istringstream iss{ R"({"name":"LocalDevice","platform":"Android","maxAudioBitrate":320000,"maxTranscodingAudioBitrate":320000,"directPlayProfiles":[{"containers":["mp4","mka","m4a","mp3","mp2","wav","flac","ogg","alac","opus","vorbis"],"audioCodecs":[],"protocols":[],"maxAudioChannels":32}],"transcodingProfiles":[{"container":"flac","audioCodec":"flac","protocol":"http","maxAudioChannels":0},{"container":"ogg","audioCodec":"opus","protocol":"http","maxAudioChannels":6},{"container":"mp3","audioCodec":"mp3","protocol":"http","maxAudioChannels":2}],"codecProfiles":[{"type":"AudioCodec","name":"vorbis","limitations":[{"name":"audioSamplerate","comparison":"LessThanEqual","values":["48000"],"required":true}]},{"type":"AudioCodec","name":"opus","limitations":[{"name":"audioSamplerate","comparison":"LessThanEqual","values":["48000"],"required":true}]}]})" };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const ClientInfo clientInfo{ parseClientInfoFromJson(iss) };
|
||||||
|
|
||||||
|
EXPECT_EQ(clientInfo.name, "LocalDevice");
|
||||||
|
EXPECT_EQ(clientInfo.platform, "Android");
|
||||||
|
EXPECT_EQ(clientInfo.maxAudioBitrate, 320000);
|
||||||
|
EXPECT_EQ(clientInfo.maxTranscodingAudioBitrate, 320000);
|
||||||
|
ASSERT_EQ(clientInfo.directPlayProfiles.size(), 1);
|
||||||
|
ASSERT_EQ(clientInfo.directPlayProfiles[0].containers.size(), 11);
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[0], "mp4");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[1], "mka");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[2], "m4a");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[3], "mp3");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[4], "mp2");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[5], "wav");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[6], "flac");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[7], "ogg");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[8], "alac");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[9], "opus");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[10], "vorbis");
|
||||||
|
ASSERT_EQ(clientInfo.directPlayProfiles[0].audioCodecs.size(), 0);
|
||||||
|
EXPECT_TRUE(clientInfo.directPlayProfiles[0].protocols.empty());
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].maxAudioChannels, 32);
|
||||||
|
ASSERT_EQ(clientInfo.transcodingProfiles.size(), 3);
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[0].container, "flac");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[0].audioCodec, "flac");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[0].protocol, "http");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[0].maxAudioChannels, std::nullopt);
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[1].container, "ogg");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[1].audioCodec, "opus");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[1].protocol, "http");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[1].maxAudioChannels, 6);
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[2].container, "mp3");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[2].audioCodec, "mp3");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[2].protocol, "http");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[2].maxAudioChannels, 2);
|
||||||
|
ASSERT_EQ(clientInfo.codecProfiles.size(), 2);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].type, "AudioCodec");
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].name, "vorbis");
|
||||||
|
ASSERT_EQ(clientInfo.codecProfiles[0].limitations.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].limitations[0].name, Limitation::Type::AudioSamplerate);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].limitations[0].comparison, Limitation::ComparisonOperator::LessThanEqual);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].limitations[0].values.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].limitations[0].values[0], "48000");
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].limitations[0].required, true);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].type, "AudioCodec");
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].name, "opus");
|
||||||
|
ASSERT_EQ(clientInfo.codecProfiles[1].limitations.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[0].name, Limitation::Type::AudioSamplerate);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[0].comparison, Limitation::ComparisonOperator::LessThanEqual);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[0].values.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[0].values[0], "48000");
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[0].required, true);
|
||||||
|
}
|
||||||
|
catch (const Error& e)
|
||||||
|
{
|
||||||
|
GTEST_FAIL() << e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ClientInfo, multi2)
|
||||||
|
{
|
||||||
|
std::istringstream iss{ R"({"name":"Upnp/192.168.1.1/Foo","platform":"UPnP","maxAudioBitrate":0,"maxTranscodingAudioBitrate":0,"directPlayProfiles":[{"containers":["opus","ogg","oga","aac","webma","webm","wav","flac","mka"],"audioCodecs":[],"protocols":[],"maxAudioChannels":0},{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":[],"maxAudioChannels":0},{"containers":["m4a","mp4"],"audioCodecs":["aac"],"protocols":[],"maxAudioChannels":0}],"transcodingProfiles":[{"container":"flac","audioCodec":"flac","protocol":"http","maxAudioChannels":6},{"container":"mp4","audioCodec":"aac","protocol":"http","maxAudioChannels":6},{"container":"aac","audioCodec":"aac","protocol":"http","maxAudioChannels":6},{"container":"mp3","audioCodec":"mp3","protocol":"http","maxAudioChannels":2}],"codecProfiles":[{"type":"AudioCodec","name":"flac","limitations":[{"name":"audioSamplerate","comparison":"LessThanEqual","values":["48000"],"required":true}]},{"type":"AudioCodec","name":"vorbis","limitations":[{"name":"audioSamplerate","comparison":"LessThanEqual","values":["48000"],"required":true}]},{"type":"AudioCodec","name":"opus","limitations":[{"name":"audioSamplerate","comparison":"LessThanEqual","values":["48000"],"required":true}]}]})" };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const ClientInfo clientInfo{ parseClientInfoFromJson(iss) };
|
||||||
|
|
||||||
|
EXPECT_EQ(clientInfo.name, "Upnp/192.168.1.1/Foo");
|
||||||
|
EXPECT_EQ(clientInfo.platform, "UPnP");
|
||||||
|
EXPECT_EQ(clientInfo.maxAudioBitrate, std::nullopt);
|
||||||
|
EXPECT_EQ(clientInfo.maxTranscodingAudioBitrate, std::nullopt);
|
||||||
|
ASSERT_EQ(clientInfo.directPlayProfiles.size(), 3);
|
||||||
|
ASSERT_EQ(clientInfo.directPlayProfiles[0].containers.size(), 9);
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[0], "opus");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[1], "ogg");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[2], "oga");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[3], "aac");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[4], "webma");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[5], "webm");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[6], "wav");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[7], "flac");
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].containers[8], "mka");
|
||||||
|
ASSERT_EQ(clientInfo.directPlayProfiles[0].audioCodecs.size(), 0);
|
||||||
|
EXPECT_TRUE(clientInfo.directPlayProfiles[0].protocols.empty());
|
||||||
|
EXPECT_EQ(clientInfo.directPlayProfiles[0].maxAudioChannels, std::nullopt);
|
||||||
|
ASSERT_EQ(clientInfo.transcodingProfiles.size(), 4);
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[0].container, "flac");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[0].audioCodec, "flac");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[0].protocol, "http");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[0].maxAudioChannels, 6);
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[1].container, "mp4");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[1].audioCodec, "aac");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[1].protocol, "http");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[1].maxAudioChannels, 6);
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[2].container, "aac");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[2].audioCodec, "aac");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[2].protocol, "http");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[2].maxAudioChannels, 6);
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[3].container, "mp3");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[3].audioCodec, "mp3");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[3].protocol, "http");
|
||||||
|
EXPECT_EQ(clientInfo.transcodingProfiles[3].maxAudioChannels, 2);
|
||||||
|
ASSERT_EQ(clientInfo.codecProfiles.size(), 3);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].type, "AudioCodec");
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].name, "flac");
|
||||||
|
ASSERT_EQ(clientInfo.codecProfiles[0].limitations.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].limitations[0].name, Limitation::Type::AudioSamplerate);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].limitations[0].comparison, Limitation::ComparisonOperator::LessThanEqual);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].limitations[0].values.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].limitations[0].values[0], "48000");
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[0].limitations[0].required, true);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].type, "AudioCodec");
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].name, "vorbis");
|
||||||
|
ASSERT_EQ(clientInfo.codecProfiles[1].limitations.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[0].name, Limitation::Type::AudioSamplerate);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[0].comparison, Limitation::ComparisonOperator::LessThanEqual);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[0].values.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[0].values[0], "48000");
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[1].limitations[0].required, true);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[2].type, "AudioCodec");
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[2].name, "opus");
|
||||||
|
ASSERT_EQ(clientInfo.codecProfiles[2].limitations.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[2].limitations[0].name, Limitation::Type::AudioSamplerate);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[2].limitations[0].comparison, Limitation::ComparisonOperator::LessThanEqual);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[2].limitations[0].values.size(), 1);
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[2].limitations[0].values[0], "48000");
|
||||||
|
EXPECT_EQ(clientInfo.codecProfiles[2].limitations[0].required, true);
|
||||||
|
}
|
||||||
|
catch (const Error& e)
|
||||||
|
{
|
||||||
|
GTEST_FAIL() << e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ClientInfo, badfield)
|
||||||
|
{
|
||||||
|
std::istringstream iss{ R"({"name":"LocalDevice","platform":"Android","maxAudioBitrate":"320000","maxTranscodingAudioBitrate":320000,"directPlayProfiles":[{"container":"mp4,mka,m4a,mp3,mp2,wav,flac,ogg,alac,opus,vorbis","audioCodec":"*","protocol":"*","maxAudioChannels":32}],"transcodingProfiles":[{"container":"flac","audioCodec":"flac","protocol":"http","maxAudioChannels":0},{"container":"ogg","audioCodec":"opus","protocol":"http","maxAudioChannels":6},{"container":"mp3","audioCodec":"mp3","protocol":"http","maxAudioChannels":2}],"codecProfiles":[{"type":"AudioCodec","name":"vorbis","limitations":[{"name":"audioSamplerate","comparison":"LessThanEqual","value":"48000","required":true}]},{"type":"AudioCodec","name":"opus","limitations":[{"name":"audioSamplerate","comparison":"LessThanEqual","value":"48000","required":true}]}]})" };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const ClientInfo clientInfo{ parseClientInfoFromJson(iss) };
|
||||||
|
GTEST_FAIL() << "Expected error";
|
||||||
|
}
|
||||||
|
catch (const BadParameterGenericError& e)
|
||||||
|
{
|
||||||
|
EXPECT_EQ(e.getParameterName(), "maxAudioBitrate");
|
||||||
|
}
|
||||||
|
catch (const Error& e)
|
||||||
|
{
|
||||||
|
GTEST_FAIL() << e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lms::api::subsonic
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include "core/ILogger.hpp"
|
||||||
|
#include "core/Service.hpp"
|
||||||
|
|
||||||
|
int main(int argc, char** argv)
|
||||||
|
{
|
||||||
|
using namespace lms;
|
||||||
|
core::Service<core::logging::ILogger> logger{ core::logging::createLogger(core::logging::Severity::ERROR) };
|
||||||
|
|
||||||
|
::testing::InitGoogleTest(&argc, argv);
|
||||||
|
|
||||||
|
return RUN_ALL_TESTS();
|
||||||
|
}
|
||||||
-6
@@ -114,9 +114,3 @@ namespace lms::api::subsonic::tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
} // namespace lms::api::subsonic::tests
|
} // namespace lms::api::subsonic::tests
|
||||||
|
|
||||||
int main(int argc, char** argv)
|
|
||||||
{
|
|
||||||
::testing::InitGoogleTest(&argc, argv);
|
|
||||||
return RUN_ALL_TESTS();
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2025 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 <variant>
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include "core/Utils.hpp"
|
||||||
|
#include "core/media/CodecType.hpp"
|
||||||
|
#include "core/media/ContainerType.hpp"
|
||||||
|
|
||||||
|
#include "endpoints/transcoding/TranscodeDecision.hpp"
|
||||||
|
#include "payloads/ClientInfo.hpp"
|
||||||
|
|
||||||
|
namespace lms::api::subsonic
|
||||||
|
{
|
||||||
|
namespace details
|
||||||
|
{
|
||||||
|
std::ostream& operator<<(std::ostream& os, const details::TranscodeDecisionResult& result)
|
||||||
|
{
|
||||||
|
std::visit(core::utils::overloads{
|
||||||
|
[&](const details::DirectPlayResult&) { os << "direct play"; },
|
||||||
|
[&](const details::FailureResult& res) { os << "failure: " << res.reason; },
|
||||||
|
[&](const details::TranscodeResult& res) {
|
||||||
|
os << "transcode: reasons = {";
|
||||||
|
|
||||||
|
bool firstReason{ true };
|
||||||
|
for (TranscodeReason reason : res.reasons)
|
||||||
|
{
|
||||||
|
if (!firstReason)
|
||||||
|
os << ", ";
|
||||||
|
os << transcodeReasonToString(reason);
|
||||||
|
firstReason = false;
|
||||||
|
}
|
||||||
|
os << "}, target stream = {";
|
||||||
|
os << "protocol = " << res.targetStreamInfo.protocol << ", container = " << res.targetStreamInfo.container << ", codec = " << res.targetStreamInfo.codec;
|
||||||
|
if (res.targetStreamInfo.audioChannels)
|
||||||
|
os << ", audioChannels = " << *res.targetStreamInfo.audioChannels;
|
||||||
|
if (res.targetStreamInfo.audioBitrate)
|
||||||
|
os << ", audioBitrate = " << *res.targetStreamInfo.audioBitrate;
|
||||||
|
if (!res.targetStreamInfo.audioProfile.empty())
|
||||||
|
os << ", audioProfile = " << res.targetStreamInfo.audioProfile;
|
||||||
|
if (res.targetStreamInfo.audioSamplerate)
|
||||||
|
os << ", audioSamplerate = " << *res.targetStreamInfo.audioSamplerate;
|
||||||
|
if (res.targetStreamInfo.audioBitdepth)
|
||||||
|
os << ", audioBitdepth = " << *res.targetStreamInfo.audioBitdepth;
|
||||||
|
os << "}";
|
||||||
|
} },
|
||||||
|
result);
|
||||||
|
|
||||||
|
return os;
|
||||||
|
} // namespace
|
||||||
|
}; // namespace details
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
struct TestCase
|
||||||
|
{
|
||||||
|
ClientInfo clientInfo;
|
||||||
|
audio::AudioProperties source;
|
||||||
|
|
||||||
|
details::TranscodeDecisionResult expected;
|
||||||
|
};
|
||||||
|
|
||||||
|
void processTests(std::span<const TestCase> testCases)
|
||||||
|
{
|
||||||
|
for (std::size_t testCaseIndex{ 0 }; testCaseIndex < std::size(testCases); ++testCaseIndex)
|
||||||
|
{
|
||||||
|
const auto& testCase{ testCases[testCaseIndex] };
|
||||||
|
const details::TranscodeDecisionResult decision{ details::computeTranscodeDecision(testCase.clientInfo, testCase.source) };
|
||||||
|
|
||||||
|
EXPECT_EQ(testCase.expected, decision) << "testCaseIndex: " << testCaseIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(TranscodeDecision, directPlay)
|
||||||
|
{
|
||||||
|
const TestCase testCases[]{
|
||||||
|
// Direct play
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "TestClient",
|
||||||
|
.platform = "TestPlatform",
|
||||||
|
.maxAudioBitrate = 512'000,
|
||||||
|
.maxTranscodingAudioBitrate = 256'000,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = { "mp3" }, .audioCodecs = { "mp3" }, .protocols = { "http" }, .maxAudioChannels = 2 },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = {
|
||||||
|
{ .container = "mp3", .audioCodec = "mp3", .protocol = "http", .maxAudioChannels = 2 },
|
||||||
|
},
|
||||||
|
.codecProfiles = { { .type = "AudioCodec", .name = "mp3", .limitations = {
|
||||||
|
{ .name = Limitation::Type::AudioBitrate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "256000" }, .required = true },
|
||||||
|
} } },
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::MPEG,
|
||||||
|
.codec = core::media::CodecType::MP3,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 128'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 44'100,
|
||||||
|
.bitsPerSample = std::nullopt,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::DirectPlayResult{} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Needs transcode due to codec limitation
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "TestClient",
|
||||||
|
.platform = "TestPlatform",
|
||||||
|
.maxAudioBitrate = 512'000,
|
||||||
|
.maxTranscodingAudioBitrate = 96'000,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = { "mp3" }, .audioCodecs = { "mp3" }, .protocols = { "http" }, .maxAudioChannels = 2 },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = {
|
||||||
|
{ .container = "mp3", .audioCodec = "mp3", .protocol = "http", .maxAudioChannels = 2 },
|
||||||
|
},
|
||||||
|
.codecProfiles = { { .type = "AudioCodec", .name = "mp3", .limitations = {
|
||||||
|
{ .name = Limitation::Type::AudioBitrate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "96000" }, .required = true },
|
||||||
|
} } },
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::MPEG,
|
||||||
|
.codec = core::media::CodecType::MP3,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 128'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 44'100,
|
||||||
|
.bitsPerSample = std::nullopt,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::TranscodeResult{ .reasons = { details::TranscodeReason::AudioBitrateNotSupported }, .targetStreamInfo = { .protocol = "http", .container = "mp3", .codec = "mp3", .audioChannels = std::nullopt, .audioBitrate = 96000, .audioProfile = "", .audioSamplerate = std::nullopt, .audioBitdepth = std::nullopt } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Needs transcode due to global limitation on the direct play bitrate
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "TestClient",
|
||||||
|
.platform = "TestPlatform",
|
||||||
|
.maxAudioBitrate = 96'000,
|
||||||
|
.maxTranscodingAudioBitrate = 96'000,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = { "mp3" }, .audioCodecs = { "mp3" }, .protocols = { "http" }, .maxAudioChannels = 2 },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = {
|
||||||
|
{ .container = "mp3", .audioCodec = "mp3", .protocol = "http", .maxAudioChannels = 2 },
|
||||||
|
},
|
||||||
|
.codecProfiles = { { .type = "AudioCodec", .name = "mp3", .limitations = {
|
||||||
|
{ .name = Limitation::Type::AudioBitrate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "256000" }, .required = true },
|
||||||
|
} } },
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::MPEG,
|
||||||
|
.codec = core::media::CodecType::MP3,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 128'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 44'100,
|
||||||
|
.bitsPerSample = std::nullopt,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::TranscodeResult{ .reasons = { details::TranscodeReason::AudioBitrateNotSupported }, .targetStreamInfo = { .protocol = "http", .container = "mp3", .codec = "mp3", .audioChannels = std::nullopt, .audioBitrate = 96000, .audioProfile = "", .audioSamplerate = std::nullopt, .audioBitdepth = std::nullopt } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Needs transcode due to codec limitation, but global limitation is even more restrictive
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "TestClient",
|
||||||
|
.platform = "TestPlatform",
|
||||||
|
.maxAudioBitrate = 96'000,
|
||||||
|
.maxTranscodingAudioBitrate = 96'000,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = { "mp3" }, .audioCodecs = { "mp3" }, .protocols = { "http" }, .maxAudioChannels = 2 },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = {
|
||||||
|
{ .container = "mp3", .audioCodec = "mp3", .protocol = "http", .maxAudioChannels = 2 },
|
||||||
|
},
|
||||||
|
.codecProfiles = { { .type = "AudioCodec", .name = "mp3", .limitations = {
|
||||||
|
{ .name = Limitation::Type::AudioBitrate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "128000" }, .required = true },
|
||||||
|
} } },
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::MPEG,
|
||||||
|
.codec = core::media::CodecType::MP3,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 192'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 44'100,
|
||||||
|
.bitsPerSample = std::nullopt,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::TranscodeResult{ .reasons = { details::TranscodeReason::AudioBitrateNotSupported }, .targetStreamInfo = { .protocol = "http", .container = "mp3", .codec = "mp3", .audioChannels = std::nullopt, .audioBitrate = 96'000, .audioProfile = "", .audioSamplerate = std::nullopt, .audioBitdepth = std::nullopt } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Needs transcode due to max audio sample rate not handle by codec limitation
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "TestClient",
|
||||||
|
.platform = "TestPlatform",
|
||||||
|
.maxAudioBitrate = 320'000,
|
||||||
|
.maxTranscodingAudioBitrate = 320'000,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = { "mp3" }, .audioCodecs = { "mp3" }, .protocols = { "http" }, .maxAudioChannels = 2 },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = {
|
||||||
|
{ .container = "mp3", .audioCodec = "mp3", .protocol = "http", .maxAudioChannels = 2 },
|
||||||
|
},
|
||||||
|
.codecProfiles = { { .type = "AudioCodec", .name = "mp3", .limitations = {
|
||||||
|
{ .name = Limitation::Type::AudioSamplerate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "48000" }, .required = true },
|
||||||
|
} } },
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::MPEG,
|
||||||
|
.codec = core::media::CodecType::MP3,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 192'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 96'000,
|
||||||
|
.bitsPerSample = std::nullopt,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::TranscodeResult{ .reasons = { details::TranscodeReason::AudioSampleRateNotSupported }, .targetStreamInfo = { .protocol = "http", .container = "mp3", .codec = "mp3", .audioChannels = std::nullopt, .audioBitrate = 192'000, .audioProfile = "", .audioSamplerate = 48'000, .audioBitdepth = std::nullopt } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Needs transcode due to max nb channels not handle by profile
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "TestClient",
|
||||||
|
.platform = "TestPlatform",
|
||||||
|
.maxAudioBitrate = 320'000,
|
||||||
|
.maxTranscodingAudioBitrate = 320'000,
|
||||||
|
.directPlayProfiles = { { { .containers = { "mp3" }, .audioCodecs = { "mp3" }, .protocols = { "http" }, .maxAudioChannels = 2 } } },
|
||||||
|
.transcodingProfiles = { { .container = "mp3", .audioCodec = "mp3", .protocol = "http", .maxAudioChannels = 2 } },
|
||||||
|
.codecProfiles = { { .type = "AudioCodec", .name = "mp3", .limitations = {} } },
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::MPEG,
|
||||||
|
.codec = core::media::CodecType::MP3,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 192'000,
|
||||||
|
.channelCount = 5,
|
||||||
|
.sampleRate = 48'000,
|
||||||
|
.bitsPerSample = std::nullopt,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::TranscodeResult{ .reasons = { details::TranscodeReason::AudioChannelsNotSupported }, .targetStreamInfo = { .protocol = "http", .container = "mp3", .codec = "mp3", .audioChannels = 2, .audioBitrate = 192'000, .audioProfile = "", .audioSamplerate = std::nullopt, .audioBitdepth = std::nullopt } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Needs transcode due to max nb channels not handle by codec. TODO take channel reduction into account for bitrate
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "TestClient",
|
||||||
|
.platform = "TestPlatform",
|
||||||
|
.maxAudioBitrate = 320'000,
|
||||||
|
.maxTranscodingAudioBitrate = 320'000,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = { "mp3" }, .audioCodecs = { "mp3" }, .protocols = { "http" }, .maxAudioChannels = std::nullopt },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = {
|
||||||
|
{ .container = "mp3", .audioCodec = "mp3", .protocol = "http", .maxAudioChannels = std::nullopt },
|
||||||
|
},
|
||||||
|
.codecProfiles = { { .type = "AudioCodec", .name = "mp3", .limitations = { { .name = Limitation::Type::AudioChannels, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "2" }, .required = true } } } },
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::MPEG,
|
||||||
|
.codec = core::media::CodecType::MP3,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 192'000,
|
||||||
|
.channelCount = 5,
|
||||||
|
.sampleRate = 48'000,
|
||||||
|
.bitsPerSample = std::nullopt,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::TranscodeResult{ .reasons = { details::TranscodeReason::AudioChannelsNotSupported }, .targetStreamInfo = { .protocol = "http", .container = "mp3", .codec = "mp3", .audioChannels = 2, .audioBitrate = 192'000, .audioProfile = "", .audioSamplerate = std::nullopt, .audioBitdepth = std::nullopt } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// needs transcode because codec not handled
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "TestClient",
|
||||||
|
.platform = "TestPlatform",
|
||||||
|
.maxAudioBitrate = 320'000,
|
||||||
|
.maxTranscodingAudioBitrate = 320'000,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = { "mp3" }, .audioCodecs = { "mp3" }, .protocols = { "http" }, .maxAudioChannels = std::nullopt },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = {
|
||||||
|
{ .container = "mp3", .audioCodec = "mp3", .protocol = "http", .maxAudioChannels = std::nullopt },
|
||||||
|
},
|
||||||
|
.codecProfiles = { { .type = "AudioCodec", .name = "mp3", .limitations = { { .name = Limitation::Type::AudioChannels, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "2" }, .required = true } } } },
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::Ogg,
|
||||||
|
.codec = core::media::CodecType::Opus,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 128'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 48'000,
|
||||||
|
.bitsPerSample = std::nullopt,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::TranscodeResult{ .reasons = { details::TranscodeReason::ContainerNotSupported }, .targetStreamInfo = { .protocol = "http", .container = "mp3", .codec = "mp3", .audioChannels = std::nullopt, .audioBitrate = 128'000, .audioProfile = "", .audioSamplerate = std::nullopt, .audioBitdepth = std::nullopt } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// needs transcode because codec not handled (lossless source => using max bitrate)
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "TestClient",
|
||||||
|
.platform = "TestPlatform",
|
||||||
|
.maxAudioBitrate = 1'000'000,
|
||||||
|
.maxTranscodingAudioBitrate = 320'000,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = { "mp3" }, .audioCodecs = { "mp3" }, .protocols = { "http" }, .maxAudioChannels = std::nullopt },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = {
|
||||||
|
{ .container = "mp3", .audioCodec = "mp3", .protocol = "http", .maxAudioChannels = std::nullopt },
|
||||||
|
},
|
||||||
|
.codecProfiles = { { .type = "AudioCodec", .name = "mp3", .limitations = {} } },
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::FLAC,
|
||||||
|
.codec = core::media::CodecType::FLAC,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 750'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 48'000,
|
||||||
|
.bitsPerSample = 16,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::TranscodeResult{ .reasons = { details::TranscodeReason::ContainerNotSupported }, .targetStreamInfo = { .protocol = "http", .container = "mp3", .codec = "mp3", .audioChannels = std::nullopt, .audioBitrate = 320000, .audioProfile = "", .audioSamplerate = std::nullopt, .audioBitdepth = std::nullopt } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// needs transcode because codec not handled (lossless source => using a default good bitrate)
|
||||||
|
{
|
||||||
|
.clientInfo = { .name = "TestClient", .platform = "TestPlatform", .maxAudioBitrate = std::nullopt, .maxTranscodingAudioBitrate = std::nullopt, .directPlayProfiles = { {
|
||||||
|
{ .containers = { "mp3" }, .audioCodecs = { "mp3" }, .protocols = { "http" }, .maxAudioChannels = std::nullopt },
|
||||||
|
} },
|
||||||
|
|
||||||
|
.transcodingProfiles = {
|
||||||
|
{ .container = "mp3", .audioCodec = "mp3", .protocol = "http", .maxAudioChannels = std::nullopt },
|
||||||
|
},
|
||||||
|
.codecProfiles = {} },
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::FLAC,
|
||||||
|
.codec = core::media::CodecType::FLAC,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 750'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 48'000,
|
||||||
|
.bitsPerSample = 16,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::TranscodeResult{ .reasons = { details::TranscodeReason::ContainerNotSupported }, .targetStreamInfo = { .protocol = "http", .container = "mp3", .codec = "mp3", .audioChannels = std::nullopt, .audioBitrate = 256000, .audioProfile = "", .audioSamplerate = std::nullopt, .audioBitdepth = std::nullopt } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// check protocol * and codec * are properly handled
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "TestClient",
|
||||||
|
.platform = "TestPlatform",
|
||||||
|
.maxAudioBitrate = 1'000'000,
|
||||||
|
.maxTranscodingAudioBitrate = 320'000,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = { "mp4", "flac", "mp3" }, .audioCodecs = {}, .protocols = {}, .maxAudioChannels = std::nullopt },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = {},
|
||||||
|
.codecProfiles = {},
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::MPEG,
|
||||||
|
.codec = core::media::CodecType::MP3,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 128'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 48'000,
|
||||||
|
.bitsPerSample = std::nullopt,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::DirectPlayResult{} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// check container * is properly handled
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "TestClient",
|
||||||
|
.platform = "TestPlatform",
|
||||||
|
.maxAudioBitrate = 1'000'000,
|
||||||
|
.maxTranscodingAudioBitrate = 320'000,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = {}, .audioCodecs = { "mp3" }, .protocols = {}, .maxAudioChannels = std::nullopt },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = {},
|
||||||
|
.codecProfiles = {},
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::MPEG,
|
||||||
|
.codec = core::media::CodecType::MP3,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 128'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 48'000,
|
||||||
|
.bitsPerSample = std::nullopt,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::DirectPlayResult{} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// want flac but bitrate too high
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "LocalDevice",
|
||||||
|
.platform = "Android",
|
||||||
|
.maxAudioBitrate = 320'000,
|
||||||
|
.maxTranscodingAudioBitrate = 320'000,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = { "flac" }, .audioCodecs = { "flac" }, .protocols = {}, .maxAudioChannels = 32 },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = { { { .container = "ogg", .audioCodec = "opus", .protocol = "http", .maxAudioChannels = std::nullopt }, { .container = "mp3", .audioCodec = "mp3", .protocol = "http", .maxAudioChannels = 2 } } },
|
||||||
|
.codecProfiles = {},
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::FLAC,
|
||||||
|
.codec = core::media::CodecType::FLAC,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 1'000'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 48'000,
|
||||||
|
.bitsPerSample = 16,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::TranscodeResult{ .reasons = { details::TranscodeReason::AudioBitrateNotSupported }, .targetStreamInfo = { .protocol = "http", .container = "ogg", .codec = "opus", .audioChannels = std::nullopt, .audioBitrate = 320'000, .audioProfile = "", .audioSamplerate = std::nullopt, .audioBitdepth = std::nullopt } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// want flac but source sample rate is too high
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "SONOS",
|
||||||
|
.platform = "UPnP",
|
||||||
|
.maxAudioBitrate = 1'000'000,
|
||||||
|
.maxTranscodingAudioBitrate = 1'000'000,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = { "flac" }, .audioCodecs = {}, .protocols = {}, .maxAudioChannels = std::nullopt },
|
||||||
|
{ .containers = { "mp3" }, .audioCodecs = { "mp3" }, .protocols = {}, .maxAudioChannels = std::nullopt },
|
||||||
|
{ .containers = { "m4a", "mp4" }, .audioCodecs = { "aac" }, .protocols = {}, .maxAudioChannels = std::nullopt },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = { {
|
||||||
|
{ .container = "flac", .audioCodec = "flac", .protocol = "http", .maxAudioChannels = 6 },
|
||||||
|
{ .container = "aac", .audioCodec = "aac", .protocol = "http", .maxAudioChannels = 6 },
|
||||||
|
{ .container = "mp3", .audioCodec = "mp3", .protocol = "http", .maxAudioChannels = 2 },
|
||||||
|
} },
|
||||||
|
.codecProfiles = {
|
||||||
|
{
|
||||||
|
{ .type = "AudioCodec", .name = "flac", .limitations = { { .name = Limitation::Type::AudioSamplerate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "48000" }, .required = true } } },
|
||||||
|
{ .type = "AudioCodec", .name = "vorbis", .limitations = { { .name = Limitation::Type::AudioSamplerate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "48000" }, .required = true } } },
|
||||||
|
{ .type = "AudioCodec", .name = "opus", .limitations = { { .name = Limitation::Type::AudioSamplerate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "48000" }, .required = true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::FLAC,
|
||||||
|
.codec = core::media::CodecType::FLAC,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 950'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 96'000,
|
||||||
|
.bitsPerSample = 24,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::TranscodeResult{ .reasons = { details::TranscodeReason::AudioSampleRateNotSupported, details::TranscodeReason::ContainerNotSupported, details::TranscodeReason::ContainerNotSupported }, .targetStreamInfo = { .protocol = "http", .container = "flac", .codec = "flac", .audioChannels = std::nullopt, .audioBitrate = std::nullopt, .audioProfile = "", .audioSamplerate = 48'000, .audioBitdepth = std::nullopt } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// want flac but source sample rate is too high, no max bitrate
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "SONOS",
|
||||||
|
.platform = "UPnP",
|
||||||
|
.maxAudioBitrate = std::nullopt,
|
||||||
|
.maxTranscodingAudioBitrate = std::nullopt,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = { "opus", "ogg", "oga", "aac", "webma", "webm", "wav", "flac", "mka" }, .audioCodecs = {}, .protocols = {}, .maxAudioChannels = std::nullopt },
|
||||||
|
{ .containers = { "mp3" }, .audioCodecs = { "mp3" }, .protocols = {}, .maxAudioChannels = std::nullopt },
|
||||||
|
{ .containers = { "m4a", "mp4" }, .audioCodecs = { "aac" }, .protocols = {}, .maxAudioChannels = std::nullopt },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = { {
|
||||||
|
{ .container = "flac", .audioCodec = "flac", .protocol = "http", .maxAudioChannels = 6 },
|
||||||
|
{ .container = "mp4", .audioCodec = "aac", .protocol = "http", .maxAudioChannels = 6 },
|
||||||
|
{ .container = "aac", .audioCodec = "aac", .protocol = "http", .maxAudioChannels = 6 },
|
||||||
|
{ .container = "mp3", .audioCodec = "mp3", .protocol = "http", .maxAudioChannels = 2 },
|
||||||
|
} },
|
||||||
|
.codecProfiles = {
|
||||||
|
{
|
||||||
|
{ .type = "AudioCodec", .name = "flac", .limitations = { { .name = Limitation::Type::AudioSamplerate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "48000" }, .required = true } } },
|
||||||
|
{ .type = "AudioCodec", .name = "vorbis", .limitations = { { .name = Limitation::Type::AudioSamplerate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "48000" }, .required = true } } },
|
||||||
|
{ .type = "AudioCodec", .name = "opus", .limitations = { { .name = Limitation::Type::AudioSamplerate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "48000" }, .required = true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::FLAC,
|
||||||
|
.codec = core::media::CodecType::FLAC,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 950'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 96'000,
|
||||||
|
.bitsPerSample = 24,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::TranscodeResult{ .reasons = { details::TranscodeReason::AudioSampleRateNotSupported, details::TranscodeReason::ContainerNotSupported, details::TranscodeReason::ContainerNotSupported }, .targetStreamInfo = { .protocol = "http", .container = "flac", .codec = "flac", .audioChannels = std::nullopt, .audioBitrate = std::nullopt, .audioProfile = "", .audioSamplerate = 48'000, .audioBitdepth = std::nullopt } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// wants a lossy codec not handled -> transcode to lossy
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "SONOS",
|
||||||
|
.platform = "UPnP",
|
||||||
|
.maxAudioBitrate = 1'000'000,
|
||||||
|
.maxTranscodingAudioBitrate = 1'000'000,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = { "flac" }, .audioCodecs = {}, .protocols = {}, .maxAudioChannels = std::nullopt },
|
||||||
|
{ .containers = { "mp3" }, .audioCodecs = { "mp3" }, .protocols = {}, .maxAudioChannels = std::nullopt },
|
||||||
|
{ .containers = { "m4a", "mp4" }, .audioCodecs = { "aac" }, .protocols = {}, .maxAudioChannels = std::nullopt },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = { {
|
||||||
|
{ .container = "flac", .audioCodec = "flac", .protocol = "http", .maxAudioChannels = 6 },
|
||||||
|
{ .container = "aac", .audioCodec = "aac", .protocol = "http", .maxAudioChannels = 6 },
|
||||||
|
{ .container = "mp3", .audioCodec = "mp3", .protocol = "http", .maxAudioChannels = 2 },
|
||||||
|
} },
|
||||||
|
.codecProfiles = {
|
||||||
|
{
|
||||||
|
{ .type = "AudioCodec", .name = "flac", .limitations = { { .name = Limitation::Type::AudioSamplerate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "48000" }, .required = true } } },
|
||||||
|
{ .type = "AudioCodec", .name = "vorbis", .limitations = { { .name = Limitation::Type::AudioSamplerate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "48000" }, .required = true } } },
|
||||||
|
{ .type = "AudioCodec", .name = "opus", .limitations = { { .name = Limitation::Type::AudioSamplerate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "48000" }, .required = true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::Ogg,
|
||||||
|
.codec = core::media::CodecType::Vorbis,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 128'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 48'000,
|
||||||
|
.bitsPerSample = 16,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::TranscodeResult{ .reasons = { details::TranscodeReason::ContainerNotSupported, details::TranscodeReason::ContainerNotSupported, details::TranscodeReason::ContainerNotSupported }, .targetStreamInfo = { .protocol = "http", .container = "mp3", .codec = "mp3", .audioChannels = std::nullopt, .audioBitrate = 128000, .audioProfile = "", .audioSamplerate = std::nullopt, .audioBitdepth = std::nullopt } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// wants a lossless codec not handled -> transcode to lossless
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "SONOS",
|
||||||
|
.platform = "UPnP",
|
||||||
|
.maxAudioBitrate = 1'000'000,
|
||||||
|
.maxTranscodingAudioBitrate = 1'000'000,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = { "flac" }, .audioCodecs = {}, .protocols = {}, .maxAudioChannels = std::nullopt },
|
||||||
|
{ .containers = { "mp3" }, .audioCodecs = { "mp3" }, .protocols = {}, .maxAudioChannels = std::nullopt },
|
||||||
|
{ .containers = { "m4a", "mp4" }, .audioCodecs = { "aac" }, .protocols = {}, .maxAudioChannels = std::nullopt },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = { {
|
||||||
|
{ .container = "flac", .audioCodec = "flac", .protocol = "http", .maxAudioChannels = 6 },
|
||||||
|
{ .container = "aac", .audioCodec = "aac", .protocol = "http", .maxAudioChannels = 6 },
|
||||||
|
{ .container = "mp3", .audioCodec = "mp3", .protocol = "http", .maxAudioChannels = 2 },
|
||||||
|
} },
|
||||||
|
.codecProfiles = {
|
||||||
|
{
|
||||||
|
{ .type = "AudioCodec", .name = "flac", .limitations = { { .name = Limitation::Type::AudioSamplerate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "48000" }, .required = true } } },
|
||||||
|
{ .type = "AudioCodec", .name = "vorbis", .limitations = { { .name = Limitation::Type::AudioSamplerate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "48000" }, .required = true } } },
|
||||||
|
{ .type = "AudioCodec", .name = "opus", .limitations = { { .name = Limitation::Type::AudioSamplerate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "48000" }, .required = true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::DSF,
|
||||||
|
.codec = core::media::CodecType::DSD,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 950'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 96'000,
|
||||||
|
.bitsPerSample = 24,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::TranscodeResult{ .reasons = { details::TranscodeReason::ContainerNotSupported, details::TranscodeReason::ContainerNotSupported, details::TranscodeReason::ContainerNotSupported }, .targetStreamInfo = { .protocol = "http", .container = "flac", .codec = "flac", .audioChannels = std::nullopt, .audioBitrate = std::nullopt, .audioProfile = "", .audioSamplerate = 48'000, .audioBitdepth = std::nullopt } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// no protocol specified
|
||||||
|
{
|
||||||
|
.clientInfo = {
|
||||||
|
.name = "TestClient",
|
||||||
|
.platform = "TestPlatform",
|
||||||
|
.maxAudioBitrate = 512'000,
|
||||||
|
.maxTranscodingAudioBitrate = 96'000,
|
||||||
|
.directPlayProfiles = { {
|
||||||
|
{ .containers = { "mp3" }, .audioCodecs = { "mp3" }, .protocols = {}, .maxAudioChannels = 2 },
|
||||||
|
} },
|
||||||
|
.transcodingProfiles = {
|
||||||
|
{ { .container = "mp3", .audioCodec = "mp3", .protocol = { "http" }, .maxAudioChannels = 2 } },
|
||||||
|
},
|
||||||
|
.codecProfiles = { { .type = "AudioCodec", .name = "mp3", .limitations = {
|
||||||
|
{ .name = Limitation::Type::AudioBitrate, .comparison = Limitation::ComparisonOperator::LessThanEqual, .values = { "96000" }, .required = true },
|
||||||
|
} } },
|
||||||
|
},
|
||||||
|
.source = {
|
||||||
|
.container = core::media::ContainerType::MPEG,
|
||||||
|
.codec = core::media::CodecType::MP3,
|
||||||
|
.duration = std::chrono::seconds{ 60 },
|
||||||
|
.bitrate = 128'000,
|
||||||
|
.channelCount = 2,
|
||||||
|
.sampleRate = 44'100,
|
||||||
|
.bitsPerSample = std::nullopt,
|
||||||
|
},
|
||||||
|
|
||||||
|
.expected = { details::TranscodeResult{ .reasons = { details::TranscodeReason::AudioBitrateNotSupported }, .targetStreamInfo = { .protocol = "http", .container = "mp3", .codec = "mp3", .audioChannels = std::nullopt, .audioBitrate = 96000, .audioProfile = "", .audioSamplerate = std::nullopt, .audioBitdepth = std::nullopt } } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
processTests(testCases);
|
||||||
|
}
|
||||||
|
} // namespace lms::api::subsonic
|
||||||
Reference in New Issue
Block a user