From 7feecb2027a585e5a3f75c1a5bbc892a315ec3e0 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 3 Dec 2025 20:53:11 +0100 Subject: [PATCH] Implement the new OpenSubsonic API transcoding extension fixes #787 --- Dockerfile-release | 4 +- src/libs/audio/impl/ffmpeg/Transcoder.cpp | 19 + src/libs/core/impl/media/CodecType.cpp | 36 +- src/libs/core/include/core/UUID.hpp | 13 + .../core/include/core/media/CodecType.hpp | 4 +- src/libs/subsonic/CMakeLists.txt | 6 + src/libs/subsonic/impl/SubsonicResource.cpp | 11 +- src/libs/subsonic/impl/SubsonicResponse.hpp | 17 +- .../impl/endpoints/MediaAnnotation.cpp | 2 +- .../impl/endpoints/MediaRetrieval.cpp | 127 +--- src/libs/subsonic/impl/endpoints/Podcast.cpp | 2 +- src/libs/subsonic/impl/endpoints/System.cpp | 49 +- .../subsonic/impl/endpoints/Transcoding.cpp | 181 +++++ .../subsonic/impl/endpoints/Transcoding.hpp | 33 + .../endpoints/transcoding/AudioFileId.hpp | 30 + .../endpoints/transcoding/AudioFileInfo.cpp | 120 ++++ .../endpoints/transcoding/AudioFileInfo.hpp | 42 ++ .../transcoding/TranscodeDecision.cpp | 595 ++++++++++++++++ .../transcoding/TranscodeDecision.hpp | 76 +++ .../transcoding/TranscodeDecisionTracker.cpp | 92 +++ .../transcoding/TranscodeDecisionTracker.hpp | 53 ++ .../subsonic/impl/payloads/ClientInfo.cpp | 324 +++++++++ .../subsonic/impl/payloads/ClientInfo.hpp | 89 +++ .../subsonic/impl/payloads/StreamDetails.cpp | 44 ++ .../subsonic/impl/payloads/StreamDetails.hpp | 44 ++ src/libs/subsonic/test/CMakeLists.txt | 6 +- src/libs/subsonic/test/ClientInfo.cpp | 319 +++++++++ src/libs/subsonic/test/Subsonic.cpp | 33 + ...cResponseTest.cpp => SubsonicResponse.cpp} | 6 - src/libs/subsonic/test/TranscodeDecision.cpp | 641 ++++++++++++++++++ 30 files changed, 2855 insertions(+), 163 deletions(-) create mode 100644 src/libs/subsonic/impl/endpoints/Transcoding.cpp create mode 100644 src/libs/subsonic/impl/endpoints/Transcoding.hpp create mode 100644 src/libs/subsonic/impl/endpoints/transcoding/AudioFileId.hpp create mode 100644 src/libs/subsonic/impl/endpoints/transcoding/AudioFileInfo.cpp create mode 100644 src/libs/subsonic/impl/endpoints/transcoding/AudioFileInfo.hpp create mode 100644 src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecision.cpp create mode 100644 src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecision.hpp create mode 100644 src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecisionTracker.cpp create mode 100644 src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecisionTracker.hpp create mode 100644 src/libs/subsonic/impl/payloads/ClientInfo.cpp create mode 100644 src/libs/subsonic/impl/payloads/ClientInfo.hpp create mode 100644 src/libs/subsonic/impl/payloads/StreamDetails.cpp create mode 100644 src/libs/subsonic/impl/payloads/StreamDetails.hpp create mode 100644 src/libs/subsonic/test/ClientInfo.cpp create mode 100644 src/libs/subsonic/test/Subsonic.cpp rename src/libs/subsonic/test/{SubsonicResponseTest.cpp => SubsonicResponse.cpp} (97%) create mode 100644 src/libs/subsonic/test/TranscodeDecision.cpp diff --git a/Dockerfile-release b/Dockerfile-release index 0a162378..585c76db 100644 --- a/Dockerfile-release +++ b/Dockerfile-release @@ -70,9 +70,9 @@ RUN \ --enable-zlib \ --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-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-muxer=ogg,mp3 \ + --enable-muxer=flac,mp3,ogg \ --enable-protocol=file,pipe \ --enable-filter=aresample \ --enable-lto \ diff --git a/src/libs/audio/impl/ffmpeg/Transcoder.cpp b/src/libs/audio/impl/ffmpeg/Transcoder.cpp index 72261de6..f146adca 100644 --- a/src/libs/audio/impl/ffmpeg/Transcoder.cpp +++ b/src/libs/audio/impl/ffmpeg/Transcoder.cpp @@ -136,6 +136,25 @@ namespace lms::audio::ffmpeg 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) { args.emplace_back("-f"); diff --git a/src/libs/core/impl/media/CodecType.cpp b/src/libs/core/impl/media/CodecType.cpp index b5b778f0..2a0bc587 100644 --- a/src/libs/core/impl/media/CodecType.cpp +++ b/src/libs/core/impl/media/CodecType.cpp @@ -71,4 +71,38 @@ namespace lms::core::media return "Unknown"; } -} // namespace lms::core::media \ No newline at end of file + + 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 diff --git a/src/libs/core/include/core/UUID.hpp b/src/libs/core/include/core/UUID.hpp index b57d4628..422bba75 100644 --- a/src/libs/core/include/core/UUID.hpp +++ b/src/libs/core/include/core/UUID.hpp @@ -19,6 +19,7 @@ #pragma once +#include #include #include #include @@ -49,3 +50,15 @@ namespace lms::core::stringUtils std::optional readAs(std::string_view str); } + +namespace std +{ + template<> + struct hash + { + size_t operator()(const lms::core::UUID& str) const + { + return hash{}(str.getAsString()); + } + }; +} // namespace std diff --git a/src/libs/core/include/core/media/CodecType.hpp b/src/libs/core/include/core/media/CodecType.hpp index dac67e6d..3311a253 100644 --- a/src/libs/core/include/core/media/CodecType.hpp +++ b/src/libs/core/include/core/media/CodecType.hpp @@ -49,4 +49,6 @@ namespace lms::core::media }; core::LiteralString codecTypeToString(CodecType type); -} // namespace lms::core::media \ No newline at end of file + + bool isCodecLossless(CodecType type); +} // namespace lms::core::media diff --git a/src/libs/subsonic/CMakeLists.txt b/src/libs/subsonic/CMakeLists.txt index 802f5611..84ee7eab 100644 --- a/src/libs/subsonic/CMakeLists.txt +++ b/src/libs/subsonic/CMakeLists.txt @@ -1,5 +1,8 @@ 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/Bookmarks.cpp impl/endpoints/Browsing.cpp @@ -10,7 +13,10 @@ add_library(lmssubsonic STATIC impl/endpoints/Podcast.cpp impl/endpoints/Searching.cpp impl/endpoints/System.cpp + impl/endpoints/Transcoding.cpp impl/endpoints/UserManagement.cpp + impl/payloads/ClientInfo.cpp + impl/payloads/StreamDetails.cpp impl/responses/Album.cpp impl/responses/AlbumInfo.cpp impl/responses/Artist.cpp diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index 32a30b84..0210f6b4 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -49,6 +49,7 @@ #include "endpoints/Podcast.hpp" #include "endpoints/Searching.hpp" #include "endpoints/System.hpp" +#include "endpoints/Transcoding.hpp" #include "endpoints/UserManagement.hpp" namespace lms::api::subsonic @@ -181,6 +182,9 @@ namespace lms::api::subsonic { "/getLyricsBySongId", { handleGetLyricsBySongId } }, { "/getAvatar", { handleNotImplemented } }, + // Transcoding extensions + { "/getTranscodeDecision", { handleGetTranscodeDecision } }, + // Media annotation { "/star", { handleStarRequest } }, { "/unstar", { handleUnstarRequest } }, @@ -244,6 +248,9 @@ namespace lms::api::subsonic { "/download", handleDownload }, { "/stream", handleStream }, { "/getCoverArt", handleGetCoverArt }, + + // Transcoding extension + { "/getTranscodeStream", handleGetTranscodeStream }, }; struct TLSMonotonicMemoryResourceCleaner @@ -352,9 +359,7 @@ namespace lms::api::subsonic } catch (const Error& e) { - LMS_LOG(API_SUBSONIC, ERROR, "Error while processing request '" << requestPath << "'" - << ", params = [" << parameterMapToDebugString(request.getParameterMap()) << "]" - << ", code = " << static_cast(e.getCode()) << ", msg = '" << e.getMessage() << "'"); + LMS_LOG(API_SUBSONIC, ERROR, "Error while processing request '" << requestPath << "'" << ", params = [" << parameterMapToDebugString(request.getParameterMap()) << "]" << ", code = " << static_cast(e.getCode()) << ", msg = '" << e.getMessage() << "'"); Response resp{ Response::createFailedResponse(protocolVersion, e) }; resp.write(response.out(), format); response.setMimeType(std::string{ ResponseFormatToMimeType(format) }); diff --git a/src/libs/subsonic/impl/SubsonicResponse.hpp b/src/libs/subsonic/impl/SubsonicResponse.hpp index 44f19387..94d76898 100644 --- a/src/libs/subsonic/impl/SubsonicResponse.hpp +++ b/src/libs/subsonic/impl/SubsonicResponse.hpp @@ -219,13 +219,24 @@ namespace lms::api::subsonic class BadParameterGenericError : public GenericError { public: - BadParameterGenericError(const std::string& parameterName) - : _parameterName{ parameterName } {} + BadParameterGenericError(std::string_view parameterName, std::string_view details = {}) + : _parameterName{ parameterName } + , _details{ details } + { + } + + std::string_view getParameterName() const { return _parameterName; } 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 _details; }; class ParameterValueTooHighGenericError : public GenericError diff --git a/src/libs/subsonic/impl/endpoints/MediaAnnotation.cpp b/src/libs/subsonic/impl/endpoints/MediaAnnotation.cpp index ae033bae..6df094dc 100644 --- a/src/libs/subsonic/impl/endpoints/MediaAnnotation.cpp +++ b/src/libs/subsonic/impl/endpoints/MediaAnnotation.cpp @@ -101,7 +101,7 @@ namespace lms::api::subsonic const int rating = getMandatoryParameterAs(parameters, "rating"); // The rating between 1 and 5 (inclusive), or 0 to remove the rating if (rating < 0 || rating > 5) - throw BadParameterGenericError{ "rating must be 0 or in range 1-5" }; + throw BadParameterGenericError{ "rating", "must be 0 or in range 1-5" }; if (rating > 0) res.rating = rating; diff --git a/src/libs/subsonic/impl/endpoints/MediaRetrieval.cpp b/src/libs/subsonic/impl/endpoints/MediaRetrieval.cpp index f31c9dc7..9ed5fee3 100644 --- a/src/libs/subsonic/impl/endpoints/MediaRetrieval.cpp +++ b/src/libs/subsonic/impl/endpoints/MediaRetrieval.cpp @@ -28,13 +28,7 @@ #include "core/String.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/objects/PodcastEpisode.hpp" #include "database/objects/PodcastEpisodeId.hpp" #include "database/objects/Track.hpp" #include "database/objects/TrackLyrics.hpp" @@ -42,7 +36,6 @@ #include "database/objects/User.hpp" #include "services/artwork/IArtworkService.hpp" -#include "services/podcast/IPodcastService.hpp" #include "services/transcoding/ITranscodeService.hpp" #include "CoverArtId.hpp" @@ -51,6 +44,7 @@ #include "SubsonicId.hpp" #include "SubsonicResponse.hpp" #include "responses/Lyrics.hpp" +#include "transcoding/AudioFileInfo.hpp" namespace lms::api::subsonic { @@ -106,93 +100,6 @@ namespace lms::api::subsonic 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; - 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(&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(&audioFileId) }) - { - const db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(session, *episodeId) }; - if (!episode) - throw RequestedDataNotFoundError{}; - - std::filesystem::path podcastCachePath{ core::Service::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 { std::filesystem::path filePath; @@ -383,31 +290,23 @@ namespace lms::api::subsonic { std::shared_ptr resourceHandler; - try + Wt::Http::ResponseContinuation* continuation = request.continuation(); + if (!continuation) { - Wt::Http::ResponseContinuation* continuation = request.continuation(); - if (!continuation) - { - StreamParameters streamParameters{ getStreamParameters(context) }; - if (streamParameters.transcodeParameters) - resourceHandler = core::Service::get()->createTranscodeResourceHandler(*streamParameters.transcodeParameters, streamParameters.estimateContentLength); - else - resourceHandler = core::createFileResourceHandler(streamParameters.filePath, core::media::getMimeType(streamParameters.audioProperties.container, streamParameters.audioProperties.codec).str()); - } + StreamParameters streamParameters{ getStreamParameters(context) }; + if (streamParameters.transcodeParameters) + resourceHandler = core::Service::get()->createTranscodeResourceHandler(*streamParameters.transcodeParameters, streamParameters.estimateContentLength); else - { - resourceHandler = Wt::cpp17::any_cast>(continuation->data()); - } - - continuation = resourceHandler->processRequest(request, response); - if (continuation) - continuation->setData(resourceHandler); + resourceHandler = core::createFileResourceHandler(streamParameters.filePath, core::media::getMimeType(streamParameters.audioProperties.container, streamParameters.audioProperties.codec).str()); } - catch (const audio::Exception& e) + else { - response.setStatus(404); // report not found if something wrong happened - LMS_LOG(API_SUBSONIC, ERROR, "Caught Av exception: " << e.what()); + resourceHandler = Wt::cpp17::any_cast>(continuation->data()); } + + continuation = resourceHandler->processRequest(request, response); + if (continuation) + continuation->setData(resourceHandler); } void handleGetCoverArt(RequestContext& context, const Wt::Http::Request& /*request*/, Wt::Http::Response& response) diff --git a/src/libs/subsonic/impl/endpoints/Podcast.cpp b/src/libs/subsonic/impl/endpoints/Podcast.cpp index c4d129ff..389462c1 100644 --- a/src/libs/subsonic/impl/endpoints/Podcast.cpp +++ b/src/libs/subsonic/impl/endpoints/Podcast.cpp @@ -97,7 +97,7 @@ namespace lms::api::subsonic const std::string url{ getMandatoryParameterAs(context.getParameters(), "url") }; 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 core::Service::get()->addPodcast(url); diff --git a/src/libs/subsonic/impl/endpoints/System.cpp b/src/libs/subsonic/impl/endpoints/System.cpp index 81468271..9cc6eeee 100644 --- a/src/libs/subsonic/impl/endpoints/System.cpp +++ b/src/libs/subsonic/impl/endpoints/System.cpp @@ -17,6 +17,8 @@ * along with LMS. If not, see . */ +#include + #include "endpoints/System.hpp" namespace lms::api::subsonic @@ -42,40 +44,27 @@ namespace lms::api::subsonic { Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; + struct Extension { - Response::Node& transcodeOffsetNode{ response.createArrayNode("openSubsonicExtensions") }; - transcodeOffsetNode.setAttribute("name", "transcodeOffset"); - transcodeOffsetNode.addArrayValue("versions", 1); - } + core::LiteralString name; + int version; + }; - { - Response::Node& formPostNode{ response.createArrayNode("openSubsonicExtensions") }; - formPostNode.setAttribute("name", "formPost"); - formPostNode.addArrayValue("versions", 1); - } + constexpr std::array extensions{ + Extension{ "apiKeyAuthentication", 1 }, + Extension{ "getPodcastEpisode", 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") }; - songLyricsNode.setAttribute("name", "songLyrics"); - songLyricsNode.addArrayValue("versions", 1); - } - - { - 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); + Response::Node& extensionNode{ response.createArrayNode("openSubsonicExtensions") }; + extensionNode.setAttribute("name", extension.name.str()); + extensionNode.addArrayValue("versions", extension.version); } return response; diff --git a/src/libs/subsonic/impl/endpoints/Transcoding.cpp b/src/libs/subsonic/impl/endpoints/Transcoding.cpp new file mode 100644 index 00000000..743a2116 --- /dev/null +++ b/src/libs/subsonic/impl/endpoints/Transcoding.cpp @@ -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 . + */ + +#include "Transcoding.hpp" + +#include +#include + +#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(context.getParameters(), "mediaType") }; + + AudioFileId audioFileId; + if (mediaType == "song") + audioFileId = getMandatoryParameterAs(context.getParameters(), "mediaId"); + else if (mediaType == "podcast") + audioFileId = getMandatoryParameterAs(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(context.getParameters(), "transcodeParams") }; + const std::chrono::seconds offset{ getParameterAs(context.getParameters(), "offset").value_or(0) }; + + const std::shared_ptr 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 resourceHandler; + + Wt::Http::ResponseContinuation* continuation = request.continuation(); + if (!continuation) + { + const audio::TranscodeParameters params{ getTranscodingParameters(context) }; + resourceHandler = core::Service::get()->createTranscodeResourceHandler(params, false /* estimate content length */); + } + else + { + resourceHandler = Wt::cpp17::any_cast>(continuation->data()); + } + assert(resourceHandler); // handles errors internally + + continuation = resourceHandler->processRequest(request, response); + if (continuation) + continuation->setData(resourceHandler); + } +} // namespace lms::api::subsonic diff --git a/src/libs/subsonic/impl/endpoints/Transcoding.hpp b/src/libs/subsonic/impl/endpoints/Transcoding.hpp new file mode 100644 index 00000000..67f52250 --- /dev/null +++ b/src/libs/subsonic/impl/endpoints/Transcoding.hpp @@ -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 . + */ + +#pragma once + +#include +#include + +#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 diff --git a/src/libs/subsonic/impl/endpoints/transcoding/AudioFileId.hpp b/src/libs/subsonic/impl/endpoints/transcoding/AudioFileId.hpp new file mode 100644 index 00000000..fe42c677 --- /dev/null +++ b/src/libs/subsonic/impl/endpoints/transcoding/AudioFileId.hpp @@ -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 . + */ + +#pragma once + +#include + +#include "database/objects/PodcastEpisodeId.hpp" +#include "database/objects/TrackId.hpp" + +namespace lms::api::subsonic +{ + using AudioFileId = std::variant; +} diff --git a/src/libs/subsonic/impl/endpoints/transcoding/AudioFileInfo.cpp b/src/libs/subsonic/impl/endpoints/transcoding/AudioFileInfo.cpp new file mode 100644 index 00000000..3a71404e --- /dev/null +++ b/src/libs/subsonic/impl/endpoints/transcoding/AudioFileInfo.cpp @@ -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 . + */ + +#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(&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(&audioFileId) }) + { + const db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(session, *episodeId) }; + if (!episode) + throw RequestedDataNotFoundError{}; + + std::filesystem::path podcastCachePath{ core::Service::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 \ No newline at end of file diff --git a/src/libs/subsonic/impl/endpoints/transcoding/AudioFileInfo.hpp b/src/libs/subsonic/impl/endpoints/transcoding/AudioFileInfo.hpp new file mode 100644 index 00000000..f3025316 --- /dev/null +++ b/src/libs/subsonic/impl/endpoints/transcoding/AudioFileInfo.hpp @@ -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 . + */ + +#pragma once + +#include + +#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 \ No newline at end of file diff --git a/src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecision.cpp b/src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecision.cpp new file mode 100644 index 00000000..4de90dce --- /dev/null +++ b/src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecision.cpp @@ -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 . + */ + +#include "TranscodeDecision.hpp" + +#include + +#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 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 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 newValue; + }; + + AdjustResult adjustUsingEqualsLimitation(std::span values, unsigned originalValue) + { + if (values.size() == 1) + { + const auto value{ core::stringUtils::readAs(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 closestValue; + for (std::string_view valueStr : values) + { + const auto value{ core::stringUtils::readAs(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 values, unsigned originalValue) + { + if (std::none_of(std::cbegin(values), std::cend(values), [&](std::string_view valueStr) { + const auto value{ core::stringUtils::readAs(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 values, unsigned originalValue) + { + // Take only the first value into account + const auto value{ core::stringUtils::readAs(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 values, unsigned originalValue) + { + // Take only the first value into account + const auto value{ core::stringUtils::readAs(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 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 valueToCheck{}; + switch (limitation.name) + { + case Limitation::Type::AudioBitrate: + valueToCheck = static_cast(source.bitrate); + break; + case Limitation::Type::AudioChannels: + valueToCheck = static_cast(source.channelCount); + break; + break; + case Limitation::Type::AudioSamplerate: + valueToCheck = static_cast(source.sampleRate); + break; + case Limitation::Type::AudioProfile: + // TODO; + break; + case Limitation::Type::AudioBitdepth: + if (source.bitsPerSample) + valueToCheck = static_cast(*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 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 needsTranscode(const DirectPlayProfile& profile, std::span 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 computeTranscodedStream(std::optional maxAudioBitrate, const TranscodingProfile& profile, std::span 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& 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{ 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 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 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 diff --git a/src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecision.hpp b/src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecision.hpp new file mode 100644 index 00000000..705fc119 --- /dev/null +++ b/src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecision.hpp @@ -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 . + */ + +#pragma once + +#include +#include + +#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 reasons; + StreamDetails targetStreamInfo; + + bool operator==(const TranscodeResult&) const = default; + }; + + struct FailureResult + { + std::string reason; + + bool operator==(const FailureResult&) const = default; + }; + + using TranscodeDecisionResult = std::variant; + 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 diff --git a/src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecisionTracker.cpp b/src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecisionTracker.cpp new file mode 100644 index 00000000..f0a4ca58 --- /dev/null +++ b/src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecisionTracker.cpp @@ -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 . + */ + +#include "TranscodeDecisionTracker.hpp" + +#include +#include + +#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(now, audioFileId, targetStreamInfo) }; + + { + std::scoped_lock lock{ mutex }; + + purgeOutdatedEntries(now); + + entries.emplace(uuid, entry); + } + + return uuid; + } + + std::shared_ptr get(const core::UUID& uuid) override + { + const Clock::time_point now{ Clock::now() }; + std::shared_ptr 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> 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 \ No newline at end of file diff --git a/src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecisionTracker.hpp b/src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecisionTracker.hpp new file mode 100644 index 00000000..5da0a4c8 --- /dev/null +++ b/src/libs/subsonic/impl/endpoints/transcoding/TranscodeDecisionTracker.hpp @@ -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 . + */ + +#pragma once + +#include +#include + +#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 get(const core::UUID& uuid) = 0; + }; + + ITranscodeDecisionTracker& getTranscodeDecisionTracker(); +} // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/payloads/ClientInfo.cpp b/src/libs/subsonic/impl/payloads/ClientInfo.cpp new file mode 100644 index 00000000..01c65a34 --- /dev/null +++ b/src/libs/subsonic/impl/payloads/ClientInfo.cpp @@ -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 . + */ + +#include "ClientInfo.hpp" + +#include + +#include +#include +#include + +#include "core/String.hpp" + +#include "SubsonicResponse.hpp" + +namespace lms::api::subsonic +{ + namespace + { + template + std::optional parseValue(const Wt::Json::Object& object, const std::string& entry) + { + try + { + std::optional res; + + const Wt::Json::Value& value{ object.get(entry) }; + if (value.isNull()) + return res; + + if constexpr (std::is_same_v) + { + if (value.type() != Wt::Json::Type::Bool) + throw BadParameterGenericError{ entry, "field must be a boolean" }; + } + else if constexpr (std::is_same_v) + { + if (value.type() != Wt::Json::Type::String) + throw BadParameterGenericError{ entry, "field must be a string" }; + } + else if constexpr (std::is_integral_v) + { + if (value.type() != Wt::Json::Type::Number) + throw BadParameterGenericError{ entry, "field must be a number" }; + } + else + { + static_assert(false, "Unhandled type"); + } + + return static_cast(value); + } + catch (const Wt::WException& e) + { + throw BadParameterGenericError{ entry, "failed to read value" }; + } + } + + template + std::vector parseMandatoryArrayValues(const Wt::Json::Object& object, const std::string& entry) + { + std::vector 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(value)) + { + if constexpr (std::is_same_v) + { + if (item.type() != Wt::Json::Type::Bool) + throw BadParameterGenericError{ entry, "array item must be a boolean" }; + } + else if constexpr (std::is_same_v) + { + if (item.type() != Wt::Json::Type::String) + throw BadParameterGenericError{ entry, "array item must be a string" }; + } + else if constexpr (std::is_integral_v) + { + 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(item)); + } + } + catch (const Wt::WException& e) + { + throw BadParameterGenericError{ entry, "failed to read value" }; + } + + return res; + } + + template + T parseMandatoryValue(const Wt::Json::Object& object, const std::string& entry) + { + std::optional res{ parseValue(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(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{ is }, std::istreambuf_iterator{} }; + + try + { + Wt::Json::Object root; + Wt::Json::parse(msgBody, root); + + res.name = parseMandatoryValue(root, "name"); + res.platform = parseMandatoryValue(root, "platform"); + { + const auto maxAudioBitrate = parseValue(root, "maxAudioBitrate"); + if (maxAudioBitrate && *maxAudioBitrate > 0) + res.maxAudioBitrate = *maxAudioBitrate; + } + + { + const auto maxTranscodingAudioBitrate = parseValue(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(directPlayProfiles)) + { + DirectPlayProfile directPlayProfile; + + auto checkValues{ [](std::span 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(profile, "containers"); + if (!checkValues(directPlayProfile.containers)) + throw BadParameterGenericError{ "ClientInfo::DirectPlayProfile::containers", "Invalid value" }; + + directPlayProfile.audioCodecs = parseMandatoryArrayValues(profile, "audioCodecs"); + if (!checkValues(directPlayProfile.audioCodecs)) + throw BadParameterGenericError{ "ClientInfo::DirectPlayProfile::containers", "Invalid value" }; + + directPlayProfile.protocols = parseMandatoryArrayValues(profile, "protocols"); + if (!checkValues(directPlayProfile.protocols)) + throw BadParameterGenericError{ "ClientInfo::DirectPlayProfile::protocols", "Invalid value" }; + + { + const auto maxAudioChannels = parseValue(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(transcodingProfiles)) + { + TranscodingProfile transcodingProfile; + transcodingProfile.container = parseMandatoryValue(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(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(profile, "protocol"); + if (transcodingProfile.protocol.empty()) + throw BadParameterGenericError{ "ClientInfo::TranscodingProfile::protocol", "cannot be empty" }; + + { + const auto maxAudioChannels{ parseValue(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(codecProfiles)) + { + CodecProfile codecProfile; + codecProfile.type = parseMandatoryValue(profile, "type"); + if (codecProfile.type != "AudioCodec" && codecProfile.type != "VideoCodec") + throw BadParameterGenericError{ "ClientInfo::CodecProfile::type", "unexpected value" }; + + codecProfile.name = parseMandatoryValue(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(limitations)) + { + Limitation lim; + lim.name = parseLimitationType(parseMandatoryValue(limitation, "name")); + lim.comparison = parseComparisonOperator(parseMandatoryValue(limitation, "comparison")); + lim.values = parseMandatoryArrayValues(limitation, "values"); + lim.required = parseMandatoryValue(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 \ No newline at end of file diff --git a/src/libs/subsonic/impl/payloads/ClientInfo.hpp b/src/libs/subsonic/impl/payloads/ClientInfo.hpp new file mode 100644 index 00000000..f16ae891 --- /dev/null +++ b/src/libs/subsonic/impl/payloads/ClientInfo.hpp @@ -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 . + */ + +#pragma once + +#include +#include +#include +#include + +namespace lms::api::subsonic +{ + struct DirectPlayProfile + { + std::vector containers; // Supported containers (e.g., mp3, flac). + std::vector audioCodecs; // Supported audio codecs. + std::vector protocols; // The streaming protocols. Can be http or hls. + std::optional 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 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 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 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 maxAudioBitrate; // The maximum audio bitrate the client can handle. + std::optional maxTranscodingAudioBitrate; // The maximum audio bitrate for transcoded content. + std::vector directPlayProfiles; // A list of profiles for direct playback. + std::vector transcodingProfiles; // A list of profiles for transcoding. The server should evaluate these in the order they are listed, as a priority list. + std::vector codecProfiles; // A list of codec-specific profiles. + }; + + [[nodiscard]] ClientInfo parseClientInfoFromJson(std::istream& is); +} // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/payloads/StreamDetails.cpp b/src/libs/subsonic/impl/payloads/StreamDetails.cpp new file mode 100644 index 00000000..df7cc044 --- /dev/null +++ b/src/libs/subsonic/impl/payloads/StreamDetails.cpp @@ -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 . + */ + +#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 \ No newline at end of file diff --git a/src/libs/subsonic/impl/payloads/StreamDetails.hpp b/src/libs/subsonic/impl/payloads/StreamDetails.hpp new file mode 100644 index 00000000..f2d96add --- /dev/null +++ b/src/libs/subsonic/impl/payloads/StreamDetails.hpp @@ -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 . + */ + +#pragma once + +#include +#include + +#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 audioChannels; // The number of audio channels. + std::optional audioBitrate; // The audio bitrate in kbps. + std::string audioProfile; // The audio profile (e.g., LC, HE-AAC). + std::optional audioSamplerate; // The audio sample rate in Hz. + std::optional audioBitdepth; // The audio bit depth in bits. + + auto operator<=>(const StreamDetails&) const = default; + }; + + Response::Node createStreamDetails(const StreamDetails& streamDetails); +} // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/test/CMakeLists.txt b/src/libs/subsonic/test/CMakeLists.txt index 6e79cf78..39678b48 100644 --- a/src/libs/subsonic/test/CMakeLists.txt +++ b/src/libs/subsonic/test/CMakeLists.txt @@ -1,7 +1,10 @@ include(GoogleTest) add_executable(test-subsonic - SubsonicResponseTest.cpp + ClientInfo.cpp + Subsonic.cpp + SubsonicResponse.cpp + TranscodeDecision.cpp ) target_include_directories(test-subsonic PRIVATE @@ -10,6 +13,7 @@ target_include_directories(test-subsonic PRIVATE target_link_libraries(test-subsonic PRIVATE lmscore + lmsaudio lmssubsonic GTest::GTest ) diff --git a/src/libs/subsonic/test/ClientInfo.cpp b/src/libs/subsonic/test/ClientInfo.cpp new file mode 100644 index 00000000..6d23df81 --- /dev/null +++ b/src/libs/subsonic/test/ClientInfo.cpp @@ -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 . + */ + +#include +#include + +#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{ "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 \ No newline at end of file diff --git a/src/libs/subsonic/test/Subsonic.cpp b/src/libs/subsonic/test/Subsonic.cpp new file mode 100644 index 00000000..b00b3a0b --- /dev/null +++ b/src/libs/subsonic/test/Subsonic.cpp @@ -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 . + */ + +#include + +#include "core/ILogger.hpp" +#include "core/Service.hpp" + +int main(int argc, char** argv) +{ + using namespace lms; + core::Service logger{ core::logging::createLogger(core::logging::Severity::ERROR) }; + + ::testing::InitGoogleTest(&argc, argv); + + return RUN_ALL_TESTS(); +} \ No newline at end of file diff --git a/src/libs/subsonic/test/SubsonicResponseTest.cpp b/src/libs/subsonic/test/SubsonicResponse.cpp similarity index 97% rename from src/libs/subsonic/test/SubsonicResponseTest.cpp rename to src/libs/subsonic/test/SubsonicResponse.cpp index c3b4cbb7..a21523ab 100644 --- a/src/libs/subsonic/test/SubsonicResponseTest.cpp +++ b/src/libs/subsonic/test/SubsonicResponse.cpp @@ -114,9 +114,3 @@ namespace lms::api::subsonic::tests } } // namespace lms::api::subsonic::tests - -int main(int argc, char** argv) -{ - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} \ No newline at end of file diff --git a/src/libs/subsonic/test/TranscodeDecision.cpp b/src/libs/subsonic/test/TranscodeDecision.cpp new file mode 100644 index 00000000..45bd8b02 --- /dev/null +++ b/src/libs/subsonic/test/TranscodeDecision.cpp @@ -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 . + */ + +#include + +#include + +#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 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