/* * Copyright (C) 2020 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 "MediaRetrieval.hpp" #include #include #include "core/FileResourceHandlerCreator.hpp" #include "core/ILogger.hpp" #include "core/IResourceHandler.hpp" #include "core/String.hpp" #include "audio/AudioTypes.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" #include "database/objects/User.hpp" #include "services/artwork/IArtworkService.hpp" #include "services/podcast/IPodcastService.hpp" #include "services/transcoding/ITranscodeService.hpp" #include "CoverArtId.hpp" #include "ParameterParsing.hpp" #include "RequestContext.hpp" #include "SubsonicId.hpp" #include "SubsonicResponse.hpp" #include "responses/Lyrics.hpp" namespace lms::api::subsonic { namespace { std::optional subsonicStreamFormatToTranscodingOutputFormat(std::string_view format) { for (const auto& [str, avFormat] : std::initializer_list>{ { "mp3", audio::OutputFormat::MP3 }, { "opus", audio::OutputFormat::OGG_OPUS }, { "vorbis", audio::OutputFormat::OGG_VORBIS }, }) { if (core::stringUtils::stringCaseInsensitiveEqual(str, format)) return avFormat; } return std::nullopt; } audio::OutputFormat userTranscodeFormatToTranscodingFormat(db::TranscodingOutputFormat format) { switch (format) { case db::TranscodingOutputFormat::MP3: return audio::OutputFormat::MP3; case db::TranscodingOutputFormat::OGG_OPUS: return audio::OutputFormat::OGG_OPUS; case db::TranscodingOutputFormat::MATROSKA_OPUS: return audio::OutputFormat::MATROSKA_OPUS; case db::TranscodingOutputFormat::OGG_VORBIS: return audio::OutputFormat::OGG_VORBIS; case db::TranscodingOutputFormat::WEBM_VORBIS: return audio::OutputFormat::WEBM_VORBIS; } return audio::OutputFormat::OGG_OPUS; } bool isCodecCompatibleWithOutputFormat(audio::CodecType codec, audio::OutputFormat outputFormat) { switch (outputFormat) { case audio::OutputFormat::MP3: return codec == audio::CodecType::MP3; case audio::OutputFormat::OGG_OPUS: case audio::OutputFormat::MATROSKA_OPUS: return codec == audio::CodecType::Opus; case audio::OutputFormat::OGG_VORBIS: case audio::OutputFormat::WEBM_VORBIS: return codec == audio::CodecType::Vorbis; } return true; } struct StreamParameters { std::filesystem::path filePath; std::string fileMimeType; // set if known std::optional transcodeParameters; bool estimateContentLength{}; }; bool isOutputFormatCompatible(const std::filesystem::path& trackPath, audio::OutputFormat outputFormat) { // TODO: put this information in db during scan // It is in base only for tracks, not yet for podcasts try { const auto parser{ audio::createAudioFileInfoParser(audio::AudioFileInfoParserBackend::FFmpeg) }; audio::AudioFileInfoParseOptions parseOptions; parseOptions.audioPropertiesReadStyle = audio::AudioFileInfoParseOptions::AudioPropertiesReadStyle::Fast; // only coded needed parseOptions.readImages = false; parseOptions.readTags = false; const auto audioFile{ parser->parse(trackPath, parseOptions) }; if (!audioFile->getAudioProperties()) throw RequestedDataNotFoundError{}; return isCodecCompatibleWithOutputFormat(audioFile->getAudioProperties()->codec, outputFormat); } catch (const audio::Exception& e) { throw RequestedDataNotFoundError{}; } } using AudioFileId = std::variant; struct AudioFileInfo { std::filesystem::path path; std::chrono::milliseconds duration{}; std::size_t bitrate{}; std::string mimeType; // set if known }; 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(); res.duration = track->getDuration(); res.bitrate = track->getBitrate(); } 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(); res.duration = episode->getDuration(); res.bitrate = episode->getEnclosureLength() / std::chrono::duration_cast(episode->getDuration()).count() * 8; res.mimeType = episode->getEnclosureContentType(); } return res; } StreamParameters getStreamParameters(RequestContext& context) { // Mandatory params const auto trackId{ getParameterAs(context.getParameters(), "id") }; const auto podcastEpisodeId{ getParameterAs(context.getParameters(), "id") }; if (!trackId && !podcastEpisodeId) throw RequiredParameterMissingError{ "id" }; const AudioFileId audioId{ trackId ? AudioFileId{ *trackId } : AudioFileId{ *podcastEpisodeId } }; // Optional params std::size_t maxBitRate{ getParameterAs(context.getParameters(), "maxBitRate").value_or(0) * 1000 }; // "If set to zero, no limit is imposed", given in kpbs const std::string format{ getParameterAs(context.getParameters(), "format").value_or("") }; std::size_t timeOffset{ getParameterAs(context.getParameters(), "timeOffset").value_or(0) }; bool estimateContentLength{ getParameterAs(context.getParameters(), "estimateContentLength").value_or(false) }; const AudioFileInfo audioFileInfo{ getAudioFileInfo(context.getDbSession(), audioId) }; StreamParameters parameters; parameters.filePath = audioFileInfo.path; parameters.fileMimeType = audioFileInfo.mimeType; parameters.estimateContentLength = estimateContentLength; if (format == "raw") // raw => no transcoding return parameters; // TODO: what if offset is not 0? std::optional requestedFormat{ subsonicStreamFormatToTranscodingOutputFormat(format) }; if (!requestedFormat) { if (context.getUser()->getSubsonicEnableTranscodingByDefault()) requestedFormat = userTranscodeFormatToTranscodingFormat(context.getUser()->getSubsonicDefaultTranscodingOutputFormat()); } if (!requestedFormat && (maxBitRate == 0 || audioFileInfo.bitrate <= maxBitRate)) { LMS_LOG(API_SUBSONIC, DEBUG, "File's bitrate is compatible with parameters => no transcoding"); return parameters; // no transcoding needed } // scan the file to check if its format is compatible with the actual requested format // same codec => apply max bitrate // otherwise => apply default bitrate (because we can't really compare bitrates between formats) + max bitrate) std::size_t bitrate{}; if (requestedFormat && isOutputFormatCompatible(audioFileInfo.path, *requestedFormat)) { if (maxBitRate == 0 || audioFileInfo.bitrate <= maxBitRate) { LMS_LOG(API_SUBSONIC, DEBUG, "File's bitrate and format are compatible with parameters => no transcoding"); return parameters; // no transcoding needed } bitrate = maxBitRate; } // Need to transcode here if (!requestedFormat) requestedFormat = userTranscodeFormatToTranscodingFormat(context.getUser()->getSubsonicDefaultTranscodingOutputFormat()); if (!bitrate) bitrate = context.getUser()->getSubsonicDefaultTranscodingOutputBitrate(); if (maxBitRate) bitrate = std::min(bitrate, maxBitRate); audio::TranscodeParameters& transcodeParameters{ parameters.transcodeParameters.emplace() }; transcodeParameters.inputParameters.filePath = audioFileInfo.path; transcodeParameters.inputParameters.duration = audioFileInfo.duration; transcodeParameters.inputParameters.offset = std::chrono::seconds{ timeOffset }; ; transcodeParameters.outputParameters.bitrate = bitrate; transcodeParameters.outputParameters.format = *requestedFormat; transcodeParameters.outputParameters.stripMetadata = false; // We want clients to use metadata (offline use, replay gain, etc.) return parameters; } } // namespace Response handleGetLyrics(RequestContext& context) { std::string artistName{ getParameterAs(context.getParameters(), "artist").value_or("") }; std::string titleName{ getParameterAs(context.getParameters(), "title").value_or("") }; Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; // best effort search, as this API is really limited auto transaction{ context.getDbSession().createReadTransaction() }; db::Track::FindParameters params; params.setName(titleName); params.setArtistName(artistName); params.setRange(db::Range{ .offset = 0, .size = 2 }); // Choice: we return nothing if there are too many results const auto tracks{ db::Track::findIds(context.getDbSession(), params) }; if (tracks.results.size() == 1) { // Choice: we return only the first lyrics if the track has many lyrics db::TrackLyrics::FindParameters lyricsParams; lyricsParams.setTrack(tracks.results[0]); lyricsParams.setSortMethod(db::TrackLyricsSortMethod::ExternalFirst); lyricsParams.setRange(db::Range{ 0, 1 }); db::TrackLyrics::find(context.getDbSession(), lyricsParams, [&](const db::TrackLyrics::pointer& lyrics) { response.addNode("lyrics", createLyricsNode(context, lyrics)); }); } return response; } Response handleGetLyricsBySongId(RequestContext& context) { // mandatory params db::TrackId id{ getMandatoryParameterAs(context.getParameters(), "id") }; Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; Response::Node& lyricsList{ response.createNode("lyricsList") }; lyricsList.createEmptyArrayChild("structuredLyrics"); auto transaction{ context.getDbSession().createReadTransaction() }; const db::Track::pointer track{ db::Track::find(context.getDbSession(), id) }; if (track) { db::TrackLyrics::FindParameters params; params.setTrack(track->getId()); params.setExternal(true); // First try to only report external lyrics as they are often duplicate of embedded lyrics and support more features bool hasExternalLyrics{}; db::TrackLyrics::find(context.getDbSession(), params, [&](const db::TrackLyrics::pointer& lyrics) { lyricsList.addArrayChild("structuredLyrics", createStructuredLyricsNode(context, lyrics)); hasExternalLyrics = true; }); if (!hasExternalLyrics) { params.setExternal(false); db::TrackLyrics::find(context.getDbSession(), params, [&](const db::TrackLyrics::pointer& lyrics) { lyricsList.addArrayChild("structuredLyrics", createStructuredLyricsNode(context, lyrics)); }); } } return response; } void handleDownload(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response) { std::shared_ptr resourceHandler; Wt::Http::ResponseContinuation* continuation{ request.continuation() }; if (!continuation) { // Mandatory params db::TrackId id{ getMandatoryParameterAs(context.getParameters(), "id") }; std::filesystem::path trackPath; { auto transaction{ context.getDbSession().createReadTransaction() }; auto track{ db::Track::find(context.getDbSession(), id) }; if (!track) throw RequestedDataNotFoundError{}; trackPath = track->getAbsoluteFilePath(); } resourceHandler = core::createFileResourceHandler(trackPath); } else { resourceHandler = Wt::cpp17::any_cast>(continuation->data()); } continuation = resourceHandler->processRequest(request, response); if (continuation) continuation->setData(resourceHandler); } void handleStream(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response) { std::shared_ptr resourceHandler; try { 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, streamParameters.fileMimeType); } else { resourceHandler = Wt::cpp17::any_cast>(continuation->data()); } continuation = resourceHandler->processRequest(request, response); if (continuation) continuation->setData(resourceHandler); } catch (const audio::Exception& e) { response.setStatus(404); // report not found if something wrong happened LMS_LOG(API_SUBSONIC, ERROR, "Caught Av exception: " << e.what()); } } void handleGetCoverArt(RequestContext& context, const Wt::Http::Request& /*request*/, Wt::Http::Response& response) { // Mandatory params const CoverArtId coverArtId{ getMandatoryParameterAs(context.getParameters(), "id") }; std::optional size{ getParameterAs(context.getParameters(), "size") }; if (size) *size = std::clamp(*size, std::size_t{ 32 }, std::size_t{ 2048 }); std::shared_ptr image{ core::Service::get()->getImage(coverArtId.id, size) }; if (!image) { response.setStatus(404); return; } response.out().write(reinterpret_cast(image->getData().data()), image->getData().size()); response.setMimeType(std::string{ image->getMimeType() }); } } // namespace lms::api::subsonic