Implement the new OpenSubsonic API transcoding extension fixes #787

This commit is contained in:
emeric
2025-12-03 20:53:11 +01:00
parent 954b873f2b
commit 7feecb2027
30 changed files with 2855 additions and 163 deletions
@@ -101,7 +101,7 @@ namespace lms::api::subsonic
const int rating = getMandatoryParameterAs<int>(parameters, "rating"); // The rating between 1 and 5 (inclusive), or 0 to remove the rating
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;
@@ -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<db::TrackId, db::PodcastEpisodeId>;
struct AudioFileInfo
{
std::filesystem::path path;
audio::AudioProperties audioProperties;
};
AudioFileInfo getAudioFileInfo(db::Session& session, AudioFileId audioFileId)
{
AudioFileInfo res;
auto transaction{ session.createReadTransaction() };
if (const db::TrackId * trackId{ std::get_if<db::TrackId>(&audioFileId) })
{
const db::Track::pointer track{ db::Track::find(session, *trackId) };
if (!track)
throw RequestedDataNotFoundError{};
res.path = track->getAbsoluteFilePath();
if (track->getContainer() && track->getCodec())
{
res.audioProperties.container = *track->getContainer();
res.audioProperties.codec = *track->getCodec();
res.audioProperties.duration = track->getDuration();
res.audioProperties.bitrate = track->getBitrate();
res.audioProperties.channelCount = track->getChannelCount();
res.audioProperties.sampleRate = track->getSampleRate();
res.audioProperties.bitsPerSample = track->getBitsPerSample();
}
else
{
res.audioProperties = getAudioProperties(res.path);
}
}
else if (const db::PodcastEpisodeId * episodeId{ std::get_if<db::PodcastEpisodeId>(&audioFileId) })
{
const db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(session, *episodeId) };
if (!episode)
throw RequestedDataNotFoundError{};
std::filesystem::path podcastCachePath{ core::Service<podcast::IPodcastService>::get()->getCachePath() };
res.path = podcastCachePath / episode->getAudioRelativeFilePath();
if (episode->getContainer() && episode->getCodec())
{
res.audioProperties.container = *episode->getContainer();
res.audioProperties.codec = *episode->getCodec();
res.audioProperties.duration = episode->getDuration();
res.audioProperties.bitrate = episode->getBitrate();
res.audioProperties.channelCount = episode->getChannelCount();
res.audioProperties.sampleRate = episode->getSampleRate();
res.audioProperties.bitsPerSample = episode->getBitsPerSample();
}
else
{
res.audioProperties = getAudioProperties(res.path);
}
}
return res;
}
struct StreamParameters
{
std::filesystem::path filePath;
@@ -383,31 +290,23 @@ namespace lms::api::subsonic
{
std::shared_ptr<core::IResourceHandler> 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<transcoding::ITranscodeService>::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<transcoding::ITranscodeService>::get()->createTranscodeResourceHandler(*streamParameters.transcodeParameters, streamParameters.estimateContentLength);
else
{
resourceHandler = Wt::cpp17::any_cast<std::shared_ptr<core::IResourceHandler>>(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<std::shared_ptr<core::IResourceHandler>>(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)
+1 -1
View File
@@ -97,7 +97,7 @@ namespace lms::api::subsonic
const std::string url{ getMandatoryParameterAs<std::string>(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<podcast::IPodcastService>::get()->addPodcast(url);
+19 -30
View File
@@ -17,6 +17,8 @@
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include <array>
#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;
@@ -0,0 +1,181 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "Transcoding.hpp"
#include <chrono>
#include <memory>
#include "core/IResourceHandler.hpp"
#include "core/Service.hpp"
#include "core/UUID.hpp"
#include "core/Utils.hpp"
#include "database/objects/PodcastEpisodeId.hpp"
#include "services/transcoding/ITranscodeService.hpp"
#include "ParameterParsing.hpp"
#include "RequestContext.hpp"
#include "SubsonicId.hpp"
#include "SubsonicResponse.hpp"
#include "payloads/ClientInfo.hpp"
#include "payloads/StreamDetails.hpp"
#include "transcoding/AudioFileInfo.hpp"
#include "transcoding/TranscodeDecision.hpp"
#include "transcoding/TranscodeDecisionTracker.hpp"
namespace lms::api::subsonic
{
namespace
{
StreamDetails createStreamDetailsFromAudioProperties(const audio::AudioProperties& audioProperties)
{
StreamDetails res;
res.protocol = "http";
res.container = core::media::containerTypeToString(audioProperties.container).str();
res.codec = core::media::codecTypeToString(audioProperties.codec).str();
res.audioChannels = audioProperties.channelCount;
res.audioBitrate = audioProperties.bitrate;
res.audioProfile = ""; // TODO
res.audioSamplerate = audioProperties.sampleRate;
res.audioBitdepth = audioProperties.bitsPerSample;
return res;
}
AudioFileId getMandatoryMediaIdParameter(RequestContext& context)
{
const std::string mediaType{ getMandatoryParameterAs<std::string>(context.getParameters(), "mediaType") };
AudioFileId audioFileId;
if (mediaType == "song")
audioFileId = getMandatoryParameterAs<db::TrackId>(context.getParameters(), "mediaId");
else if (mediaType == "podcast")
audioFileId = getMandatoryParameterAs<db::PodcastEpisodeId>(context.getParameters(), "mediaId");
else
throw BadParameterGenericError{ "id", "must be 'song' or 'podcast'" };
return audioFileId;
}
} // namespace
Response handleGetTranscodeDecision(RequestContext& context)
{
// Parameters
const AudioFileId audioFileId{ getMandatoryMediaIdParameter(context) };
const ClientInfo clientInfo{ parseClientInfoFromJson(context.getBody()) };
const AudioFileInfo audioFileInfo{ getAudioFileInfo(context.getDbSession(), audioFileId) };
Response response{ Response::createOkResponse(context.getServerProtocolVersion()) };
Response::Node& transcodeNode{ response.createNode("transcodeDecision") };
{
const StreamDetails sourceStream{ createStreamDetailsFromAudioProperties(audioFileInfo.audioProperties) };
transcodeNode.addChild("sourceStream", createStreamDetails(sourceStream));
}
const details::TranscodeDecisionResult transcodeDecision{ details::computeTranscodeDecision(clientInfo, audioFileInfo.audioProperties) };
std::visit(core::utils::overloads{
[&](const details::DirectPlayResult&) {
transcodeNode.setAttribute("canDirectPlay", true);
transcodeNode.setAttribute("canTranscode", false);
},
[&](const details::TranscodeResult& transcodeRes) {
transcodeNode.setAttribute("canDirectPlay", false);
transcodeNode.setAttribute("canTranscode", true);
for (details::TranscodeReason reason : transcodeRes.reasons)
transcodeNode.addArrayValue("transcodeReason", transcodeReasonToString(reason).str());
const core::UUID uuid{ getTranscodeDecisionTracker().add(audioFileId, transcodeRes.targetStreamInfo) };
transcodeNode.addChild("transcodeStream", createStreamDetails(transcodeRes.targetStreamInfo));
transcodeNode.setAttribute("transcodeParams", uuid.getAsString());
},
[&](const details::FailureResult& failureRes) {
transcodeNode.setAttribute("canDirectPlay", false);
transcodeNode.setAttribute("canTranscode", false);
transcodeNode.setAttribute("errorReason", failureRes.reason);
} },
transcodeDecision);
return response;
}
audio::TranscodeParameters getTranscodingParameters(RequestContext& context)
{
// Parameters
const AudioFileId audioFileId{ getMandatoryMediaIdParameter(context) };
const core::UUID uuid{ getMandatoryParameterAs<core::UUID>(context.getParameters(), "transcodeParams") };
const std::chrono::seconds offset{ getParameterAs<std::size_t>(context.getParameters(), "offset").value_or(0) };
const std::shared_ptr<ITranscodeDecisionTracker::Entry> entry{ getTranscodeDecisionTracker().get(uuid) };
if (!entry || entry->audioFileId != audioFileId)
throw RequestedDataNotFoundError{};
const AudioFileInfo audioFileInfo{ getAudioFileInfo(context.getDbSession(), audioFileId) };
audio::TranscodeParameters params;
params.inputParameters.filePath = audioFileInfo.path;
params.inputParameters.audioProperties = audioFileInfo.audioProperties;
params.inputParameters.offset = offset;
if (entry->targetStreamInfo.audioChannels)
params.outputParameters.channelCount = *entry->targetStreamInfo.audioChannels;
if (entry->targetStreamInfo.audioSamplerate)
params.outputParameters.sampleRate = *entry->targetStreamInfo.audioSamplerate;
if (entry->targetStreamInfo.audioBitrate)
params.outputParameters.bitrate = *entry->targetStreamInfo.audioBitrate;
if (entry->targetStreamInfo.audioBitdepth)
params.outputParameters.bitsPerSample = *entry->targetStreamInfo.audioBitdepth;
params.outputParameters.stripMetadata = false;
const audio::TranscodeOutputFormat* transcodeOutputFormat{ details::selectTranscodeOutputFormat(entry->targetStreamInfo.container, entry->targetStreamInfo.codec) };
if (!transcodeOutputFormat)
throw InternalErrorGenericError{ "Unsupported output format" };
params.outputParameters.format = *transcodeOutputFormat;
return params;
}
void handleGetTranscodeStream(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response)
{
std::shared_ptr<core::IResourceHandler> resourceHandler;
Wt::Http::ResponseContinuation* continuation = request.continuation();
if (!continuation)
{
const audio::TranscodeParameters params{ getTranscodingParameters(context) };
resourceHandler = core::Service<transcoding::ITranscodeService>::get()->createTranscodeResourceHandler(params, false /* estimate content length */);
}
else
{
resourceHandler = Wt::cpp17::any_cast<std::shared_ptr<core::IResourceHandler>>(continuation->data());
}
assert(resourceHandler); // handles errors internally
continuation = resourceHandler->processRequest(request, response);
if (continuation)
continuation->setData(resourceHandler);
}
} // namespace lms::api::subsonic
@@ -0,0 +1,33 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <Wt/Http/Request.h>
#include <Wt/Http/Response.h>
#include "SubsonicResponse.hpp"
namespace lms::api::subsonic
{
struct RequestContext;
Response handleGetTranscodeDecision(RequestContext& context);
void handleGetTranscodeStream(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response);
} // namespace lms::api::subsonic
@@ -0,0 +1,30 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <variant>
#include "database/objects/PodcastEpisodeId.hpp"
#include "database/objects/TrackId.hpp"
namespace lms::api::subsonic
{
using AudioFileId = std::variant<db::TrackId, db::PodcastEpisodeId>;
}
@@ -0,0 +1,120 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "AudioFileInfo.hpp"
#include "audio/AudioProperties.hpp"
#include "audio/Exception.hpp"
#include "audio/IAudioFileInfo.hpp"
#include "audio/IAudioFileInfoParser.hpp"
#include "database/Session.hpp"
#include "database/objects/PodcastEpisode.hpp"
#include "database/objects/Track.hpp"
#include "services/podcast/IPodcastService.hpp"
#include "SubsonicResponse.hpp"
namespace lms::api::subsonic
{
namespace
{
audio::AudioProperties getAudioProperties(const std::filesystem::path& trackPath)
{
try
{
const auto parser{ audio::createAudioFileInfoParser(audio::AudioFileInfoParserBackend::FFmpeg) };
audio::AudioFileInfoParseOptions parseOptions;
parseOptions.audioPropertiesReadStyle = audio::AudioFileInfoParseOptions::AudioPropertiesReadStyle::Average;
parseOptions.readImages = false;
parseOptions.readTags = false;
const auto audioFile{ parser->parse(trackPath, parseOptions) };
const audio::AudioProperties* properties{ audioFile->getAudioProperties() };
if (!properties)
throw RequestedDataNotFoundError{};
return *properties;
}
catch (const audio::Exception& e)
{
throw RequestedDataNotFoundError{};
}
}
} // namespace
AudioFileInfo getAudioFileInfo(db::Session& session, AudioFileId audioFileId)
{
AudioFileInfo res;
auto transaction{ session.createReadTransaction() };
if (const db::TrackId * trackId{ std::get_if<db::TrackId>(&audioFileId) })
{
const db::Track::pointer track{ db::Track::find(session, *trackId) };
if (!track)
throw RequestedDataNotFoundError{};
res.path = track->getAbsoluteFilePath();
if (track->getContainer() && track->getCodec())
{
res.audioProperties.container = *track->getContainer();
res.audioProperties.codec = *track->getCodec();
res.audioProperties.duration = track->getDuration();
res.audioProperties.bitrate = track->getBitrate();
res.audioProperties.channelCount = track->getChannelCount();
res.audioProperties.sampleRate = track->getSampleRate();
res.audioProperties.bitsPerSample = track->getBitsPerSample();
}
else
{
res.audioProperties = getAudioProperties(res.path);
}
}
else if (const db::PodcastEpisodeId * episodeId{ std::get_if<db::PodcastEpisodeId>(&audioFileId) })
{
const db::PodcastEpisode::pointer episode{ db::PodcastEpisode::find(session, *episodeId) };
if (!episode)
throw RequestedDataNotFoundError{};
std::filesystem::path podcastCachePath{ core::Service<podcast::IPodcastService>::get()->getCachePath() };
res.path = podcastCachePath / episode->getAudioRelativeFilePath();
if (episode->getContainer() && episode->getCodec())
{
res.audioProperties.container = *episode->getContainer();
res.audioProperties.codec = *episode->getCodec();
res.audioProperties.duration = episode->getDuration();
res.audioProperties.bitrate = episode->getBitrate();
res.audioProperties.channelCount = episode->getChannelCount();
res.audioProperties.sampleRate = episode->getSampleRate();
res.audioProperties.bitsPerSample = episode->getBitsPerSample();
}
else
{
res.audioProperties = getAudioProperties(res.path);
}
}
return res;
}
} // namespace lms::api::subsonic
@@ -0,0 +1,42 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <filesystem>
#include "audio/AudioProperties.hpp"
#include "AudioFileId.hpp"
namespace lms::db
{
class Session;
}
namespace lms::api::subsonic
{
struct AudioFileInfo
{
std::filesystem::path path;
audio::AudioProperties audioProperties;
};
AudioFileInfo getAudioFileInfo(db::Session& session, AudioFileId audioFileId); // throw RequestedDataNotFoundError on failure
} // namespace lms::api::subsonic
@@ -0,0 +1,595 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "TranscodeDecision.hpp"
#include <array>
#include "core/ILogger.hpp"
#include "core/String.hpp"
#include "audio/TranscodeTypes.hpp"
#include "SubsonicResponse.hpp"
#include "payloads/ClientInfo.hpp"
namespace lms::api::subsonic::details
{
namespace
{
constexpr std::array supportedTranscodeOutputFormats{
audio::TranscodeOutputFormat{ .container = core::media::ContainerType::MPEG, .codec = core::media::CodecType::MP3 },
audio::TranscodeOutputFormat{ .container = core::media::ContainerType::Ogg, .codec = core::media::CodecType::Vorbis },
audio::TranscodeOutputFormat{ .container = core::media::ContainerType::Ogg, .codec = core::media::CodecType::Opus },
audio::TranscodeOutputFormat{ .container = core::media::ContainerType::FLAC, .codec = core::media::CodecType::FLAC },
};
bool isMatchingContainerName(core::media::ContainerType container, std::string_view containerStr)
{
using namespace std::literals; // for "..."sv
constexpr std::array aiffNames{ "aif"sv, "aiff"sv };
constexpr std::array apeNames{ "ape"sv };
constexpr std::array asfNames{ "asf"sv, "wma"sv };
constexpr std::array dsfNames{ "dsf"sv };
constexpr std::array mpcNames{ "mpc"sv, "mpp"sv, "mp"sv };
constexpr std::array mpegNames{ "mp3"sv, "mp2"sv, "mpeg"sv };
constexpr std::array oggNames{ "ogg"sv, "oga"sv };
constexpr std::array flacNames{ "flac"sv };
constexpr std::array mp4Names{ "aac"sv, "adts"sv, "m4a"sv, "mp4"sv, "m4b"sv, "m4p"sv };
constexpr std::array shortenNames{ "shn"sv };
constexpr std::array trueAudioNames{ "tta"sv };
constexpr std::array wavNames{ "wav"sv };
constexpr std::array wavPackNames{ "wv"sv };
std::span<const std::string_view> containerNames;
switch (container)
{
case core::media::ContainerType::AIFF:
containerNames = aiffNames;
break;
case core::media::ContainerType::APE:
containerNames = apeNames;
break;
case core::media::ContainerType::ASF:
containerNames = asfNames;
break;
case core::media::ContainerType::DSF:
containerNames = dsfNames;
break;
case core::media::ContainerType::MPC:
containerNames = mpcNames;
break;
case core::media::ContainerType::MPEG:
containerNames = mpegNames;
break;
case core::media::ContainerType::Ogg:
containerNames = oggNames;
break;
case core::media::ContainerType::FLAC:
containerNames = flacNames;
break;
case core::media::ContainerType::MP4:
containerNames = mp4Names;
break;
case core::media::ContainerType::Shorten:
containerNames = shortenNames;
break;
case core::media::ContainerType::TrueAudio:
containerNames = trueAudioNames;
break;
case core::media::ContainerType::WAV:
containerNames = wavNames;
break;
case core::media::ContainerType::WavPack:
containerNames = wavPackNames;
break;
}
return std::any_of(std::cbegin(containerNames), std::cend(containerNames), [&](std::string_view containerName) { return core::stringUtils::stringCaseInsensitiveEqual(containerName, containerStr); });
}
bool isMatchingCodecName(core::media::CodecType codec, std::string_view codecStr)
{
using namespace std::literals; // for "..."sv
constexpr std::array aacCodecNames{ "aac"sv, "adts"sv };
constexpr std::array ac3CodecNames{ "ac3"sv, "ac-3"sv };
constexpr std::array alacCodecNames{ "alac"sv };
constexpr std::array apeCodecNames{ "ape"sv };
constexpr std::array dsdCodecNames{ "dsd"sv };
constexpr std::array flacCodecNames{ "flac"sv };
constexpr std::array eac3CodecNames{ "eac3"sv, "e-ac3"sv, "e-ac-3"sv, "eac-3"sv };
constexpr std::array mp3CodecNames{ "mp3"sv };
constexpr std::array mp4alsCodecNames{ "mp4als"sv, "als"sv };
constexpr std::array mpc7CodecNames{ "mpc7"sv, "musepack7"sv };
constexpr std::array mpc8CodecNames{ "mpc8"sv, "musepack8"sv };
constexpr std::array opusCodecNames{ "opus"sv };
constexpr std::array pcmCodecNames{ "pcm"sv };
constexpr std::array shortenCodecNames{ "shn"sv, "shorten"sv };
constexpr std::array trueAudioCodecNames{ "tta"sv };
constexpr std::array vorbisCodecNames{ "vorbis"sv };
constexpr std::array wavPackCodecNames{ "wv"sv };
constexpr std::array wma1CodecNames{ "wma1"sv, "wmav1"sv };
constexpr std::array wma2CodecNames{ "wma2"sv, "wmav2"sv };
constexpr std::array wma9LosslessCodecNames{ "wmalossless"sv, "wma9lossless"sv };
constexpr std::array wma9ProCodecNames{ "wmapro"sv, "wma9pro"sv };
std::span<const std::string_view> codecNames;
switch (codec)
{
case core::media::CodecType::AAC:
codecNames = aacCodecNames;
break;
case core::media::CodecType::AC3:
codecNames = ac3CodecNames;
break;
case core::media::CodecType::ALAC:
codecNames = alacCodecNames;
break;
case core::media::CodecType::APE:
codecNames = apeCodecNames;
break;
case core::media::CodecType::DSD:
codecNames = dsdCodecNames;
break;
case core::media::CodecType::EAC3:
codecNames = eac3CodecNames;
break;
case core::media::CodecType::FLAC:
codecNames = flacCodecNames;
break;
case core::media::CodecType::MP3:
codecNames = mp3CodecNames;
break;
case core::media::CodecType::MP4ALS:
codecNames = mp4alsCodecNames;
break;
case core::media::CodecType::MPC7:
codecNames = mpc7CodecNames;
break;
case core::media::CodecType::MPC8:
codecNames = mpc8CodecNames;
break;
case core::media::CodecType::Opus:
codecNames = opusCodecNames;
break;
case core::media::CodecType::PCM:
codecNames = pcmCodecNames;
break;
case core::media::CodecType::Shorten:
codecNames = shortenCodecNames;
break;
case core::media::CodecType::TrueAudio:
codecNames = trueAudioCodecNames;
break;
case core::media::CodecType::Vorbis:
codecNames = vorbisCodecNames;
break;
case core::media::CodecType::WavPack:
codecNames = wavPackCodecNames;
break;
case core::media::CodecType::WMA1:
codecNames = wma1CodecNames;
break;
case core::media::CodecType::WMA2:
codecNames = wma2CodecNames;
break;
case core::media::CodecType::WMA9Lossless:
codecNames = wma9LosslessCodecNames;
break;
case core::media::CodecType::WMA9Pro:
codecNames = wma9ProCodecNames;
break;
}
return std::any_of(std::cbegin(codecNames), std::cend(codecNames), [&](std::string_view codecName) { return core::stringUtils::stringCaseInsensitiveEqual(codecName, codecStr); });
}
struct AdjustResult
{
enum class Type
{
None,
Adjusted,
CannotAdjust,
};
Type type{ Type::None };
std::optional<unsigned> newValue;
};
AdjustResult adjustUsingEqualsLimitation(std::span<const std::string> values, unsigned originalValue)
{
if (values.size() == 1)
{
const auto value{ core::stringUtils::readAs<unsigned>(values.front()) };
assert(value);
if (originalValue == *value)
return AdjustResult{ .type = AdjustResult::Type::None, .newValue = std::nullopt };
}
else
{
// Get the closest allowed value *below* originalValue (we don't want to upscale)
// Not sure if this worth doing this?
std::optional<unsigned> closestValue;
for (std::string_view valueStr : values)
{
const auto value{ core::stringUtils::readAs<unsigned>(valueStr) };
assert(value);
if (*value == originalValue)
return AdjustResult{ .type = AdjustResult::Type::None, .newValue = std::nullopt };
if (*value < originalValue && (!closestValue || *value > *closestValue))
closestValue = *value;
}
if (closestValue)
return AdjustResult{ .type = AdjustResult::Type::Adjusted, .newValue = *closestValue };
}
// Don't really know what to do here
return AdjustResult{ .type = AdjustResult::Type::CannotAdjust, .newValue = std::nullopt };
}
AdjustResult adjustUsingNotEqualsLimitation(std::span<const std::string> values, unsigned originalValue)
{
if (std::none_of(std::cbegin(values), std::cend(values), [&](std::string_view valueStr) {
const auto value{ core::stringUtils::readAs<unsigned>(valueStr) };
assert(value);
return *value == originalValue;
}))
{
return AdjustResult{ .type = AdjustResult::Type::None, .newValue = std::nullopt };
}
// don't really know what to do here
return AdjustResult{ .type = AdjustResult::Type::CannotAdjust, .newValue = std::nullopt };
}
AdjustResult adjustUsingLessThanEqualLimitation(std::span<const std::string> values, unsigned originalValue)
{
// Take only the first value into account
const auto value{ core::stringUtils::readAs<unsigned>(values.front()) };
assert(value);
if (originalValue <= *value)
return AdjustResult{ .type = AdjustResult::Type::None, .newValue = std::nullopt };
return AdjustResult{ .type = AdjustResult::Type::Adjusted, .newValue = *value };
}
AdjustResult adjustUsingGreaterThanEqualLimitation(std::span<const std::string> values, unsigned originalValue)
{
// Take only the first value into account
const auto value{ core::stringUtils::readAs<unsigned>(values.front()) };
assert(value);
if (originalValue >= *value)
return AdjustResult{ .type = AdjustResult::Type::None, .newValue = std::nullopt };
// We don't want to use a higher value than the original one (we don't want to upscale)
return AdjustResult{ .type = AdjustResult::Type::CannotAdjust, .newValue = *value };
}
AdjustResult adjustUsingLimitation(Limitation::ComparisonOperator comparisonOp, std::span<const std::string> values, unsigned originalValue)
{
assert(values.size() >= 1);
switch (comparisonOp)
{
case Limitation::ComparisonOperator::Equals:
return adjustUsingEqualsLimitation(values, originalValue);
case Limitation::ComparisonOperator::NotEquals:
return adjustUsingNotEqualsLimitation(values, originalValue);
case Limitation::ComparisonOperator::LessThanEqual:
return adjustUsingLessThanEqualLimitation(values, originalValue);
case Limitation::ComparisonOperator::GreaterThanEqual:
return adjustUsingGreaterThanEqualLimitation(values, originalValue);
}
throw InternalErrorGenericError{ "Unhandled limitation comparison operator" };
}
bool isStreamCompatibleWithLimitation(const audio::AudioProperties& source, const Limitation& limitation)
{
if (!limitation.required)
return true;
// TODO handle strings
std::optional<unsigned> valueToCheck{};
switch (limitation.name)
{
case Limitation::Type::AudioBitrate:
valueToCheck = static_cast<unsigned>(source.bitrate);
break;
case Limitation::Type::AudioChannels:
valueToCheck = static_cast<unsigned>(source.channelCount);
break;
break;
case Limitation::Type::AudioSamplerate:
valueToCheck = static_cast<unsigned>(source.sampleRate);
break;
case Limitation::Type::AudioProfile:
// TODO;
break;
case Limitation::Type::AudioBitdepth:
if (source.bitsPerSample)
valueToCheck = static_cast<unsigned>(*source.bitsPerSample);
break;
}
if (!valueToCheck)
return false;
const AdjustResult adjustResult{ adjustUsingLimitation(limitation.comparison, limitation.values, *valueToCheck) };
return adjustResult.type == AdjustResult::Type::None;
}
const CodecProfile* getAudioCodecProfile(std::span<const CodecProfile> codecProfiles, core::media::CodecType codec)
{
for (const CodecProfile& profile : codecProfiles)
{
if (profile.type != "AudioCodec")
continue;
if (isMatchingCodecName(codec, profile.name))
return &profile;
}
return nullptr;
}
std::optional<TranscodeReason> needsTranscode(const DirectPlayProfile& profile, std::span<const CodecProfile> codecProfiles, const audio::AudioProperties& source)
{
if (!profile.containers.empty() && std::none_of(std::cbegin(profile.containers), std::cend(profile.containers), [&](const std::string& container) { return isMatchingContainerName(source.container, container); }))
return TranscodeReason::ContainerNotSupported;
if (!profile.audioCodecs.empty() && std::none_of(std::cbegin(profile.audioCodecs), std::cend(profile.audioCodecs), [&](const std::string& audioCodec) { return isMatchingCodecName(source.codec, audioCodec); }))
return TranscodeReason::AudioCodecNotSupported;
if (!profile.protocols.empty() && std::find(std::cbegin(profile.protocols), std::cend(profile.protocols), "http") == std::cend(profile.protocols))
return TranscodeReason::ProtocolNotSupported;
if (profile.maxAudioChannels && source.channelCount > *profile.maxAudioChannels)
return TranscodeReason::AudioChannelsNotSupported;
// check potential codec profiles limitations
if (const CodecProfile * codecProfile{ getAudioCodecProfile(codecProfiles, source.codec) })
{
for (const Limitation& limitation : codecProfile->limitations)
{
if (!isStreamCompatibleWithLimitation(source, limitation))
{
switch (limitation.name)
{
case Limitation::Type::AudioBitrate:
return TranscodeReason::AudioBitrateNotSupported;
case Limitation::Type::AudioChannels:
return TranscodeReason::AudioChannelsNotSupported;
case Limitation::Type::AudioSamplerate:
return TranscodeReason::AudioSampleRateNotSupported;
case Limitation::Type::AudioProfile:
return TranscodeReason::AudioCodecNotSupported;
case Limitation::Type::AudioBitdepth:
return TranscodeReason::AudioBitdepthNotSupported;
}
}
}
}
return std::nullopt;
}
AdjustResult applyLimitation(const audio::AudioProperties& source, const Limitation& limitation, StreamDetails& transcodedStream)
{
switch (limitation.name)
{
case Limitation::Type::AudioChannels:
{
// transcodedStream.audioChannels may already be set by the transcoding profile maxAudioChannels
const AdjustResult adjustResult{ adjustUsingLimitation(limitation.comparison, limitation.values, transcodedStream.audioChannels ? *transcodedStream.audioChannels : source.channelCount) };
if (adjustResult.type == AdjustResult::Type::Adjusted)
transcodedStream.audioChannels = *adjustResult.newValue;
return adjustResult;
}
case Limitation::Type::AudioBitrate:
{
const AdjustResult adjustResult{ adjustUsingLimitation(limitation.comparison, limitation.values, transcodedStream.audioBitrate ? *transcodedStream.audioBitrate : source.bitrate) };
if (adjustResult.type == AdjustResult::Type::Adjusted)
transcodedStream.audioBitrate = *adjustResult.newValue;
return adjustResult;
}
case Limitation::Type::AudioProfile:
{
// TODO
AdjustResult res{ .type = AdjustResult::Type::None, .newValue = std::nullopt };
return res;
}
case Limitation::Type::AudioSamplerate:
{
const AdjustResult adjustResult{ adjustUsingLimitation(limitation.comparison, limitation.values, source.sampleRate) };
if (adjustResult.type == AdjustResult::Type::Adjusted)
transcodedStream.audioSamplerate = *adjustResult.newValue;
return adjustResult;
}
case Limitation::Type::AudioBitdepth:
{
if (source.bitsPerSample)
{
const AdjustResult adjustResult{ adjustUsingLimitation(limitation.comparison, limitation.values, *source.bitsPerSample) };
if (adjustResult.type == AdjustResult::Type::Adjusted)
transcodedStream.audioBitdepth = *adjustResult.newValue;
return adjustResult;
}
}
}
return AdjustResult{ .type = AdjustResult::Type::CannotAdjust, .newValue = std::nullopt };
}
std::optional<StreamDetails> computeTranscodedStream(std::optional<std::size_t> maxAudioBitrate, const TranscodingProfile& profile, std::span<const CodecProfile> codecProfiles, const audio::AudioProperties& source)
{
if (profile.protocol != "http")
return std::nullopt;
const audio::TranscodeOutputFormat* transcodeFormat{ selectTranscodeOutputFormat(profile.container, profile.audioCodec) };
if (!transcodeFormat)
return std::nullopt;
StreamDetails transcodedStream;
transcodedStream.protocol = "http";
transcodedStream.container = profile.container; // put back what was requested instead of our internal names
transcodedStream.codec = profile.audioCodec; // put back what was requested instead of our internal names
if (core::media::isCodecLossless(source.codec))
{
if (!core::media::isCodecLossless(transcodeFormat->codec))
{
// If coming from lossless source, maximize the bitrate if going to a non lossless source
// otherwise, pick a good enough value as we don't want to keep the original bitrate which does not make sense for lossy codecs
if (maxAudioBitrate)
transcodedStream.audioBitrate = maxAudioBitrate;
else
transcodedStream.audioBitrate = 256'000; // TODO, only if no bitrate limitation found? take channel count into account?
}
else
{
// If going to a lossless codec, make sure we can respect the original bitrate
// technically, we could have a chance to respect the bitrate if we apply limitations, but that's not easy to have a strong garantee
if (maxAudioBitrate && source.bitrate > *maxAudioBitrate)
return std::nullopt;
}
}
else
{
// source is lossy
if (core::media::isCodecLossless(transcodeFormat->codec))
return std::nullopt; // not compatible with lossless codecs
// let's pick the same bitrate as the lossy source
transcodedStream.audioBitrate = source.bitrate;
}
if (maxAudioBitrate && source.bitrate > *maxAudioBitrate)
transcodedStream.audioBitrate = *maxAudioBitrate;
if (profile.maxAudioChannels && source.channelCount > *profile.maxAudioChannels)
transcodedStream.audioChannels = *profile.maxAudioChannels;
if (const CodecProfile * codecProfile{ getAudioCodecProfile(codecProfiles, transcodeFormat->codec) })
{
for (const Limitation& limitation : codecProfile->limitations)
{
const AdjustResult result{ applyLimitation(source, limitation, transcodedStream) };
if (limitation.name == Limitation::Type::AudioBitrate && core::media::isCodecLossless(transcodeFormat->codec) && result.type == AdjustResult::Type::Adjusted)
return std::nullopt; // not compatible with lossless codecs
if (result.type == AdjustResult::Type::CannotAdjust)
return std::nullopt;
}
}
return transcodedStream;
}
bool canDirectPlay(const ClientInfo& clientInfo, const audio::AudioProperties& source, std::vector<TranscodeReason>& transcodeReasons)
{
// Check global constraints
if (clientInfo.maxAudioBitrate && *clientInfo.maxAudioBitrate < source.bitrate)
{
transcodeReasons.push_back(TranscodeReason::AudioBitrateNotSupported);
return false;
}
// Check direct play profiles
for (const DirectPlayProfile& profile : clientInfo.directPlayProfiles)
{
const std::optional<TranscodeReason> transcodeReason{ needsTranscode(profile, clientInfo.codecProfiles, source) };
if (!transcodeReason)
return true;
transcodeReasons.push_back(*transcodeReason);
}
return false;
}
} // namespace
core::LiteralString transcodeReasonToString(TranscodeReason reason)
{
switch (reason)
{
case TranscodeReason::AudioCodecNotSupported:
return "audio codec not supported";
case TranscodeReason::AudioBitrateNotSupported:
return "audio bitrate not supported";
case TranscodeReason::AudioChannelsNotSupported:
return "audio channels not supported";
case TranscodeReason::AudioSampleRateNotSupported:
return "audio samplerate not supported";
case TranscodeReason::AudioBitdepthNotSupported:
return "audio bitdepth not supported";
case TranscodeReason::ContainerNotSupported:
return "container not supported";
case TranscodeReason::ProtocolNotSupported:
return "protocol not supported";
}
return "unknown";
}
const audio::TranscodeOutputFormat* selectTranscodeOutputFormat(std::string_view containerName, std::string_view codecName)
{
// Find a supported output format
const auto it{ std::find_if(std::cbegin(supportedTranscodeOutputFormats), std::cend(supportedTranscodeOutputFormats), [&](const audio::TranscodeOutputFormat& format) {
return isMatchingCodecName(format.codec, codecName) && isMatchingContainerName(format.container, containerName);
}) };
if (it == std::cend(supportedTranscodeOutputFormats))
return nullptr;
return &(*it);
}
TranscodeDecisionResult computeTranscodeDecision(const ClientInfo& clientInfo, const audio::AudioProperties& source)
{
std::vector<TranscodeReason> transcodeReasons;
if (canDirectPlay(clientInfo, source, transcodeReasons))
return DirectPlayResult{};
LMS_LOG(API_SUBSONIC, DEBUG, "Direct play not possible: no compatible direct play profile found");
// Check transcoding profiles, we have to select the first one we can handle, order is important
for (const TranscodingProfile& profile : clientInfo.transcodingProfiles)
{
std::optional<StreamDetails> targetStream{ computeTranscodedStream(clientInfo.maxTranscodingAudioBitrate, profile, clientInfo.codecProfiles, source) };
if (targetStream)
return TranscodeResult{ .reasons = transcodeReasons, .targetStreamInfo = *targetStream };
}
return FailureResult{ "No compatible direct play or transcoding profile found" };
}
} // namespace lms::api::subsonic::details
@@ -0,0 +1,76 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <string_view>
#include <vector>
#include "core/LiteralString.hpp"
#include "audio/AudioProperties.hpp"
#include "audio/TranscodeTypes.hpp"
#include "payloads/StreamDetails.hpp"
namespace lms::api::subsonic
{
struct ClientInfo;
namespace details
{
enum class TranscodeReason
{
AudioCodecNotSupported,
AudioBitrateNotSupported,
AudioChannelsNotSupported,
AudioSampleRateNotSupported,
AudioBitdepthNotSupported,
ContainerNotSupported,
ProtocolNotSupported
};
core::LiteralString transcodeReasonToString(TranscodeReason reason);
struct DirectPlayResult
{
bool operator==(const DirectPlayResult&) const = default;
};
struct TranscodeResult
{
std::vector<TranscodeReason> reasons;
StreamDetails targetStreamInfo;
bool operator==(const TranscodeResult&) const = default;
};
struct FailureResult
{
std::string reason;
bool operator==(const FailureResult&) const = default;
};
using TranscodeDecisionResult = std::variant<DirectPlayResult, TranscodeResult, FailureResult>;
TranscodeDecisionResult computeTranscodeDecision(const ClientInfo& clientInfo, const audio::AudioProperties& source);
const audio::TranscodeOutputFormat* selectTranscodeOutputFormat(std::string_view containerName, std::string_view codecName);
} // namespace details
} // namespace lms::api::subsonic
@@ -0,0 +1,92 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "TranscodeDecisionTracker.hpp"
#include <mutex>
#include <unordered_map>
#include "core/Random.hpp"
namespace lms::api::subsonic
{
namespace
{
class TranscodeDecisionTracker : public ITranscodeDecisionTracker
{
public:
core::UUID add(AudioFileId audioFileId, const StreamDetails& targetStreamInfo) override
{
const core::UUID uuid{ core::UUID::generate() };
const Clock::time_point now{ Clock::now() };
auto entry{ std::make_shared<Entry>(now, audioFileId, targetStreamInfo) };
{
std::scoped_lock lock{ mutex };
purgeOutdatedEntries(now);
entries.emplace(uuid, entry);
}
return uuid;
}
std::shared_ptr<Entry> get(const core::UUID& uuid) override
{
const Clock::time_point now{ Clock::now() };
std::shared_ptr<Entry> res;
std::scoped_lock lock{ mutex };
auto it{ entries.find(uuid) };
if (it != entries.end())
{
if (now > it->second->addedTimePoint + maxEntryDuration)
entries.erase(it);
else
res = it->second;
}
return res;
}
private:
void purgeOutdatedEntries(Clock::time_point now)
{
std::erase_if(entries, [&](const auto& entry) { return now > entry.second->addedTimePoint + maxEntryDuration; });
while (entries.size() > maxEntryCount)
entries.erase(core::random::pickRandom(entries)); // TODO kill oldest one?
}
std::mutex mutex;
std::unordered_map<core::UUID, std::shared_ptr<Entry>> entries;
static constexpr std::size_t maxEntryCount{ 1'000 };
static constexpr std::chrono::hours maxEntryDuration{ 12 };
};
} // namespace
ITranscodeDecisionTracker& getTranscodeDecisionTracker()
{
static TranscodeDecisionTracker tracker;
return tracker;
}
} // namespace lms::api::subsonic
@@ -0,0 +1,53 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <chrono>
#include <memory>
#include "core/UUID.hpp"
#include "payloads/StreamDetails.hpp"
#include "AudioFileId.hpp"
namespace lms::api::subsonic
{
// Keeps track of decisions using UUIDs
class ITranscodeDecisionTracker
{
public:
virtual ~ITranscodeDecisionTracker() = default;
using Clock = std::chrono::steady_clock;
struct Entry
{
Clock::time_point addedTimePoint;
AudioFileId audioFileId;
StreamDetails targetStreamInfo;
};
virtual core::UUID add(AudioFileId audioFileId, const StreamDetails& targetStreamInfo) = 0;
virtual std::shared_ptr<Entry> get(const core::UUID& uuid) = 0;
};
ITranscodeDecisionTracker& getTranscodeDecisionTracker();
} // namespace lms::api::subsonic