diff --git a/CMakeLists.txt b/CMakeLists.txt index 29844248..bc0ce574 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.12) -project(lms VERSION 3.72.1) +project(lms VERSION 3.73.0) set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_SOURCE_DIR}/cmake/modules/) diff --git a/Dockerfile-release b/Dockerfile-release index 1bf40d82..585c76db 100644 --- a/Dockerfile-release +++ b/Dockerfile-release @@ -67,11 +67,12 @@ RUN \ --enable-libopenjpeg \ --enable-libopus \ --enable-libvorbis \ + --enable-zlib \ --disable-everything \ - --enable-decoder=aac*,ac3*,alac,als,dsd*,flac,mp3*,libopus,pcm*,libvorbis,wavpack,wma*,libopenjpg,png \ - --enable-encoder=libmp3lame,libopus,libvorbis \ - --enable-demuxer=aac,aiff,asf,dsf,flac,ipod,ogg,matroska,mov,mp3,mp4,wav,wv,webm \ - --enable-muxer=ogg,matroska,mp3,webm \ + --enable-decoder=aac*,ac3*,alac,als,ape,asf,dsd*,flac,libopus,pcm*,libvorbis,mp3*,mpc7,mpc8,shorten,tta,wavpack,wma*,libopenjpg,png \ + --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=flac,mp3,ogg \ --enable-protocol=file,pipe \ --enable-filter=aresample \ --enable-lto \ diff --git a/README.md b/README.md index d4344ff7..8534b2f0 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The folder must follow a structure defined by Kodi, as detailed [here](https://k __Note__: If no name is provided in the `artist.nfo` file, the name of the containing folder is used. ### Filtering -It is possible to apply global filters on your collection using `genre`, `mood`, `grouping`, `language`, and by music library. More tags, including custom ones, can be added in the database administration settings. +It is possible to apply global filters on your collection using `genre`, `mood`, `grouping`, `language`, `codec`, and by music library. More tags, including custom ones, can be added in the database administration settings. __Note__: You can use the `lms-audioinfo` tool to get an idea of the tags parsed by _LMS_. diff --git a/approot/artist.xml b/approot/artist.xml index 13d24a6a..78195b00 100644 --- a/approot/artist.xml +++ b/approot/artist.xml @@ -24,7 +24,9 @@ ${}
  • ${tr:Lms.Explore.musicbrainz-artist}
  • ${
    } + ${}
  • ${download class="dropdown-item"}
  • + ${
    } diff --git a/approot/messages.xml b/approot/messages.xml index 9a4fd79f..00d92c46 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -371,11 +371,9 @@ Never Transcoding output bitrate Transcoding output format -Matroska/Opus MP3 Ogg/Opus Ogg/Vorbis -WebM/Vorbis User Interface diff --git a/approot/messages_es.xml b/approot/messages_es.xml index b60112a2..5800e983 100644 --- a/approot/messages_es.xml +++ b/approot/messages_es.xml @@ -370,11 +370,9 @@ Nunca Bitrate de transcodificación Formato de transcodificación -Matroska/Opus MP3 Ogg/Opus Ogg/Vorbis -WebM/Vorbis Interfaz de usuario diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index ed0a2b30..4ca0b8a5 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -370,11 +370,9 @@ Jamais Bitrate du transcodage Format du transcodage -Matroska/Opus MP3 Ogg/Opus Ogg/Vorbis -WebM/Vorbis Interface utilisateur diff --git a/approot/messages_it.xml b/approot/messages_it.xml index 13e74ee0..a1856a6b 100644 --- a/approot/messages_it.xml +++ b/approot/messages_it.xml @@ -370,11 +370,9 @@ Mai Bitrate transcodifica Formato di transcodifica -Matroska/Opus MP3 Ogg/Opus Ogg/Vorbis -WebM/Vorbis Interfaccia utente diff --git a/approot/messages_pl.xml b/approot/messages_pl.xml index 2687adc6..6e8ff867 100644 --- a/approot/messages_pl.xml +++ b/approot/messages_pl.xml @@ -396,11 +396,9 @@ Nigdy Przepływność transkodowania Format wyjściowy transkodowania -Matroska/Opus MP3 Ogg/Opus Ogg/Vorbis -WebM/Vorbis Interfejs użytkownika diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml index e5f4cda8..37f11a77 100644 --- a/approot/messages_zh.xml +++ b/approot/messages_zh.xml @@ -367,11 +367,9 @@ 从不 转码比特率 转码格式 -Matroska/Opus MP3 Ogg/Opus Ogg/Vorbis -WebM/Vorbis diff --git a/approot/playqueue.xml b/approot/playqueue.xml index c372f8df..2f6f2453 100644 --- a/approot/playqueue.xml +++ b/approot/playqueue.xml @@ -65,7 +65,9 @@ diff --git a/approot/release.xml b/approot/release.xml index 0d856885..d19cd070 100644 --- a/approot/release.xml +++ b/approot/release.xml @@ -27,7 +27,9 @@ ${}
  • ${tr:Lms.Explore.musicbrainz-release}
  • ${
    } + ${}
  • ${download class="dropdown-item"}
  • + ${
    }
  • ${release-info class="dropdown-item"}
  • @@ -109,7 +111,9 @@
  • ${play-next class="dropdown-item"}
  • ${play-last class="dropdown-item"}
  • ${star class="dropdown-item"}
  • + ${}
  • ${download class="dropdown-item"}
  • + ${
    }
  • ${track-info class="dropdown-item"}
  • ${}
  • ${track-lyrics class="dropdown-item"}
  • ${
    } diff --git a/approot/tracklist.xml b/approot/tracklist.xml index 5a2ec74b..9f4fc8ca 100644 --- a/approot/tracklist.xml +++ b/approot/tracklist.xml @@ -13,7 +13,9 @@ diff --git a/conf/lms.conf b/conf/lms.conf index 269ae960..3a22444d 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -113,9 +113,6 @@ artist-image-file-names = ("artist"); # Note: files named after the disc itself are always searched before the names in this list. medium-image-file-names = ("discsubtitle"); -# Playqueue max entry count -playqueue-max-entry-count = 1000; - # Internal tracing for profiling purposes. Enable only if necessary, as it incurs some runtime overhead! # Possible values are "disabled", "overview" or "detailed". # If enabled, data has to be dumped in the tracing view located in the admin menu @@ -140,3 +137,9 @@ podcast-auto-download-episodes = true; # Max age in days for auto-downloaded episodes before deletion podcast-auto-download-episodes-max-age-days = 30; + +# Playqueue max entry count +ui-playqueue-max-entry-count = 1000; + +# Allow downloads +ui-allow-downloads = true; \ No newline at end of file diff --git a/src/libs/audio/CMakeLists.txt b/src/libs/audio/CMakeLists.txt index 48f071bb..0979b975 100644 --- a/src/libs/audio/CMakeLists.txt +++ b/src/libs/audio/CMakeLists.txt @@ -4,17 +4,17 @@ pkg_check_modules(Taglib REQUIRED IMPORTED_TARGET taglib) add_library(lmsaudio STATIC impl/ffmpeg/AudioFile.cpp impl/ffmpeg/AudioFileInfo.cpp + impl/ffmpeg/AudioFileInfoParser.cpp impl/ffmpeg/ImageReader.cpp impl/ffmpeg/TagReader.cpp impl/ffmpeg/Transcoder.cpp impl/ffmpeg/Utils.cpp impl/taglib/AudioFileInfo.cpp + impl/taglib/AudioFileInfoParser.cpp impl/taglib/ImageReader.cpp impl/taglib/TagReader.cpp impl/taglib/Utils.cpp - impl/AudioTypes.cpp - impl/ImageReader.cpp - impl/ParseAudioFileInfo.cpp + impl/AudioFileInfoParser.cpp impl/TagReader.cpp ) diff --git a/src/libs/audio/impl/AudioFileInfoParser.cpp b/src/libs/audio/impl/AudioFileInfoParser.cpp new file mode 100644 index 00000000..069399d7 --- /dev/null +++ b/src/libs/audio/impl/AudioFileInfoParser.cpp @@ -0,0 +1,41 @@ +/* + * 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 "audio/IAudioFileInfoParser.hpp" + +#include "ffmpeg/AudioFileInfoParser.hpp" +#include "taglib/AudioFileInfoParser.hpp" + +namespace lms::audio +{ + std::unique_ptr createAudioFileInfoParser(AudioFileInfoParserBackend backend) + { + switch (backend) + { + case AudioFileInfoParserBackend::TagLib: + return std::make_unique(); + case AudioFileInfoParserBackend::FFmpeg: + return std::make_unique(); + } + + return nullptr; + } +} // namespace lms::audio \ No newline at end of file diff --git a/src/libs/audio/impl/AudioTypes.cpp b/src/libs/audio/impl/AudioTypes.cpp deleted file mode 100644 index d0a5930b..00000000 --- a/src/libs/audio/impl/AudioTypes.cpp +++ /dev/null @@ -1,105 +0,0 @@ -/* - * 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 "audio/AudioTypes.hpp" - -namespace lms::audio -{ - core::LiteralString containerTypeToString(ContainerType type) - { - switch (type) - { - case ContainerType::AIFF: - return "AIFF"; - case ContainerType::APE: - return "APE"; - case ContainerType::ASF: - return "ASF"; - case ContainerType::DSF: - return "DSF"; - case ContainerType::FLAC: - return "FLAC"; - case ContainerType::MP4: - return "MP4"; - case ContainerType::MPC: - return "MPC"; - case ContainerType::MPEG: - return "MPEG"; - case ContainerType::Ogg: - return "Ogg"; - case ContainerType::Shorten: - return "Shorten"; - case ContainerType::TrueAudio: - return "TrueAudio"; - case ContainerType::WAV: - return "WAV"; - case ContainerType::WavPack: - return "WavPack"; - } - - return ""; - } - - core::LiteralString codecTypeToString(CodecType type) - { - switch (type) - { - case CodecType::AAC: - return "AAC"; - case CodecType::ALAC: - return "ALAC"; - case CodecType::APE: - return "APE"; - case CodecType::DSD: - return "DSD"; - case CodecType::FLAC: - return "FLAC"; - case CodecType::MP3: - return "MP3"; - case CodecType::MP4ALS: - return "MP4ALS"; - case CodecType::MPC7: - return "MPC7"; - case CodecType::MPC8: - return "MPC8"; - case CodecType::Opus: - return "Opus"; - case CodecType::PCM: - return "PCM"; - case CodecType::Shorten: - return "Shorten"; - case CodecType::TrueAudio: - return "TrueAudio"; - case CodecType::Vorbis: - return "Vorbis"; - case CodecType::WavPack: - return "WavPack"; - case CodecType::WMA1: - return "WMA1"; - case CodecType::WMA2: - return "WMA2"; - case CodecType::WMA9Pro: - return "WMA9Pro"; - case CodecType::WMA9Lossless: - return "WMA9Lossless"; - } - - return ""; - } -} // namespace lms::audio \ No newline at end of file diff --git a/src/libs/audio/impl/ParseAudioFileInfo.cpp b/src/libs/audio/impl/ParseAudioFileInfo.cpp deleted file mode 100644 index 321d46a7..00000000 --- a/src/libs/audio/impl/ParseAudioFileInfo.cpp +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (C) 2015 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 "audio/IAudioFileInfo.hpp" - -#include "ffmpeg/AudioFileInfo.hpp" -#include "ffmpeg/Utils.hpp" -#include "taglib/AudioFileInfo.hpp" -#include "taglib/Utils.hpp" - -namespace lms::audio -{ - std::unique_ptr parseAudioFile(const std::filesystem::path& p, const ParserOptions& parserOptions) - { - switch (parserOptions.parser) - { - case ParserOptions::Parser::TagLib: - return std::make_unique(p, parserOptions.readStyle, parserOptions.enableExtraDebugLogs); - case ParserOptions::Parser::FFmpeg: - return std::make_unique(p, parserOptions.enableExtraDebugLogs); - } - - return {}; - } - - std::span getSupportedExtensions(ParserOptions::Parser parser) - { - switch (parser) - { - case ParserOptions::Parser::TagLib: - return taglib::utils::getSupportedExtensions(); - case ParserOptions::Parser::FFmpeg: - return ffmpeg::utils::getSupportedExtensions(); - } - - return {}; - } - -} // namespace lms::audio \ No newline at end of file diff --git a/src/libs/audio/impl/ffmpeg/AudioFile.cpp b/src/libs/audio/impl/ffmpeg/AudioFile.cpp index ad1861a7..1583eb73 100644 --- a/src/libs/audio/impl/ffmpeg/AudioFile.cpp +++ b/src/libs/audio/impl/ffmpeg/AudioFile.cpp @@ -20,21 +20,22 @@ #include "AudioFile.hpp" #include +#include #include extern "C" { -#define __STDC_CONSTANT_MACROS #include #include #include +#include } #include "core/ILogger.hpp" +#include "core/ITraceLogger.hpp" #include "core/String.hpp" -#include "audio/AudioTypes.hpp" -#include "audio/IAudioFileInfo.hpp" +#include "audio/Exception.hpp" namespace lms::audio::ffmpeg { @@ -50,115 +51,169 @@ namespace lms::audio::ffmpeg return "Unknown error"; } - class AudioFileException : public AudioFileParsingException + class AvException : public Exception { public: - AudioFileException(int avError) - : AudioFileParsingException{ averror_to_string(avError) } + AvException(int avError) + : Exception{ averror_to_string(avError) } { } }; - void getMetaDataFromDictionnary(AVDictionary* dictionnary, AudioFile::MetadataMap& res) + void extractMetaDataFromDictionnary(AVDictionary* dictionnary, AudioFile::MetadataMap& res) { if (!dictionnary) return; - AVDictionaryEntry* tag = NULL; - while ((tag = ::av_dict_get(dictionnary, "", tag, AV_DICT_IGNORE_SUFFIX))) - { + const AVDictionaryEntry* tag{ NULL }; + while ((tag = av_dict_iterate(dictionnary, tag))) res[core::stringUtils::stringToUpper(tag->key)] = tag->value; - } } - std::optional avdemuxerToContainerType(std::string_view name) + std::optional avdemuxerToContainerType(std::string_view name) { if (name == "aiff") - return ContainerType::AIFF; + return core::media::Container::AIFF; if (name == "ape") - return ContainerType::APE; + return core::media::Container::APE; if (name.starts_with("asf")) - return ContainerType::ASF; + return core::media::Container::ASF; if (name == "dsf") - return ContainerType::DSF; + return core::media::Container::DSF; if (name == "flac") - return ContainerType::FLAC; + return core::media::Container::FLAC; if (name.find("mp4") != std::string_view::npos) - return ContainerType::MP4; + return core::media::Container::MP4; if (name.starts_with("mpc")) - return ContainerType::MPC; + return core::media::Container::MPC; if (name == "mp3") - return ContainerType::MPEG; + return core::media::Container::MPEG; if (name == "ogg") - return ContainerType::Ogg; + return core::media::Container::Ogg; if (name == "shn") - return ContainerType::Shorten; + return core::media::Container::Shorten; if (name == "tta") - return ContainerType::TrueAudio; + return core::media::Container::TrueAudio; if (name == "wav") - return ContainerType::WAV; + return core::media::Container::WAV; if (name == "wv") - return ContainerType::WavPack; + return core::media::Container::WavPack; return std::nullopt; } - std::optional avcodecToCodecType(AVCodecID codec) + std::optional avcodecToCodecType(AVCodecID codec) { switch (codec) { - case AV_CODEC_ID_MP3: - return CodecType::MP3; case AV_CODEC_ID_AAC: - return CodecType::AAC; - case AV_CODEC_ID_VORBIS: - return CodecType::Vorbis; - case AV_CODEC_ID_WMAV1: - return CodecType::WMA1; - case AV_CODEC_ID_WMAV2: - return CodecType::WMA2; - case AV_CODEC_ID_WMAPRO: - return CodecType::WMA9Pro; - case AV_CODEC_ID_WMALOSSLESS: - return CodecType::WMA9Lossless; - case AV_CODEC_ID_FLAC: - return CodecType::FLAC; + return core::media::Codec::AAC; + case AV_CODEC_ID_AC3: + return core::media::Codec::AC3; case AV_CODEC_ID_ALAC: - return CodecType::ALAC; - case AV_CODEC_ID_WAVPACK: - return CodecType::WavPack; - case AV_CODEC_ID_MUSEPACK7: - return CodecType::MPC7; - case AV_CODEC_ID_MUSEPACK8: - return CodecType::MPC8; + return core::media::Codec::ALAC; case AV_CODEC_ID_APE: - return CodecType::APE; - case AV_CODEC_ID_MP4ALS: - return CodecType::MP4ALS; - case AV_CODEC_ID_OPUS: - return CodecType::Opus; - case AV_CODEC_ID_SHORTEN: - return CodecType::Shorten; + return core::media::Codec::APE; case AV_CODEC_ID_DSD_LSBF: case AV_CODEC_ID_DSD_LSBF_PLANAR: case AV_CODEC_ID_DSD_MSBF: case AV_CODEC_ID_DSD_MSBF_PLANAR: - return CodecType::DSD; + return core::media::Codec::DSD; + case AV_CODEC_ID_EAC3: + return core::media::Codec::EAC3; + case AV_CODEC_ID_FLAC: + return core::media::Codec::FLAC; + case AV_CODEC_ID_MP3: + return core::media::Codec::MP3; + case AV_CODEC_ID_MP4ALS: + return core::media::Codec::MP4ALS; + case AV_CODEC_ID_MUSEPACK7: + return core::media::Codec::MPC7; + case AV_CODEC_ID_MUSEPACK8: + return core::media::Codec::MPC8; + case AV_CODEC_ID_OPUS: + return core::media::Codec::Opus; + case AV_CODEC_ID_SHORTEN: + return core::media::Codec::Shorten; + case AV_CODEC_ID_VORBIS: + return core::media::Codec::Vorbis; + case AV_CODEC_ID_WAVPACK: + return core::media::Codec::WavPack; + case AV_CODEC_ID_WMALOSSLESS: + return core::media::Codec::WMA9Lossless; + case AV_CODEC_ID_WMAPRO: + return core::media::Codec::WMA9Pro; + case AV_CODEC_ID_WMAV1: + return core::media::Codec::WMA1; + case AV_CODEC_ID_WMAV2: + return core::media::Codec::WMA2; default: return std::nullopt; } } + + core::LiteralString avLogLevelToStr(int level) + { + switch (level) + { + case AV_LOG_TRACE: + return "trace"; + case AV_LOG_DEBUG: + return "debug"; + case AV_LOG_VERBOSE: + return "verbose"; + case AV_LOG_INFO: + return "info"; + case AV_LOG_WARNING: + return "warning"; + case AV_LOG_ERROR: + return "error"; + case AV_LOG_FATAL: + return "fatal"; + case AV_LOG_PANIC: + return "panic"; + default: + return "unknown"; + } + } + + void avLogCallback(void*, int level, const char* fmt, va_list vl) + { + if (!core::Service::get()->isSeverityActive(core::logging::Severity::DEBUG)) + return; + + if (level > AV_LOG_WARNING) + return; + + std::array buffer{ 0 }; + std::vsnprintf(buffer.data(), buffer.size(), fmt, vl); + + LMS_LOG(AUDIO, DEBUG, "FFmpeg [" << avLogLevelToStr(level) << "] " << buffer.data()); + } + + class AvInitializer + { + public: + AvInitializer() + { + ::av_log_set_callback(avLogCallback); + } + }; } // namespace AudioFile::AudioFile(const std::filesystem::path& p) : _p{ p } { + LMS_SCOPED_TRACE_DETAILED("MetaData", "FFmpegParseFile"); + + static AvInitializer init; + int error{ avformat_open_input(&_context, _p.c_str(), nullptr, nullptr) }; if (error < 0) { LMS_LOG(AUDIO, ERROR, "Cannot open " << _p << ": " << averror_to_string(error)); - throw AudioFileException{ error }; + throw AvException{ error }; } error = avformat_find_stream_info(_context, nullptr); @@ -166,7 +221,7 @@ namespace lms::audio::ffmpeg { LMS_LOG(AUDIO, ERROR, "Cannot find stream information on " << _p << ": " << averror_to_string(error)); avformat_close_input(&_context); - throw AudioFileException{ error }; + throw AvException{ error }; } } @@ -187,17 +242,18 @@ namespace lms::audio::ffmpeg info.container = avdemuxerToContainerType(_context->iformat->name); info.containerName = _context->iformat->name; - info.bitrate = _context->bit_rate; + if (_context->bit_rate > 0) + info.bitrate = _context->bit_rate; info.duration = std::chrono::milliseconds{ _context->duration == AV_NOPTS_VALUE ? 0 : _context->duration / AV_TIME_BASE * 1'000 }; return info; } - AudioFile::MetadataMap AudioFile::getMetaData() const + AudioFile::MetadataMap AudioFile::extractMetaData() const { MetadataMap res; - getMetaDataFromDictionnary(_context->metadata, res); + extractMetaDataFromDictionnary(_context->metadata, res); // HACK for OGG files // If we did not find tags, search metadata in streams @@ -205,7 +261,7 @@ namespace lms::audio::ffmpeg { for (std::size_t i{}; i < _context->nb_streams; ++i) { - getMetaDataFromDictionnary(_context->streams[i]->metadata, res); + extractMetaDataFromDictionnary(_context->streams[i]->metadata, res); if (!res.empty()) break; @@ -266,7 +322,7 @@ namespace lms::audio::ffmpeg return false; } - void AudioFile::visitAttachedPictures(std::function func) const + void AudioFile::visitAttachedPictures(std::function func) const { static const std::unordered_map codecMimeMap{ { AV_CODEC_ID_BMP, "image/bmp" }, @@ -286,14 +342,14 @@ namespace lms::audio::ffmpeg if (avstream->codecpar == nullptr) { - LMS_LOG(AUDIO, ERROR, "Skipping stream " << i << " since no codecpar is set"); + LMS_LOG(AUDIO, WARNING, "Skipping stream " << i << " since no codecpar is set"); continue; } MetadataMap metadata; - getMetaDataFromDictionnary(avstream->metadata, metadata); + extractMetaDataFromDictionnary(avstream->metadata, metadata); - Picture picture; + PictureView picture; auto itMime = codecMimeMap.find(avstream->codecpar->codec_id); if (itMime != codecMimeMap.end()) @@ -303,10 +359,10 @@ namespace lms::audio::ffmpeg else { picture.mimeType = "application/octet-stream"; - LMS_LOG(AUDIO, ERROR, "CODEC ID " << avstream->codecpar->codec_id << " not handled in mime type conversion"); + LMS_LOG(AUDIO, WARNING, "AVCodecID" << avstream->codecpar->codec_id << " (" << ::avcodec_get_name(avstream->codecpar->codec_id) << ") not handled in mime type conversion"); } - const AVPacket& pkt{ avstream->attached_pic }; + const ::AVPacket& pkt{ avstream->attached_pic }; picture.data = std::span{ reinterpret_cast(pkt.data), static_cast(pkt.size) }; func(picture, metadata); @@ -325,7 +381,7 @@ namespace lms::audio::ffmpeg if (!avstream->codecpar) { - LMS_LOG(AUDIO, ERROR, "Skipping stream " << streamIndex << " since no codecpar is set"); + LMS_LOG(AUDIO, WARNING, "Skipping stream " << streamIndex << " since no codecpar is set"); return res; } diff --git a/src/libs/audio/impl/ffmpeg/AudioFile.hpp b/src/libs/audio/impl/ffmpeg/AudioFile.hpp index 2af7fb1b..fdf0ac65 100644 --- a/src/libs/audio/impl/ffmpeg/AudioFile.hpp +++ b/src/libs/audio/impl/ffmpeg/AudioFile.hpp @@ -27,7 +27,8 @@ #include #include -#include "audio/AudioTypes.hpp" +#include "core/media/Codec.hpp" +#include "core/media/Container.hpp" extern "C" { @@ -36,7 +37,7 @@ extern "C" namespace lms::audio::ffmpeg { - struct Picture + struct PictureView { std::string mimeType; std::span data; // valid as long as IAudioFile exists @@ -44,17 +45,17 @@ namespace lms::audio::ffmpeg struct ContainerInfo { - std::optional container; + std::optional container; std::string containerName; - std::size_t bitrate{}; + std::optional bitrate; std::chrono::milliseconds duration{}; }; struct StreamInfo { size_t index{}; - std::optional codec; + std::optional codec; std::string codecName; std::optional bitrate; @@ -75,12 +76,12 @@ namespace lms::audio::ffmpeg const std::filesystem::path& getPath() const; ContainerInfo getContainerInfo() const; - MetadataMap getMetaData() const; + MetadataMap extractMetaData() const; std::vector getStreamInfo() const; std::optional getBestStreamInfo() const; std::optional getBestStreamIndex() const; bool hasAttachedPictures() const; - void visitAttachedPictures(std::function func) const; + void visitAttachedPictures(std::function func) const; private: std::optional getStreamInfo(std::size_t streamIndex) const; diff --git a/src/libs/audio/impl/ffmpeg/AudioFileInfo.cpp b/src/libs/audio/impl/ffmpeg/AudioFileInfo.cpp index fe0793f9..e987412e 100644 --- a/src/libs/audio/impl/ffmpeg/AudioFileInfo.cpp +++ b/src/libs/audio/impl/ffmpeg/AudioFileInfo.cpp @@ -19,7 +19,12 @@ #include "AudioFileInfo.hpp" -#include "audio/AudioTypes.hpp" +#include +#include + +#include "core/ILogger.hpp" + +#include "audio/AudioProperties.hpp" #include "audio/IAudioFileInfo.hpp" #include "AudioFile.hpp" @@ -30,65 +35,108 @@ namespace lms::audio::ffmpeg { namespace { - AudioProperties computeAudioProperties(const AudioFile& audioFile) + std::optional computeAudioProperties(const AudioFile& audioFile) { - AudioProperties audioProperties; + std::optional audioProperties; const auto containerInfo{ audioFile.getContainerInfo() }; const auto bestStreamInfo{ audioFile.getBestStreamInfo() }; if (!bestStreamInfo) - throw AudioFileParsingException{ "Cannot find best audio stream" }; + { + LMS_LOG(AUDIO, DEBUG, "Cannot find best audio stream in " << audioFile.getPath()); + return audioProperties; + } + + if (containerInfo.duration.count() == 0) + { + LMS_LOG(AUDIO, DEBUG, "Cannot determine duration in " << audioFile.getPath()); + return audioProperties; + } if (!containerInfo.container) - throw AudioFileParsingException{ "Unhandled container type '" + containerInfo.containerName + "'" }; + { + LMS_LOG(AUDIO, DEBUG, "Unhandled container '" << containerInfo.containerName << "' in " << audioFile.getPath()); + return audioProperties; + } if (!bestStreamInfo->codec) - throw AudioFileParsingException{ "Unhandled codec type '" + bestStreamInfo->codecName + "'" }; - - if (!bestStreamInfo->bitrate || *bestStreamInfo->bitrate == 0) - throw AudioFileParsingException{ "Cannot determine bitrate" }; + { + LMS_LOG(AUDIO, DEBUG, "Unhandled codec '" << bestStreamInfo->codecName << "' in " << audioFile.getPath()); + return audioProperties; + } if (!bestStreamInfo->channelCount || *bestStreamInfo->channelCount == 0) - throw AudioFileParsingException{ "Cannot determine channel count" }; + { + LMS_LOG(AUDIO, DEBUG, "Cannot determine channel count in " << audioFile.getPath()); + return audioProperties; + } if (!bestStreamInfo->sampleRate || *bestStreamInfo->sampleRate == 0) - throw AudioFileParsingException{ "Cannot determine sample rate" }; + { + LMS_LOG(AUDIO, DEBUG, "Cannot determine sample rate in " << audioFile.getPath()); + return audioProperties; + } - audioProperties.container = *containerInfo.container; - audioProperties.duration = containerInfo.duration; - audioProperties.codec = *bestStreamInfo->codec; - audioProperties.bitrate = *bestStreamInfo->bitrate; - audioProperties.channelCount = *bestStreamInfo->channelCount; - audioProperties.sampleRate = *bestStreamInfo->sampleRate; - audioProperties.bitsPerSample = bestStreamInfo->bitsPerSample; + audioProperties.emplace(); + + audioProperties->container = *containerInfo.container; + audioProperties->duration = containerInfo.duration; + audioProperties->codec = *bestStreamInfo->codec; + + if (bestStreamInfo->bitrate) + audioProperties->bitrate = *bestStreamInfo->bitrate; + else if (containerInfo.bitrate) + audioProperties->bitrate = *containerInfo.bitrate; + else // Fallback on a bitrate based on duration/size + { + std::error_code ec; + const auto fileSize{ std::filesystem::file_size(audioFile.getPath(), ec) }; + if (ec) + { + LMS_LOG(AUDIO, DEBUG, "Cannot determine file size for " << audioFile.getPath() << ": " << ec.message()); + audioProperties.reset(); + return audioProperties; + } + + audioProperties->bitrate = static_cast(std::chrono::duration_cast>(containerInfo.duration).count() / fileSize); + LMS_LOG(AUDIO, DEBUG, "Estimated bitrate from duration/size: " << audioProperties->bitrate << " bps in " << audioFile.getPath()); + } + assert(audioProperties->bitrate > 0); + + audioProperties->channelCount = *bestStreamInfo->channelCount; + audioProperties->sampleRate = *bestStreamInfo->sampleRate; + audioProperties->bitsPerSample = bestStreamInfo->bitsPerSample; return audioProperties; } } // namespace - AudioFileInfo::AudioFileInfo(const std::filesystem::path& filePath, bool enableExtraDebugLogs) + AudioFileInfo::AudioFileInfo(const std::filesystem::path& filePath, const AudioFileInfoParseOptions& parseOptions) : _audioFile{ std::make_unique(filePath) } - , _audioProperties{ std::make_unique(computeAudioProperties(*_audioFile)) } - , _tagReader{ std::make_unique(*_audioFile, enableExtraDebugLogs) } - , _imageReader{ std::make_unique(*_audioFile) } + , _audioProperties{ computeAudioProperties(*_audioFile) } { + if (parseOptions.readTags) + _tagReader = std::make_unique(*_audioFile, parseOptions.enableExtraDebugLogs); + + if (parseOptions.readImages) + _imageReader = std::make_unique(*_audioFile); } AudioFileInfo::~AudioFileInfo() = default; - const AudioProperties& AudioFileInfo::getAudioProperties() const + const AudioProperties* AudioFileInfo::getAudioProperties() const { - return *_audioProperties; + return _audioProperties.has_value() ? &_audioProperties.value() : nullptr; } - const IImageReader& AudioFileInfo::getImageReader() const + const IImageReader* AudioFileInfo::getImageReader() const { - return *_imageReader; + return _imageReader.get(); } - const ITagReader& AudioFileInfo::getTagReader() const + const ITagReader* AudioFileInfo::getTagReader() const { - return *_tagReader; + return _tagReader.get(); } } // namespace lms::audio::ffmpeg diff --git a/src/libs/audio/impl/ffmpeg/AudioFileInfo.hpp b/src/libs/audio/impl/ffmpeg/AudioFileInfo.hpp index e8c806a7..e0874668 100644 --- a/src/libs/audio/impl/ffmpeg/AudioFileInfo.hpp +++ b/src/libs/audio/impl/ffmpeg/AudioFileInfo.hpp @@ -20,9 +20,11 @@ #pragma once #include +#include -#include "audio/AudioTypes.hpp" +#include "audio/AudioProperties.hpp" #include "audio/IAudioFileInfo.hpp" +#include "audio/IAudioFileInfoParser.hpp" namespace lms::audio::ffmpeg { @@ -33,18 +35,19 @@ namespace lms::audio::ffmpeg class AudioFileInfo final : public IAudioFileInfo { public: - AudioFileInfo(const std::filesystem::path& filePath, bool enableExtraDebugLogs); - ~AudioFileInfo(); + AudioFileInfo(const std::filesystem::path& filePath, const AudioFileInfoParseOptions& parseOptions); + ~AudioFileInfo() override; + AudioFileInfo(const AudioFileInfo&) = delete; AudioFileInfo& operator=(const AudioFileInfo&) = delete; private: - const AudioProperties& getAudioProperties() const override; - const IImageReader& getImageReader() const override; - const ITagReader& getTagReader() const override; + const AudioProperties* getAudioProperties() const override; + const IImageReader* getImageReader() const override; + const ITagReader* getTagReader() const override; std::unique_ptr _audioFile; - std::unique_ptr _audioProperties; + const std::optional _audioProperties; std::unique_ptr _tagReader; std::unique_ptr _imageReader; }; diff --git a/src/libs/audio/impl/ffmpeg/AudioFileInfoParser.cpp b/src/libs/audio/impl/ffmpeg/AudioFileInfoParser.cpp new file mode 100644 index 00000000..9af6d1f8 --- /dev/null +++ b/src/libs/audio/impl/ffmpeg/AudioFileInfoParser.cpp @@ -0,0 +1,38 @@ +/* + * 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 "AudioFileInfoParser.hpp" + +#include + +#include "AudioFileInfo.hpp" +#include "Utils.hpp" + +namespace lms::audio::ffmpeg +{ + std::unique_ptr AudioFileInfoParser::parse(const std::filesystem::path& p, const AudioFileInfoParseOptions& parseOptions) const + { + return std::make_unique(p, parseOptions); + } + + std::span AudioFileInfoParser::getSupportedExtensions() const + { + return utils::getSupportedExtensions(); + } +} // namespace lms::audio::ffmpeg \ No newline at end of file diff --git a/src/libs/audio/impl/ffmpeg/AudioFileInfoParser.hpp b/src/libs/audio/impl/ffmpeg/AudioFileInfoParser.hpp new file mode 100644 index 00000000..0c3a1e98 --- /dev/null +++ b/src/libs/audio/impl/ffmpeg/AudioFileInfoParser.hpp @@ -0,0 +1,32 @@ +/* + * 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 "audio/IAudioFileInfoParser.hpp" + +namespace lms::audio::ffmpeg +{ + class AudioFileInfoParser : public IAudioFileInfoParser + { + private: + std::unique_ptr parse(const std::filesystem::path& p, const AudioFileInfoParseOptions& parseOptions = AudioFileInfoParseOptions{}) const override; + std::span getSupportedExtensions() const override; + }; +} // namespace lms::audio::ffmpeg \ No newline at end of file diff --git a/src/libs/audio/impl/ffmpeg/ImageReader.cpp b/src/libs/audio/impl/ffmpeg/ImageReader.cpp index ad935d1e..cedd6b8c 100644 --- a/src/libs/audio/impl/ffmpeg/ImageReader.cpp +++ b/src/libs/audio/impl/ffmpeg/ImageReader.cpp @@ -40,17 +40,16 @@ namespace lms::audio::ffmpeg return std::any_of(std::cbegin(metadata), std::cend(metadata), [&](const auto& keyValue) { return core::stringUtils::stringCaseInsensitiveContains(keyValue.second, keyword); }); } }; - _audioFile.visitAttachedPictures([&](const Picture& picture, const AudioFile::MetadataMap& metaData) { + _audioFile.visitAttachedPictures([&](const PictureView& picture, const AudioFile::MetadataMap& metaData) { Image image; image.data = picture.data; image.mimeType = picture.mimeType; if (metaDataHasKeyword(metaData, "front")) - image.type = Image::Type::FrontCover; + image.type = core::media::ImageType::FrontCover; else if (metaDataHasKeyword(metaData, "back")) - image.type = Image::Type::BackCover; + image.type = core::media::ImageType::BackCover; visitor(image); }); } - } // namespace lms::audio::ffmpeg diff --git a/src/libs/audio/impl/ffmpeg/TagReader.cpp b/src/libs/audio/impl/ffmpeg/TagReader.cpp index fa5d9e4f..dcf648aa 100644 --- a/src/libs/audio/impl/ffmpeg/TagReader.cpp +++ b/src/libs/audio/impl/ffmpeg/TagReader.cpp @@ -144,8 +144,7 @@ namespace lms::audio::ffmpeg } // namespace TagReader::TagReader(const AudioFile& audioFile, bool enableExtraDebugLogs) - : _audioFile{ audioFile } - , _metaDataMap{ audioFile.getMetaData() } + : _metaDataMap{ audioFile.extractMetaData() } { if (enableExtraDebugLogs && core::Service::get()->isSeverityActive(core::logging::Severity::DEBUG)) { diff --git a/src/libs/audio/impl/ffmpeg/TagReader.hpp b/src/libs/audio/impl/ffmpeg/TagReader.hpp index 25e7bd7f..9864e0d3 100644 --- a/src/libs/audio/impl/ffmpeg/TagReader.hpp +++ b/src/libs/audio/impl/ffmpeg/TagReader.hpp @@ -39,7 +39,6 @@ namespace lms::audio::ffmpeg void visitPerformerTags(PerformerVisitor visitor) const override; void visitLyricsTags(LyricsVisitor visitor) const override; - const AudioFile& _audioFile; AudioFile::MetadataMap _metaDataMap; }; } // namespace lms::audio::ffmpeg diff --git a/src/libs/audio/impl/ffmpeg/Transcoder.cpp b/src/libs/audio/impl/ffmpeg/Transcoder.cpp index cd6ea9d5..649f8872 100644 --- a/src/libs/audio/impl/ffmpeg/Transcoder.cpp +++ b/src/libs/audio/impl/ffmpeg/Transcoder.cpp @@ -19,13 +19,14 @@ #include "Transcoder.hpp" -#include +#include #include #include "core/IChildProcessManager.hpp" #include "core/IConfig.hpp" #include "core/ILogger.hpp" #include "core/Service.hpp" +#include "core/media/MimeType.hpp" #include "audio/Exception.hpp" #include "audio/TranscodeTypes.hpp" @@ -42,18 +43,29 @@ namespace lms::audio::ffmpeg { #define LOG(severity, message) LMS_LOG(TRANSCODING, severity, "[" << _debugId << "] - " << message) - static std::atomic globalId{}; - static std::filesystem::path ffmpegPath; - - void Transcoder::init() + namespace { - ffmpegPath = core::Service::get()->getPath("ffmpeg-file", "/usr/bin/ffmpeg"); - if (!std::filesystem::exists(ffmpegPath)) - throw Exception{ "File '" + ffmpegPath.string() + "' does not exist!" }; - } + class FFmpegPath + { + public: + FFmpegPath() + { + path = core::Service::get()->getPath("ffmpeg-file", "/usr/bin/ffmpeg"); + if (!std::filesystem::exists(path)) + throw Exception{ "File '" + path.string() + "' does not exist!" }; + } + + const std::filesystem::path& get() const { return path; } + + private: + std::filesystem::path path; + }; + } // namespace + + std::atomic Transcoder::_nextDebugId; Transcoder::Transcoder(const TranscodeParameters& parameters) - : _debugId{ globalId++ } + : _debugId{ _nextDebugId++ } , _inputParams{ parameters.inputParameters } , _outputParams{ parameters.outputParameters } { @@ -64,8 +76,7 @@ namespace lms::audio::ffmpeg void Transcoder::start() { - if (ffmpegPath.empty()) - init(); + static FFmpegPath ffmpegPath; try { @@ -76,15 +87,16 @@ namespace lms::audio::ffmpeg } catch (const std::filesystem::filesystem_error& e) { - // TODO store/raise e.code() - throw Exception{ "File error '" + _inputParams.filePath.string() + "': " + e.what() }; + throw IOFileException{ _inputParams.filePath, "Failed to check if file exists", e.code() }; } LOG(INFO, "Transcoding file " << _inputParams.filePath); std::vector args; - args.emplace_back(ffmpegPath.string()); + args.emplace_back(ffmpegPath.get().string()); + + // TODO some codecs have restrictions, take them into account (channel count, sample rate, etc.) // Make sure: // - we do not produce anything in the stderr output @@ -114,7 +126,7 @@ namespace lms::audio::ffmpeg args.emplace_back("-1"); } - // Skip video flows (including covers) + // Skip video flows (including covers!) args.emplace_back("-vn"); // Output bitrates @@ -124,63 +136,88 @@ namespace lms::audio::ffmpeg args.emplace_back(std::to_string(*_outputParams.bitrate)); } + // -ac doc says: "For output streams it is set by default to the number of input audio channels". + // But it looks like specifying it is required in case the original layout is not compatible with the output codec selected + { + args.emplace_back("-ac"); + args.emplace_back(std::to_string(_outputParams.channelCount ? *_outputParams.channelCount : _inputParams.audioProperties.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) { - switch (*_outputParams.format) + args.emplace_back("-f"); + + switch (_outputParams.format->container) { - case OutputFormat::MP3: - args.emplace_back("-f"); + case core::media::Container::FLAC: + args.emplace_back("flac"); + break; + case core::media::Container::Ogg: + args.emplace_back("ogg"); + break; + case core::media::Container::MPEG: args.emplace_back("mp3"); break; - case OutputFormat::OGG_OPUS: - args.emplace_back("-acodec"); + default: + throw Exception{ "Unsupported container type " + std::string{ core::media::containerToString(_outputParams.format->container).str() } }; + } + + args.emplace_back("-acodec"); + + switch (_outputParams.format->codec) + { + case core::media::Codec::MP3: + args.emplace_back("libmp3lame"); + break; + + case core::media::Codec::Opus: args.emplace_back("libopus"); - args.emplace_back("-f"); - args.emplace_back("ogg"); break; - case OutputFormat::MATROSKA_OPUS: - args.emplace_back("-acodec"); - args.emplace_back("libopus"); - args.emplace_back("-f"); - args.emplace_back("matroska"); - break; - - case OutputFormat::OGG_VORBIS: - args.emplace_back("-acodec"); + case core::media::Codec::Vorbis: args.emplace_back("libvorbis"); - args.emplace_back("-f"); - args.emplace_back("ogg"); break; - case OutputFormat::WEBM_VORBIS: - args.emplace_back("-acodec"); - args.emplace_back("libvorbis"); - args.emplace_back("-f"); - args.emplace_back("webm"); + case core::media::Codec::FLAC: + args.emplace_back("flac"); break; default: - throw Exception{ "Unhandled format (" + std::to_string(static_cast(*_outputParams.format)) + ")" }; + throw Exception{ "Unhandled codec type " + std::string{ core::media::getCodecDesc(_outputParams.format->codec).name.str() } }; } } args.emplace_back("pipe:1"); - LOG(DEBUG, "Dumping args (" << args.size() << ")"); - for (const std::string& arg : args) - LOG(DEBUG, "Arg = '" << arg << "'"); + if (core::Service::get()->isSeverityActive(core::logging::Severity::DEBUG)) + { + LOG(DEBUG, "Dumping args (" << args.size() << ")"); + for (const std::string& arg : args) + LOG(DEBUG, "Arg = '" << arg << "'"); + } // Caution: stdin must have been closed before try { - _childProcess = core::Service::get()->spawnChildProcess(ffmpegPath, args); + _childProcess = core::Service::get()->spawnChildProcess(ffmpegPath.get(), args); } catch (core::ChildProcessException& exception) { - throw Exception{ "Cannot execute '" + ffmpegPath.string() + "': " + exception.what() }; + throw Exception{ "Cannot execute '" + ffmpegPath.get().string() + "': " + exception.what() }; } } @@ -202,25 +239,10 @@ namespace lms::audio::ffmpeg std::string_view Transcoder::getOutputMimeType() const { - // TODO: use input mime type if (_outputParams.format) - { - switch (*_outputParams.format) - { - case OutputFormat::MP3: - return "audio/mpeg"; - case OutputFormat::OGG_OPUS: - return "audio/opus"; - case OutputFormat::MATROSKA_OPUS: - return "audio/x-matroska"; - case OutputFormat::OGG_VORBIS: - return "audio/ogg"; - case OutputFormat::WEBM_VORBIS: - return "audio/webm"; - } - } + return core::media::getMimeType(_outputParams.format->container, _outputParams.format->codec).str(); - return "application/octet-stream"; // default, should not happen + return core::media::getMimeType(_inputParams.audioProperties.container, _inputParams.audioProperties.codec).str(); } bool Transcoder::finished() const diff --git a/src/libs/audio/impl/ffmpeg/Transcoder.hpp b/src/libs/audio/impl/ffmpeg/Transcoder.hpp index 715c6d4f..7204922c 100644 --- a/src/libs/audio/impl/ffmpeg/Transcoder.hpp +++ b/src/libs/audio/impl/ffmpeg/Transcoder.hpp @@ -19,6 +19,8 @@ #pragma once +#include + #include "audio/ITranscoder.hpp" namespace lms::core @@ -47,7 +49,8 @@ namespace lms::audio::ffmpeg static void init(); void start(); - const std::size_t _debugId{}; + static std::atomic _nextDebugId; + const std::size_t _debugId; const TranscodeInputParameters _inputParams; const TranscodeOutputParameters _outputParams; std::unique_ptr _childProcess; diff --git a/src/libs/audio/impl/taglib/AudioFileInfo.cpp b/src/libs/audio/impl/taglib/AudioFileInfo.cpp index 6cc81dea..b0251481 100644 --- a/src/libs/audio/impl/taglib/AudioFileInfo.cpp +++ b/src/libs/audio/impl/taglib/AudioFileInfo.cpp @@ -43,8 +43,11 @@ #include #endif -#include "audio/AudioTypes.hpp" +#include "core/ILogger.hpp" + +#include "audio/AudioProperties.hpp" #include "audio/IAudioFileInfo.hpp" +#include "audio/IAudioFileInfoParser.hpp" #include "ImageReader.hpp" #include "TagReader.hpp" @@ -54,60 +57,78 @@ namespace lms::audio::taglib { namespace { - AudioProperties computeAudioProperties(const ::TagLib::File& file) + std::optional computeAudioProperties(const ::TagLib::File& file, const std::filesystem::path& filePath) { assert(file.audioProperties()); AudioProperties audioProperties; { - const ::TagLib::AudioProperties& properties{ *file.audioProperties() }; + const ::TagLib::AudioProperties* properties{ file.audioProperties() }; + if (!properties) + { + LMS_LOG(AUDIO, DEBUG, "Cannot determine audio properties in " << filePath); + return std::nullopt; + } // Common properties - audioProperties.bitrate = static_cast(properties.bitrate() * 1000); + audioProperties.bitrate = static_cast(properties->bitrate() * 1000); if (audioProperties.bitrate == 0) - throw AudioFileParsingException{ "Cannot determine bitrate" }; + { + LMS_LOG(AUDIO, DEBUG, "Cannot determine bitrate in " << filePath); + return std::nullopt; + } - audioProperties.channelCount = static_cast(properties.channels()); + audioProperties.channelCount = static_cast(properties->channels()); if (audioProperties.channelCount == 0) - throw AudioFileParsingException{ "Cannot determine channel count" }; + { + LMS_LOG(AUDIO, DEBUG, "Cannot determine channel count in " << filePath); + return std::nullopt; + } - audioProperties.duration = std::chrono::milliseconds{ properties.lengthInMilliseconds() }; + audioProperties.duration = std::chrono::milliseconds{ properties->lengthInMilliseconds() }; if (audioProperties.duration == decltype(audioProperties.duration)::zero()) - throw AudioFileParsingException{ "Cannot determine duration" }; + { + LMS_LOG(AUDIO, DEBUG, "Cannot determine duration in " << filePath); + return std::nullopt; + } - audioProperties.sampleRate = static_cast(properties.sampleRate()); + audioProperties.sampleRate = static_cast(properties->sampleRate()); if (audioProperties.sampleRate == 0) - throw AudioFileParsingException{ "Cannot determine sample rate" }; + { + LMS_LOG(AUDIO, DEBUG, "Cannot determine sample rate in " << filePath); + return std::nullopt; + } } // Guess container from the file type if (const auto* apeFile{ dynamic_cast(&file) }) { - audioProperties.container = ContainerType::APE; - audioProperties.codec = CodecType::APE; // TODO version? + audioProperties.container = core::media::Container::APE; + audioProperties.codec = core::media::Codec::APE; // TODO version? audioProperties.bitsPerSample = apeFile->audioProperties()->bitsPerSample(); } else if (const auto* asfFile{ dynamic_cast(&file) }) { - audioProperties.container = ContainerType::ASF; + audioProperties.container = core::media::Container::ASF; switch (asfFile->audioProperties()->codec()) { case ::TagLib::ASF::Properties::Codec::WMA1: - audioProperties.codec = CodecType::WMA1; + audioProperties.codec = core::media::Codec::WMA1; break; case ::TagLib::ASF::Properties::Codec::WMA2: - audioProperties.codec = CodecType::WMA2; + audioProperties.codec = core::media::Codec::WMA2; break; case ::TagLib::ASF::Properties::Codec::WMA9Lossless: - audioProperties.codec = CodecType::WMA9Lossless; + audioProperties.codec = core::media::Codec::WMA9Lossless; break; case ::TagLib::ASF::Properties::Codec::WMA9Pro: - audioProperties.codec = CodecType::WMA9Pro; + audioProperties.codec = core::media::Codec::WMA9Pro; break; case ::TagLib::ASF::Properties::Codec::Unknown: - throw AudioFileParsingException{ "Unhandled ASF codec" }; + LMS_LOG(AUDIO, DEBUG, "Unhandled ASF codec in " << filePath); + return std::nullopt; } audioProperties.bitsPerSample = asfFile->audioProperties()->bitsPerSample(); @@ -115,110 +136,116 @@ namespace lms::audio::taglib #if LMS_TAGLIB_HAS_DSF else if (const auto* dsfFile{ dynamic_cast(&file) }) { - audioProperties.container = ContainerType::DSF; - audioProperties.codec = CodecType::DSD; + audioProperties.container = core::media::Container::DSF; + audioProperties.codec = core::media::Codec::DSD; audioProperties.bitsPerSample = dsfFile->audioProperties()->bitsPerSample(); } #endif // LMS_TAGLIB_HAS_DSF else if (const auto* flacFile{ dynamic_cast(&file) }) { - audioProperties.container = ContainerType::FLAC; - audioProperties.codec = CodecType::FLAC; + audioProperties.container = core::media::Container::FLAC; + audioProperties.codec = core::media::Codec::FLAC; audioProperties.bitsPerSample = flacFile->audioProperties()->bitsPerSample(); } else if (const auto* mp4File{ dynamic_cast(&file) }) { - audioProperties.container = ContainerType::MP4; + audioProperties.container = core::media::Container::MP4; switch (mp4File->audioProperties()->codec()) { case ::TagLib::MP4::Properties::Codec::AAC: - audioProperties.codec = CodecType::AAC; + audioProperties.codec = core::media::Codec::AAC; break; case ::TagLib::MP4::Properties::Codec::ALAC: - audioProperties.codec = CodecType::ALAC; + audioProperties.codec = core::media::Codec::ALAC; break; case ::TagLib::MP4::Properties::Codec::Unknown: - throw AudioFileParsingException{ "Unhandled MP4 codec" }; + LMS_LOG(AUDIO, DEBUG, "Unhandled MP4 codec in " << filePath); + return std::nullopt; } audioProperties.bitsPerSample = mp4File->audioProperties()->bitsPerSample(); } else if (const auto* mpcFile{ dynamic_cast(&file) }) { - audioProperties.container = ContainerType::MPC; + audioProperties.container = core::media::Container::MPC; switch (mpcFile->audioProperties()->mpcVersion()) { case 7: - audioProperties.codec = CodecType::MPC7; + audioProperties.codec = core::media::Codec::MPC7; break; case 8: - audioProperties.codec = CodecType::MPC8; + audioProperties.codec = core::media::Codec::MPC8; break; default: - throw AudioFileParsingException{ "Unhandled MPC codec" }; + LMS_LOG(AUDIO, DEBUG, "Unhandled MPC codec version " << mpcFile->audioProperties()->mpcVersion() << " in " << filePath); + return std::nullopt; } } else if (const auto* mpegFile{ dynamic_cast(&file) }) { const auto& properties{ *mpegFile->audioProperties() }; - audioProperties.container = ContainerType::MPEG; + audioProperties.container = core::media::Container::MPEG; if ((properties.version() == TagLib::MPEG::Header::Version::Version1 || properties.version() == TagLib::MPEG::Header::Version::Version2 || properties.version() == TagLib::MPEG::Header::Version::Version2_5) && mpegFile->audioProperties()->layer() == 3) - audioProperties.codec = CodecType::MP3; // could be MPEG-1 layer 3 or MPEG-2(.5) layer 3 + audioProperties.codec = core::media::Codec::MP3; // could be MPEG-1 layer 3 or MPEG-2(.5) layer 3 #if LMS_TAGLIB_HAS_ADTS else if (mpegFile->audioProperties()->isADTS()) // likely AAC - audioProperties.codec = CodecType::AAC; + audioProperties.codec = core::media::Codec::AAC; #endif else - throw AudioFileParsingException{ "Unhandled MPEG codec" }; + { + LMS_LOG(AUDIO, DEBUG, "Unhandled MPEG codec in " << filePath); + return std::nullopt; + } } else if (dynamic_cast(&file)) { - audioProperties.container = ContainerType::Ogg; - audioProperties.codec = CodecType::Opus; + audioProperties.container = core::media::Container::Ogg; + audioProperties.codec = core::media::Codec::Opus; } else if (dynamic_cast(&file)) { - audioProperties.container = ContainerType::Ogg; - audioProperties.codec = CodecType::Vorbis; + audioProperties.container = core::media::Container::Ogg; + audioProperties.codec = core::media::Codec::Vorbis; } else if (const auto* aiffFile{ dynamic_cast(&file) }) { - audioProperties.container = ContainerType::AIFF; - audioProperties.codec = CodecType::PCM; + audioProperties.container = core::media::Container::AIFF; + audioProperties.codec = core::media::Codec::PCM; audioProperties.bitsPerSample = aiffFile->audioProperties()->bitsPerSample(); } else if (const auto* wavFile{ dynamic_cast(&file) }) { - audioProperties.container = ContainerType::WAV; - audioProperties.codec = CodecType::PCM; + audioProperties.container = core::media::Container::WAV; + audioProperties.codec = core::media::Codec::PCM; audioProperties.bitsPerSample = wavFile->audioProperties()->bitsPerSample(); } #if LMS_TAGLIB_HAS_SHORTEN else if (const auto* shortenFile{ dynamic_cast(&file) }) { - audioProperties.container = ContainerType::Shorten; - audioProperties.codec = CodecType::Shorten; + audioProperties.container = core::media::Container::Shorten; + audioProperties.codec = core::media::Codec::Shorten; audioProperties.bitsPerSample = shortenFile->audioProperties()->bitsPerSample(); } #endif // LMS_TAGLIB_HAS_SHORTEN else if (const auto* trueAudioFile{ dynamic_cast(&file) }) { - audioProperties.container = ContainerType::TrueAudio; - audioProperties.codec = CodecType::TrueAudio; + audioProperties.container = core::media::Container::TrueAudio; + audioProperties.codec = core::media::Codec::TrueAudio; audioProperties.bitsPerSample = trueAudioFile->audioProperties()->bitsPerSample(); } else if (const auto* wavPackFile{ dynamic_cast(&file) }) { - audioProperties.container = ContainerType::WavPack; - audioProperties.codec = CodecType::WavPack; + audioProperties.container = core::media::Container::WavPack; + audioProperties.codec = core::media::Codec::WavPack; audioProperties.bitsPerSample = wavPackFile->audioProperties()->bitsPerSample(); } else { - throw AudioFileParsingException{ "Unhandled file type" }; + LMS_LOG(AUDIO, DEBUG, "Unhandled file type in " << filePath); + return std::nullopt; } if (audioProperties.bitsPerSample && *audioProperties.bitsPerSample == 0) @@ -228,30 +255,32 @@ namespace lms::audio::taglib } } // namespace - AudioFileInfo::AudioFileInfo(const std::filesystem::path& filePath, ParserOptions::AudioPropertiesReadStyle readStyle, bool enableExtraDebugLogs) + AudioFileInfo::AudioFileInfo(const std::filesystem::path& filePath, const AudioFileInfoParseOptions& parseOptions) : _filePath{ filePath } - , _file{ utils::parseFile(filePath, readStyle) } - , _audioProperties{ std::make_unique(computeAudioProperties(*_file)) } - , _tagReader{ std::make_unique(*_file, enableExtraDebugLogs) } - , _imageReader{ std::make_unique(*_file) } + , _file{ utils::parseFile(filePath, parseOptions.audioPropertiesReadStyle) } + , _audioProperties{ computeAudioProperties(*_file, filePath) } { + if (parseOptions.readTags) + _tagReader = std::make_unique(*_file, parseOptions.enableExtraDebugLogs); + + if (parseOptions.readImages) + _imageReader = std::make_unique(*_file); } AudioFileInfo::~AudioFileInfo() = default; - const AudioProperties& AudioFileInfo::getAudioProperties() const + const AudioProperties* AudioFileInfo::getAudioProperties() const { - return *_audioProperties; + return _audioProperties.has_value() ? &_audioProperties.value() : nullptr; } - const IImageReader& AudioFileInfo::getImageReader() const + const IImageReader* AudioFileInfo::getImageReader() const { - return *_imageReader; + return _imageReader.get(); } - const ITagReader& AudioFileInfo::getTagReader() const + const ITagReader* AudioFileInfo::getTagReader() const { - return *_tagReader; + return _tagReader.get(); } - } // namespace lms::audio::taglib diff --git a/src/libs/audio/impl/taglib/AudioFileInfo.hpp b/src/libs/audio/impl/taglib/AudioFileInfo.hpp index 4cd85452..00d734ac 100644 --- a/src/libs/audio/impl/taglib/AudioFileInfo.hpp +++ b/src/libs/audio/impl/taglib/AudioFileInfo.hpp @@ -19,8 +19,12 @@ #pragma once -#include "audio/AudioTypes.hpp" +#include +#include + +#include "audio/AudioProperties.hpp" #include "audio/IAudioFileInfo.hpp" +#include "audio/IAudioFileInfoParser.hpp" #include "audio/IImageReader.hpp" #include "audio/ITagReader.hpp" @@ -37,20 +41,20 @@ namespace lms::audio::taglib class AudioFileInfo final : public IAudioFileInfo { public: - AudioFileInfo(const std::filesystem::path& filePath, ParserOptions::AudioPropertiesReadStyle readStyle, bool enableExtraDebugLogs); + AudioFileInfo(const std::filesystem::path& filePath, const AudioFileInfoParseOptions& parseOptions); ~AudioFileInfo() override; AudioFileInfo(const AudioFileInfo&) = delete; AudioFileInfo& operator=(const AudioFileInfo&) = delete; private: - const AudioProperties& getAudioProperties() const override; - const IImageReader& getImageReader() const override; - const ITagReader& getTagReader() const override; + const AudioProperties* getAudioProperties() const override; + const IImageReader* getImageReader() const override; + const ITagReader* getTagReader() const override; const std::filesystem::path _filePath; std::unique_ptr<::TagLib::File> _file; - std::unique_ptr _audioProperties; + std::optional _audioProperties; std::unique_ptr _tagReader; std::unique_ptr _imageReader; }; diff --git a/src/libs/audio/impl/taglib/AudioFileInfoParser.cpp b/src/libs/audio/impl/taglib/AudioFileInfoParser.cpp new file mode 100644 index 00000000..bc87c66e --- /dev/null +++ b/src/libs/audio/impl/taglib/AudioFileInfoParser.cpp @@ -0,0 +1,38 @@ +/* + * 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 "AudioFileInfoParser.hpp" + +#include + +#include "AudioFileInfo.hpp" +#include "Utils.hpp" + +namespace lms::audio::taglib +{ + std::unique_ptr AudioFileInfoParser::parse(const std::filesystem::path& p, const AudioFileInfoParseOptions& parseOptions) const + { + return std::make_unique(p, parseOptions); + } + + std::span AudioFileInfoParser::getSupportedExtensions() const + { + return utils::getSupportedExtensions(); + } +} // namespace lms::audio::taglib \ No newline at end of file diff --git a/src/libs/audio/impl/taglib/AudioFileInfoParser.hpp b/src/libs/audio/impl/taglib/AudioFileInfoParser.hpp new file mode 100644 index 00000000..c6e30cc4 --- /dev/null +++ b/src/libs/audio/impl/taglib/AudioFileInfoParser.hpp @@ -0,0 +1,32 @@ +/* + * 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 "audio/IAudioFileInfoParser.hpp" + +namespace lms::audio::taglib +{ + class AudioFileInfoParser : public IAudioFileInfoParser + { + private: + std::unique_ptr parse(const std::filesystem::path& p, const AudioFileInfoParseOptions& parseOptions = AudioFileInfoParseOptions{}) const override; + std::span getSupportedExtensions() const override; + }; +} // namespace lms::audio::taglib \ No newline at end of file diff --git a/src/libs/audio/impl/taglib/ImageReader.cpp b/src/libs/audio/impl/taglib/ImageReader.cpp index adad0a24..35378549 100644 --- a/src/libs/audio/impl/taglib/ImageReader.cpp +++ b/src/libs/audio/impl/taglib/ImageReader.cpp @@ -44,157 +44,157 @@ namespace lms::audio::taglib { namespace { - Image::Type imageTypeFromfromID3v2(TagLib::ID3v2::AttachedPictureFrame::Type type) + core::media::ImageType imageTypeFromfromID3v2(TagLib::ID3v2::AttachedPictureFrame::Type type) { switch (type) { case ::TagLib::ID3v2::AttachedPictureFrame::Type::Other: - return Image::Type::Other; + return core::media::ImageType::Other; case ::TagLib::ID3v2::AttachedPictureFrame::Type::FileIcon: - return Image::Type::FileIcon; + return core::media::ImageType::FileIcon; case ::TagLib::ID3v2::AttachedPictureFrame::Type::OtherFileIcon: - return Image::Type::OtherFileIcon; + return core::media::ImageType::OtherFileIcon; case ::TagLib::ID3v2::AttachedPictureFrame::Type::FrontCover: - return Image::Type::FrontCover; + return core::media::ImageType::FrontCover; case ::TagLib::ID3v2::AttachedPictureFrame::Type::BackCover: - return Image::Type::BackCover; + return core::media::ImageType::BackCover; case ::TagLib::ID3v2::AttachedPictureFrame::Type::LeafletPage: - return Image::Type::LeafletPage; + return core::media::ImageType::LeafletPage; case ::TagLib::ID3v2::AttachedPictureFrame::Type::Media: - return Image::Type::Media; + return core::media::ImageType::Media; case ::TagLib::ID3v2::AttachedPictureFrame::Type::LeadArtist: - return Image::Type::LeadArtist; + return core::media::ImageType::LeadArtist; case ::TagLib::ID3v2::AttachedPictureFrame::Type::Artist: - return Image::Type::Artist; + return core::media::ImageType::Artist; case ::TagLib::ID3v2::AttachedPictureFrame::Type::Conductor: - return Image::Type::Conductor; + return core::media::ImageType::Conductor; case ::TagLib::ID3v2::AttachedPictureFrame::Type::Band: - return Image::Type::Band; + return core::media::ImageType::Band; case ::TagLib::ID3v2::AttachedPictureFrame::Type::Composer: - return Image::Type::Composer; + return core::media::ImageType::Composer; case ::TagLib::ID3v2::AttachedPictureFrame::Type::Lyricist: - return Image::Type::Lyricist; + return core::media::ImageType::Lyricist; case ::TagLib::ID3v2::AttachedPictureFrame::Type::RecordingLocation: - return Image::Type::RecordingLocation; + return core::media::ImageType::RecordingLocation; case ::TagLib::ID3v2::AttachedPictureFrame::Type::DuringRecording: - return Image::Type::DuringRecording; + return core::media::ImageType::DuringRecording; case ::TagLib::ID3v2::AttachedPictureFrame::Type::DuringPerformance: - return Image::Type::DuringPerformance; + return core::media::ImageType::DuringPerformance; case ::TagLib::ID3v2::AttachedPictureFrame::Type::MovieScreenCapture: - return Image::Type::MovieScreenCapture; + return core::media::ImageType::MovieScreenCapture; case ::TagLib::ID3v2::AttachedPictureFrame::Type::ColouredFish: - return Image::Type::ColouredFish; + return core::media::ImageType::ColouredFish; case ::TagLib::ID3v2::AttachedPictureFrame::Type::Illustration: - return Image::Type::Illustration; + return core::media::ImageType::Illustration; case ::TagLib::ID3v2::AttachedPictureFrame::Type::BandLogo: - return Image::Type::BandLogo; + return core::media::ImageType::BandLogo; case ::TagLib::ID3v2::AttachedPictureFrame::Type::PublisherLogo: - return Image::Type::PublisherLogo; + return core::media::ImageType::PublisherLogo; } - return Image::Type::Unknown; + return core::media::ImageType::Unknown; } - Image::Type imageTypeFromfromASF(TagLib::ASF::Picture::Type type) + core::media::ImageType imageTypeFromfromASF(TagLib::ASF::Picture::Type type) { switch (type) { case ::TagLib::ASF::Picture::Type::Other: - return Image::Type::Other; + return core::media::ImageType::Other; case ::TagLib::ASF::Picture::Type::FileIcon: - return Image::Type::FileIcon; + return core::media::ImageType::FileIcon; case ::TagLib::ASF::Picture::Type::OtherFileIcon: - return Image::Type::OtherFileIcon; + return core::media::ImageType::OtherFileIcon; case ::TagLib::ASF::Picture::Type::FrontCover: - return Image::Type::FrontCover; + return core::media::ImageType::FrontCover; case ::TagLib::ASF::Picture::Type::BackCover: - return Image::Type::BackCover; + return core::media::ImageType::BackCover; case ::TagLib::ASF::Picture::Type::LeafletPage: - return Image::Type::LeafletPage; + return core::media::ImageType::LeafletPage; case ::TagLib::ASF::Picture::Type::Media: - return Image::Type::Media; + return core::media::ImageType::Media; case ::TagLib::ASF::Picture::Type::LeadArtist: - return Image::Type::LeadArtist; + return core::media::ImageType::LeadArtist; case ::TagLib::ASF::Picture::Type::Artist: - return Image::Type::Artist; + return core::media::ImageType::Artist; case ::TagLib::ASF::Picture::Type::Conductor: - return Image::Type::Conductor; + return core::media::ImageType::Conductor; case ::TagLib::ASF::Picture::Type::Band: - return Image::Type::Band; + return core::media::ImageType::Band; case ::TagLib::ASF::Picture::Type::Composer: - return Image::Type::Composer; + return core::media::ImageType::Composer; case ::TagLib::ASF::Picture::Type::Lyricist: - return Image::Type::Lyricist; + return core::media::ImageType::Lyricist; case ::TagLib::ASF::Picture::Type::RecordingLocation: - return Image::Type::RecordingLocation; + return core::media::ImageType::RecordingLocation; case ::TagLib::ASF::Picture::Type::DuringRecording: - return Image::Type::DuringRecording; + return core::media::ImageType::DuringRecording; case ::TagLib::ASF::Picture::Type::DuringPerformance: - return Image::Type::DuringPerformance; + return core::media::ImageType::DuringPerformance; case ::TagLib::ASF::Picture::Type::MovieScreenCapture: - return Image::Type::MovieScreenCapture; + return core::media::ImageType::MovieScreenCapture; case ::TagLib::ASF::Picture::Type::ColouredFish: - return Image::Type::ColouredFish; + return core::media::ImageType::ColouredFish; case ::TagLib::ASF::Picture::Type::Illustration: - return Image::Type::Illustration; + return core::media::ImageType::Illustration; case ::TagLib::ASF::Picture::Type::BandLogo: - return Image::Type::BandLogo; + return core::media::ImageType::BandLogo; case ::TagLib::ASF::Picture::Type::PublisherLogo: - return Image::Type::PublisherLogo; + return core::media::ImageType::PublisherLogo; } - return Image::Type::Unknown; + return core::media::ImageType::Unknown; } - Image::Type imageTypeFromfromFLAC(TagLib::FLAC::Picture::Type type) + core::media::ImageType imageTypeFromfromFLAC(TagLib::FLAC::Picture::Type type) { switch (type) { case ::TagLib::FLAC::Picture::Type::Other: - return Image::Type::Other; + return core::media::ImageType::Other; case ::TagLib::FLAC::Picture::Type::FileIcon: - return Image::Type::FileIcon; + return core::media::ImageType::FileIcon; case ::TagLib::FLAC::Picture::Type::OtherFileIcon: - return Image::Type::OtherFileIcon; + return core::media::ImageType::OtherFileIcon; case ::TagLib::FLAC::Picture::Type::FrontCover: - return Image::Type::FrontCover; + return core::media::ImageType::FrontCover; case ::TagLib::FLAC::Picture::Type::BackCover: - return Image::Type::BackCover; + return core::media::ImageType::BackCover; case ::TagLib::FLAC::Picture::Type::LeafletPage: - return Image::Type::LeafletPage; + return core::media::ImageType::LeafletPage; case ::TagLib::FLAC::Picture::Type::Media: - return Image::Type::Media; + return core::media::ImageType::Media; case ::TagLib::FLAC::Picture::Type::LeadArtist: - return Image::Type::LeadArtist; + return core::media::ImageType::LeadArtist; case ::TagLib::FLAC::Picture::Type::Artist: - return Image::Type::Artist; + return core::media::ImageType::Artist; case ::TagLib::FLAC::Picture::Type::Conductor: - return Image::Type::Conductor; + return core::media::ImageType::Conductor; case ::TagLib::FLAC::Picture::Type::Band: - return Image::Type::Band; + return core::media::ImageType::Band; case ::TagLib::FLAC::Picture::Type::Composer: - return Image::Type::Composer; + return core::media::ImageType::Composer; case ::TagLib::FLAC::Picture::Type::Lyricist: - return Image::Type::Lyricist; + return core::media::ImageType::Lyricist; case ::TagLib::FLAC::Picture::Type::RecordingLocation: - return Image::Type::RecordingLocation; + return core::media::ImageType::RecordingLocation; case ::TagLib::FLAC::Picture::Type::DuringRecording: - return Image::Type::DuringRecording; + return core::media::ImageType::DuringRecording; case ::TagLib::FLAC::Picture::Type::DuringPerformance: - return Image::Type::DuringPerformance; + return core::media::ImageType::DuringPerformance; case ::TagLib::FLAC::Picture::Type::MovieScreenCapture: - return Image::Type::MovieScreenCapture; + return core::media::ImageType::MovieScreenCapture; case ::TagLib::FLAC::Picture::Type::ColouredFish: - return Image::Type::ColouredFish; + return core::media::ImageType::ColouredFish; case ::TagLib::FLAC::Picture::Type::Illustration: - return Image::Type::Illustration; + return core::media::ImageType::Illustration; case ::TagLib::FLAC::Picture::Type::BandLogo: - return Image::Type::BandLogo; + return core::media::ImageType::BandLogo; case ::TagLib::FLAC::Picture::Type::PublisherLogo: - return Image::Type::PublisherLogo; + return core::media::ImageType::PublisherLogo; } - return Image::Type::Unknown; + return core::media::ImageType::Unknown; } const char* mp4ImageFormatToMimeType(TagLib::MP4::CoverArt::Format format) @@ -217,14 +217,14 @@ namespace lms::audio::taglib } #if LMS_TAGLIB_HAS_APE_COMPLEX_PROPERTIES - Image::Type imageTypeFromAPEPictureType(std::string_view pictureType) + core::media::ImageType imageTypeFromAPEPictureType(std::string_view pictureType) { if (core::stringUtils::stringCaseInsensitiveContains(pictureType, "front")) - return Image::Type::FrontCover; + return core::media::ImageType::FrontCover; if (core::stringUtils::stringCaseInsensitiveContains(pictureType, "back")) - return Image::Type::BackCover; + return core::media::ImageType::BackCover; - return Image::Type::Unknown; + return core::media::ImageType::Unknown; } #endif // LMS_TAGLIB_HAS_APE_COMPLEX_PROPERTIES @@ -294,7 +294,7 @@ namespace lms::audio::taglib image.data = pictureData; // By convention, consider the first cover art as the front cover - image.type = firstCover ? Image::Type::FrontCover : Image::Type::Unknown; + image.type = firstCover ? core::media::ImageType::FrontCover : core::media::ImageType::Unknown; firstCover = false; visitor(image); diff --git a/src/libs/audio/impl/taglib/Utils.cpp b/src/libs/audio/impl/taglib/Utils.cpp index aebdaf05..594a8b10 100644 --- a/src/libs/audio/impl/taglib/Utils.cpp +++ b/src/libs/audio/impl/taglib/Utils.cpp @@ -47,7 +47,7 @@ #include "core/ITraceLogger.hpp" #include "core/String.hpp" -#include "audio/IAudioFileInfo.hpp" +#include "audio/Exception.hpp" namespace lms::audio::taglib::utils { @@ -89,15 +89,15 @@ namespace lms::audio::taglib::utils return std::span{ supportedExtensions }; } - TagLib::AudioProperties::ReadStyle readStyleToTagLibReadStyle(ParserOptions::ParserOptions::AudioPropertiesReadStyle readStyle) + TagLib::AudioProperties::ReadStyle readStyleToTagLibReadStyle(AudioFileInfoParseOptions::AudioPropertiesReadStyle readStyle) { switch (readStyle) { - case ParserOptions::AudioPropertiesReadStyle::Fast: + case AudioFileInfoParseOptions::AudioPropertiesReadStyle::Fast: return TagLib::AudioProperties::ReadStyle::Fast; - case ParserOptions::AudioPropertiesReadStyle::Average: + case AudioFileInfoParseOptions::AudioPropertiesReadStyle::Average: return TagLib::AudioProperties::ReadStyle::Average; - case ParserOptions::AudioPropertiesReadStyle::Accurate: + case AudioFileInfoParseOptions::AudioPropertiesReadStyle::Accurate: return TagLib::AudioProperties::ReadStyle::Accurate; } @@ -111,7 +111,7 @@ namespace lms::audio::taglib::utils { const std::error_code ec{ errno, std::generic_category() }; LMS_LOG(METADATA, DEBUG, "fopen failed for " << p << ": " << ec.message()); - throw IOException{ "fopen failed", ec }; + throw IOFileException{ p, "fopen failed", ec }; } int fd{ ::fileno(file) }; @@ -119,7 +119,7 @@ namespace lms::audio::taglib::utils { const std::error_code ec{ errno, std::generic_category() }; LMS_LOG(METADATA, DEBUG, "fileno failed for " << p << ": " << ec.message()); - throw IOException{ "fileno failed", ec }; + throw IOFileException{ p, "fileno failed", ec }; } return TagLib::FileStream{ fd, true }; @@ -236,7 +236,7 @@ namespace lms::audio::taglib::utils return file; } - std::unique_ptr parseFile(const std::filesystem::path& p, ParserOptions::AudioPropertiesReadStyle readStyle) + std::unique_ptr parseFile(const std::filesystem::path& p, AudioFileInfoParseOptions::AudioPropertiesReadStyle readStyle) { LMS_SCOPED_TRACE_DETAILED("MetaData", "TagLibParseFile"); @@ -252,10 +252,7 @@ namespace lms::audio::taglib::utils } if (!file) - throw AudioFileParsingException{ "Parsing failed" }; - - if (!file->audioProperties()) - throw AudioFileParsingException{ "No audio properties" }; + throw Exception{ "Parsing failed" }; return file; } diff --git a/src/libs/audio/impl/taglib/Utils.hpp b/src/libs/audio/impl/taglib/Utils.hpp index f79e96bf..fa614508 100644 --- a/src/libs/audio/impl/taglib/Utils.hpp +++ b/src/libs/audio/impl/taglib/Utils.hpp @@ -25,10 +25,10 @@ #include -#include "audio/IAudioFileInfo.hpp" +#include "audio/IAudioFileInfoParser.hpp" namespace lms::audio::taglib::utils { std::span getSupportedExtensions(); - std::unique_ptr<::TagLib::File> parseFile(const std::filesystem::path& p, ParserOptions::AudioPropertiesReadStyle readStyle); + std::unique_ptr<::TagLib::File> parseFile(const std::filesystem::path& p, AudioFileInfoParseOptions::AudioPropertiesReadStyle readStyle); } // namespace lms::audio::taglib::utils \ No newline at end of file diff --git a/src/libs/audio/include/audio/AudioProperties.hpp b/src/libs/audio/include/audio/AudioProperties.hpp new file mode 100644 index 00000000..07dadd0b --- /dev/null +++ b/src/libs/audio/include/audio/AudioProperties.hpp @@ -0,0 +1,40 @@ +/* + * 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/media/Codec.hpp" +#include "core/media/Container.hpp" + +namespace lms::audio +{ + struct AudioProperties + { + core::media::Container container; + core::media::Codec codec; + std::chrono::milliseconds duration; + unsigned bitrate; + unsigned channelCount; + unsigned sampleRate; + std::optional bitsPerSample; + }; +} // namespace lms::audio \ No newline at end of file diff --git a/src/libs/audio/include/audio/Exception.hpp b/src/libs/audio/include/audio/Exception.hpp index 13f13437..feaa9560 100644 --- a/src/libs/audio/include/audio/Exception.hpp +++ b/src/libs/audio/include/audio/Exception.hpp @@ -19,6 +19,9 @@ #pragma once +#include +#include + #include "core/Exception.hpp" namespace lms::audio @@ -28,4 +31,23 @@ namespace lms::audio public: using LmsException::LmsException; }; + + class IOFileException : public Exception + { + public: + IOFileException(const std::filesystem::path& path, std::string_view message, std::error_code err) + : Exception{ "File '" + path.string() + "': " + std::string{ message } + ": " + err.message() } + , _path{ path } + , _err{ err } + { + } + + const std::filesystem::path& getPath() const { return _path; } + std::error_code getErrorCode() const { return _err; } + + private: + std::filesystem::path _path; + std::error_code _err; + }; + } // namespace lms::audio diff --git a/src/libs/audio/include/audio/IAudioFileInfo.hpp b/src/libs/audio/include/audio/IAudioFileInfo.hpp index b417e78b..adb707e5 100644 --- a/src/libs/audio/include/audio/IAudioFileInfo.hpp +++ b/src/libs/audio/include/audio/IAudioFileInfo.hpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2015 Emeric Poupon + * Copyright (C) 2025 Emeric Poupon * * This file is part of LMS. * @@ -19,14 +19,9 @@ #pragma once -#include -#include - -#include "audio/Exception.hpp" - namespace lms::audio { - class AudioProperties; + struct AudioProperties; class IImageReader; class ITagReader; @@ -35,52 +30,8 @@ namespace lms::audio public: virtual ~IAudioFileInfo() = default; - virtual const AudioProperties& getAudioProperties() const = 0; - virtual const IImageReader& getImageReader() const = 0; - virtual const ITagReader& getTagReader() const = 0; + virtual const AudioProperties* getAudioProperties() const = 0; // may be null even if requested, due to backend limitation + virtual const IImageReader* getImageReader() const = 0; + virtual const ITagReader* getTagReader() const = 0; }; - - class AudioFileParsingException : public Exception - { - public: - using Exception::Exception; - }; - - class IOException : public Exception - { - public: - IOException(std::string_view message, std::error_code err) - : Exception{ std::string{ message } + ": " + err.message() } - , _err{ err } - { - } - - std::error_code getErrorCode() const { return _err; } - - private: - std::error_code _err; - }; - - struct ParserOptions - { - enum class Parser - { - TagLib, - FFmpeg, - }; - - enum class AudioPropertiesReadStyle - { - Fast, - Average, - Accurate, - }; - - Parser parser{ Parser::TagLib }; - AudioPropertiesReadStyle readStyle{ AudioPropertiesReadStyle::Average }; - bool enableExtraDebugLogs{}; - }; - std::unique_ptr parseAudioFile(const std::filesystem::path& p, const ParserOptions& parserOptions = ParserOptions{}); - - std::span getSupportedExtensions(ParserOptions::ParserOptions::Parser parser); } // namespace lms::audio diff --git a/src/libs/audio/include/audio/IAudioFileInfoParser.hpp b/src/libs/audio/include/audio/IAudioFileInfoParser.hpp new file mode 100644 index 00000000..45350209 --- /dev/null +++ b/src/libs/audio/include/audio/IAudioFileInfoParser.hpp @@ -0,0 +1,61 @@ +/* + * 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 + +namespace lms::audio +{ + class IAudioFileInfo; + + struct AudioFileInfoParseOptions + { + enum class AudioPropertiesReadStyle + { + Fast, // fastest + Average, // normal + Accurate, // full scan + }; + + AudioPropertiesReadStyle audioPropertiesReadStyle{ AudioPropertiesReadStyle::Average }; + bool readTags{ true }; + bool readImages{ true }; + + bool enableExtraDebugLogs{}; + }; + + class IAudioFileInfoParser + { + public: + virtual ~IAudioFileInfoParser() = default; + + virtual std::unique_ptr parse(const std::filesystem::path& p, const AudioFileInfoParseOptions& parseOptions = AudioFileInfoParseOptions{}) const = 0; + virtual std::span getSupportedExtensions() const = 0; + }; + + enum class AudioFileInfoParserBackend + { + TagLib, + FFmpeg, + }; + static inline constexpr AudioFileInfoParserBackend defaultAudioFileInfoParserBackend{ AudioFileInfoParserBackend::TagLib }; + std::unique_ptr createAudioFileInfoParser(AudioFileInfoParserBackend backend = defaultAudioFileInfoParserBackend); +} // namespace lms::audio diff --git a/src/libs/audio/include/audio/IImageReader.hpp b/src/libs/audio/include/audio/IImageReader.hpp index 0063151c..8d301b45 100644 --- a/src/libs/audio/include/audio/IImageReader.hpp +++ b/src/libs/audio/include/audio/IImageReader.hpp @@ -23,69 +23,18 @@ #include #include -#include "core/LiteralString.hpp" +#include "core/media/ImageType.hpp" namespace lms::audio { struct Image { - // See TagLib types (based on ID3v2 APIC types) - enum class Type - { - // No information - Unknown, - // A type not enumerated below - Other, - // 32x32 PNG image that should be used as the file icon - FileIcon, - // File icon of a different size or format - OtherFileIcon, - // Front cover image of the album - FrontCover, - // Back cover image of the album - BackCover, - // Inside leaflet page of the album - LeafletPage, - // Image from the album itself - Media, - // Picture of the lead artist or soloist - LeadArtist, - // Picture of the artist or performer - Artist, - // Picture of the conductor - Conductor, - // Picture of the band or orchestra - Band, - // Picture of the composer - Composer, - // Picture of the lyricist or text writer - Lyricist, - // Picture of the recording location or studio - RecordingLocation, - // Picture of the artists during recording - DuringRecording, - // Picture of the artists during performance - DuringPerformance, - // Picture from a movie or video related to the track - MovieScreenCapture, - // Picture of a large, coloured fish - ColouredFish, - // Illustration related to the track - Illustration, - // Logo of the band or performer - BandLogo, - // Logo of the publisher (record company) - PublisherLogo - }; - - Type type{ Type::Unknown }; + core::media::ImageType type{ core::media::ImageType::Unknown }; std::string mimeType{ "application/octet-stream" }; std::string description; std::span data; }; - core::LiteralString imageTypeToString(Image::Type type); - class IImageReader { public: diff --git a/src/libs/audio/include/audio/TranscodeTypes.hpp b/src/libs/audio/include/audio/TranscodeTypes.hpp index 1aaf34cf..0dfcf4b3 100644 --- a/src/libs/audio/include/audio/TranscodeTypes.hpp +++ b/src/libs/audio/include/audio/TranscodeTypes.hpp @@ -23,28 +23,30 @@ #include #include +#include "core/media/Codec.hpp" +#include "core/media/Container.hpp" + +#include "audio/AudioProperties.hpp" + namespace lms::audio { - // TODO deprecate? - enum class OutputFormat - { - MP3, - OGG_OPUS, - MATROSKA_OPUS, - OGG_VORBIS, - WEBM_VORBIS, - }; - struct TranscodeInputParameters { std::filesystem::path filePath; - std::chrono::milliseconds duration{}; // Duration of the audio file - std::chrono::milliseconds offset{}; // Offset in the audio file to start transcoding from + AudioProperties audioProperties; // properties of the audio file + std::chrono::milliseconds offset{}; // Offset in the audio file to start transcoding from + }; + + struct TranscodeOutputFormat + { + core::media::Container container; + core::media::Codec codec; }; struct TranscodeOutputParameters { - std::optional format; + // Not setting a value here means to use the same value as the input + std::optional format; std::optional bitrate; std::optional bitsPerSample; std::optional channelCount; diff --git a/src/libs/core/CMakeLists.txt b/src/libs/core/CMakeLists.txt index d25b4264..d8ddf79d 100644 --- a/src/libs/core/CMakeLists.txt +++ b/src/libs/core/CMakeLists.txt @@ -13,6 +13,10 @@ configure_file( add_library(lmscore STATIC impl/http/Client.cpp impl/http/SendQueue.cpp + impl/media/Codec.cpp + impl/media/Container.cpp + impl/media/ImageType.cpp + impl/media/MimeType.cpp impl/ArchiveZipper.cpp impl/ChildProcess.cpp impl/ChildProcessManager.cpp diff --git a/src/libs/core/impl/Logger.cpp b/src/libs/core/impl/Logger.cpp index a5f20884..fbebb231 100644 --- a/src/libs/core/impl/Logger.cpp +++ b/src/libs/core/impl/Logger.cpp @@ -144,33 +144,33 @@ namespace lms::core::logging { case core::logging::Severity::DEBUG: if (_logFileStream) - addOutputStream(*_logFileStream, { core::logging::Severity::DEBUG }); + addOutputStream(*_logFileStream, core::logging::Severity::DEBUG); else - addOutputStream(std::cout, { core::logging::Severity::DEBUG }); + addOutputStream(std::cout, core::logging::Severity::DEBUG); [[fallthrough]]; case core::logging::Severity::INFO: if (_logFileStream) - addOutputStream(*_logFileStream, { core::logging::Severity::INFO }); + addOutputStream(*_logFileStream, core::logging::Severity::INFO); else - addOutputStream(std::cout, { core::logging::Severity::INFO }); + addOutputStream(std::cout, core::logging::Severity::INFO); [[fallthrough]]; case core::logging::Severity::WARNING: if (_logFileStream) - addOutputStream(*_logFileStream, { core::logging::Severity::WARNING }); + addOutputStream(*_logFileStream, core::logging::Severity::WARNING); else - addOutputStream(std::cerr, { core::logging::Severity::WARNING }); + addOutputStream(std::cerr, core::logging::Severity::WARNING); [[fallthrough]]; case core::logging::Severity::ERROR: if (_logFileStream) - addOutputStream(*_logFileStream, { core::logging::Severity::ERROR }); + addOutputStream(*_logFileStream, core::logging::Severity::ERROR); else - addOutputStream(std::cerr, { core::logging::Severity::ERROR }); + addOutputStream(std::cerr, core::logging::Severity::ERROR); [[fallthrough]]; case core::logging::Severity::FATAL: if (_logFileStream) - addOutputStream(*_logFileStream, { core::logging::Severity::FATAL }); + addOutputStream(*_logFileStream, core::logging::Severity::FATAL); else - addOutputStream(std::cerr, { core::logging::Severity::FATAL }); + addOutputStream(std::cerr, core::logging::Severity::FATAL); break; } } @@ -206,5 +206,4 @@ namespace lms::core::logging std::unique_lock lock{ outputStream->mutex }; outputStream->stream << stringUtils::toISO8601String(now) << " " << std::this_thread::get_id() << " [" << getSeverityName(severity) << "] [" << getModuleName(module) << "] " << message << std::endl; } - } // namespace lms::core::logging \ No newline at end of file diff --git a/src/libs/core/impl/String.cpp b/src/libs/core/impl/String.cpp index 50a9035c..99db1e12 100644 --- a/src/libs/core/impl/String.cpp +++ b/src/libs/core/impl/String.cpp @@ -31,7 +31,7 @@ namespace lms::core::stringUtils { - namespace details + namespace detail { constexpr std::pair jsEscapeChars[]{ { '\\', "\\\\" }, @@ -203,7 +203,7 @@ namespace lms::core::stringUtils return std::distance(std::cbegin(months), str) + 1; } - } // namespace details + } // namespace detail template<> std::optional readAs(std::string_view str) @@ -240,32 +240,32 @@ namespace lms::core::stringUtils std::vector splitString(std::string_view str, std::span separators) { - return details::splitString(str, separators); + return detail::splitString(str, separators); } std::vector splitString(std::string_view str, std::span separators) { - return details::splitString(str, separators); + return detail::splitString(str, separators); } std::string joinStrings(std::span strings, std::string_view delimiter) { - return details::joinStrings(strings, delimiter); + return detail::joinStrings(strings, delimiter); } std::string joinStrings(std::span strings, std::string_view delimiter) { - return details::joinStrings(strings, delimiter); + return detail::joinStrings(strings, delimiter); } std::string joinStrings(std::span strings, char delimiter) { - return details::joinStrings(strings, std::string_view{ &delimiter, 1 }); + return detail::joinStrings(strings, std::string_view{ &delimiter, 1 }); } std::string joinStrings(std::span strings, char delimiter) { - return details::joinStrings(strings, std::string_view{ &delimiter, 1 }); + return detail::joinStrings(strings, std::string_view{ &delimiter, 1 }); } std::string escapeAndJoinStrings(std::span strings, char delimiter, char escapeChar) @@ -434,32 +434,32 @@ namespace lms::core::stringUtils std::string jsEscape(std::string_view str) { - return details::escape(str, details::jsEscapeChars); + return detail::escape(str, detail::jsEscapeChars); } void writeJSEscapedString(std::ostream& os, std::string_view str) { - details::writeEscapedString(os, str, details::jsEscapeChars); + detail::writeEscapedString(os, str, detail::jsEscapeChars); } std::string jsonEscape(std::string_view str) { - return details::escape(str, details::jsonEscapeChars); + return detail::escape(str, detail::jsonEscapeChars); } void writeJsonEscapedString(std::ostream& os, std::string_view str) { - details::writeEscapedString(os, str, details::jsonEscapeChars); + detail::writeEscapedString(os, str, detail::jsonEscapeChars); } std::string xmlEscape(std::string_view str) { - return details::escape(str, details::xmlEscapeChars); + return detail::escape(str, detail::xmlEscapeChars); } void writeXmlEscapedString(std::ostream& os, std::string_view str) { - details::writeEscapedString(os, str, details::xmlEscapeChars); + detail::writeEscapedString(os, str, detail::xmlEscapeChars); } std::string escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar) @@ -611,11 +611,11 @@ namespace lms::core::stringUtils timeStr += ":00"; // Normalize zone - const std::optional offset{ details::getRFC822ZoneOffset(zoneStr) }; + const std::optional offset{ detail::getRFC822ZoneOffset(zoneStr) }; if (!offset) return {}; - std::optional month{ details::getRFC822Month(monthStr) }; + std::optional month{ detail::getRFC822Month(monthStr) }; if (!month) return {}; diff --git a/src/libs/core/impl/media/Codec.cpp b/src/libs/core/impl/media/Codec.cpp new file mode 100644 index 00000000..17ff0a5e --- /dev/null +++ b/src/libs/core/impl/media/Codec.cpp @@ -0,0 +1,68 @@ +/* + * 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 "core/media/Codec.hpp" +#include "core/Exception.hpp" + +#include +#include + +namespace lms::core::media +{ + namespace + { + constexpr std::array codecDescs{ + CodecDesc{ Codec::AAC, "AAC", "Advanced Audio Coding", false }, + CodecDesc{ Codec::AC3, "AC3", "Dolby AC-3", false }, + CodecDesc{ Codec::ALAC, "ALAC", "Apple Lossless Audio Codec", true }, + CodecDesc{ Codec::APE, "APE", "Monkey's Audio", true }, + CodecDesc{ Codec::DSD, "DSD", "Direct Stream Digital", true }, + CodecDesc{ Codec::EAC3, "E-AC3", "Dolby Digital Plus", false }, + CodecDesc{ Codec::FLAC, "FLAC", "Free Lossless Audio Codec", true }, + CodecDesc{ Codec::MP3, "MP3", "MPEG-3", false }, + CodecDesc{ Codec::MP4ALS, "MP4ALS", "MPEG-4 Audio Lossless Coding", true }, + CodecDesc{ Codec::MPC7, "MPC7", "Musepack7", false }, + CodecDesc{ Codec::MPC8, "MPC8", "Musepack8", false }, + CodecDesc{ Codec::Opus, "Opus", "Opus", false }, + CodecDesc{ Codec::PCM, "PCM", "Pulse-code modulation", false }, + CodecDesc{ Codec::Shorten, "Shorten", "Shorten", true }, + CodecDesc{ Codec::TrueAudio, "TTA", "The True Audio", true }, + CodecDesc{ Codec::Vorbis, "Vorbis", "Vorbis", false }, + CodecDesc{ Codec::WavPack, "WavPack", "WavPack", true }, + CodecDesc{ Codec::WMA1, "WMA1", "Windows Media Audio", false }, + CodecDesc{ Codec::WMA2, "WMA2", "Windows Media Audio 2", false }, + CodecDesc{ Codec::WMA9Pro, "WMA9Pro", "Windows Media Audio Professional", false }, + CodecDesc{ Codec::WMA9Lossless, "WMA9Lossless", "Windows Media Audio Lossless", true }, + }; + } // namespace + + void visitCodecs(const std::function& visitor) + { + std::for_each(std::cbegin(codecDescs), std::cend(codecDescs), visitor); + } + + const CodecDesc& getCodecDesc(Codec type) + { + const auto it{ std::find_if(codecDescs.begin(), codecDescs.end(), [type](const auto& desc) { return desc.type == type; }) }; + if (it == codecDescs.end()) [[unlikely]] + throw LmsException{ "Unknown codec type" }; + + return *it; + } +} // namespace lms::core::media diff --git a/src/libs/core/impl/media/Container.cpp b/src/libs/core/impl/media/Container.cpp new file mode 100644 index 00000000..6bb0698e --- /dev/null +++ b/src/libs/core/impl/media/Container.cpp @@ -0,0 +1,58 @@ +/* + * 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 "core/media/Container.hpp" + +namespace lms::core::media +{ + core::LiteralString containerToString(Container type) + { + switch (type) + { + case Container::AIFF: + return "AIFF"; + case Container::APE: + return "APE"; + case Container::ASF: + return "ASF"; + case Container::DSF: + return "DSF"; + case Container::FLAC: + return "FLAC"; + case Container::MP4: + return "MP4"; + case Container::MPC: + return "MPC"; + case Container::MPEG: + return "MPEG"; + case Container::Ogg: + return "Ogg"; + case Container::Shorten: + return "Shorten"; + case Container::TrueAudio: + return "TrueAudio"; + case Container::WAV: + return "WAV"; + case Container::WavPack: + return "WavPack"; + } + + return "Unknown"; + } +} // namespace lms::core::media \ No newline at end of file diff --git a/src/libs/audio/impl/ImageReader.cpp b/src/libs/core/impl/media/ImageType.cpp similarity index 59% rename from src/libs/audio/impl/ImageReader.cpp rename to src/libs/core/impl/media/ImageType.cpp index 7b314875..894f1150 100644 --- a/src/libs/audio/impl/ImageReader.cpp +++ b/src/libs/core/impl/media/ImageType.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2015 Emeric Poupon + * Copyright (C) 2025 Emeric Poupon * * This file is part of LMS. * @@ -17,61 +17,60 @@ * along with LMS. If not, see . */ -#include "audio/IImageReader.hpp" +#include "core/media/ImageType.hpp" -namespace lms::audio +namespace lms::core::media { - core::LiteralString imageTypeToString(Image::Type type) + core::LiteralString imageTypeToString(ImageType type) { switch (type) { - case Image::Type::Other: + case ImageType::Other: return "Other"; - case Image::Type::FileIcon: + case ImageType::FileIcon: return "FileIcon"; - case Image::Type::OtherFileIcon: + case ImageType::OtherFileIcon: return "OtherFileIcon"; - case Image::Type::FrontCover: + case ImageType::FrontCover: return "FrontCover"; - case Image::Type::BackCover: + case ImageType::BackCover: return "BackCover"; - case Image::Type::LeafletPage: + case ImageType::LeafletPage: return "LeafletPage"; - case Image::Type::Media: + case ImageType::Media: return "Media"; - case Image::Type::LeadArtist: + case ImageType::LeadArtist: return "LeadArtist"; - case Image::Type::Artist: + case ImageType::Artist: return "Artist"; - case Image::Type::Conductor: + case ImageType::Conductor: return "Conductor"; - case Image::Type::Band: + case ImageType::Band: return "Band"; - case Image::Type::Composer: + case ImageType::Composer: return "Composer"; - case Image::Type::Lyricist: + case ImageType::Lyricist: return "Lyricist"; - case Image::Type::RecordingLocation: + case ImageType::RecordingLocation: return "RecordingLocation"; - case Image::Type::DuringRecording: + case ImageType::DuringRecording: return "DuringRecording"; - case Image::Type::DuringPerformance: + case ImageType::DuringPerformance: return "DuringPerformance"; - case Image::Type::MovieScreenCapture: + case ImageType::MovieScreenCapture: return "MovieScreenCapture"; - case Image::Type::ColouredFish: + case ImageType::ColouredFish: return "ColouredFish"; - case Image::Type::Illustration: + case ImageType::Illustration: return "Illustration"; - case Image::Type::BandLogo: + case ImageType::BandLogo: return "BandLogo"; - case Image::Type::PublisherLogo: + case ImageType::PublisherLogo: return "PublisherLogo"; - case Image::Type::Unknown: + case ImageType::Unknown: break; } return "Unknown"; } - -} // namespace lms::audio \ No newline at end of file +} // namespace lms::core::media \ No newline at end of file diff --git a/src/libs/core/impl/media/MimeType.cpp b/src/libs/core/impl/media/MimeType.cpp new file mode 100644 index 00000000..ef17cba3 --- /dev/null +++ b/src/libs/core/impl/media/MimeType.cpp @@ -0,0 +1,80 @@ +/* + * 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 "core/media/MimeType.hpp" + +namespace lms::core::media +{ + core::LiteralString getMimeType(Container container, Codec codec) + { + switch (container) + { + case Container::AIFF: + return "audio/x-aiff"; + case Container::APE: + return "audio/x-monkeys-audio"; + case Container::ASF: + return "audio/x-ms-wma"; + case Container::DSF: + return "audio/x-dsd-dsf"; + case Container::FLAC: + return "audio/flac"; + case Container::MP4: + switch (codec) + { + case Codec::AAC: + return "audio/mp4; codecs=\"mp4a.40.2\""; + case Codec::ALAC: + return "audio/mp4; codecs=\"alac\""; + case Codec::MP4ALS: + return "audio/mp4; codecs=\"mp4als\""; + default: + return "audio/mp4"; + } + + case Container::MPC: + return "audio/x-musepack"; + case Container::MPEG: + return "audio/mpeg"; + case Container::Ogg: + switch (codec) + { + case Codec::Opus: + return "audio/opus"; + case Codec::Vorbis: + return "audio/ogg; codecs=\"vorbis\""; + case Codec::FLAC: + return "audio/ogg; codecs=\"flac\""; + default: + return "audio/ogg"; + } + + case Container::Shorten: + return "audio/x-shn"; + case Container::TrueAudio: + return "audio/x-tta"; + case Container::WAV: + return "audio/wav"; + case Container::WavPack: + return "audio/x-wavpack"; + } + + return "application/octet-stream"; + } +} // namespace lms::core::media \ No newline at end of file diff --git a/src/libs/core/include/core/EnumSet.hpp b/src/libs/core/include/core/EnumSet.hpp index dc035610..c2ec5c73 100644 --- a/src/libs/core/include/core/EnumSet.hpp +++ b/src/libs/core/include/core/EnumSet.hpp @@ -19,6 +19,7 @@ #pragma once +#include #include #include #include @@ -51,6 +52,11 @@ namespace lms::core assign(begin, end); } + constexpr std::size_t size() const + { + return std::popcount(_bitfield); + } + template constexpr void assign(It begin, It end) { diff --git a/src/libs/core/include/core/Tuple.hpp b/src/libs/core/include/core/Tuple.hpp index 128ad0e3..c64019c7 100644 --- a/src/libs/core/include/core/Tuple.hpp +++ b/src/libs/core/include/core/Tuple.hpp @@ -23,7 +23,7 @@ namespace lms::core { - namespace details + namespace detail { template struct Seq @@ -45,11 +45,11 @@ namespace lms::core { auto l = { (f(std::get(t)), 0)... }; } - } // namespace details + } // namespace detail template void forEachTypeInTuple(std::tuple const& t, Func f) { - details::forEachTypeInTuple(t, f, details::GenSeq()); + detail::forEachTypeInTuple(t, f, detail::GenSeq()); } } // namespace lms::core \ No newline at end of file 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/audio/include/audio/AudioTypes.hpp b/src/libs/core/include/core/media/Codec.hpp similarity index 61% rename from src/libs/audio/include/audio/AudioTypes.hpp rename to src/libs/core/include/core/media/Codec.hpp index 84478949..06438922 100644 --- a/src/libs/audio/include/audio/AudioTypes.hpp +++ b/src/libs/core/include/core/media/Codec.hpp @@ -19,38 +19,20 @@ #pragma once -#include -#include +#include #include "core/LiteralString.hpp" -namespace lms::audio +namespace lms::core::media { - enum class ContainerType - { - AIFF, - APE, // Monkey's Audio - ASF, // Advanced Systems Format - DSF, - FLAC, - MP4, - MPC, // Musepack - MPEG, - Ogg, - Shorten, - TrueAudio, - WAV, - WavPack, - }; - - core::LiteralString containerTypeToString(ContainerType type); - - enum class CodecType + enum class Codec { AAC, + AC3, ALAC, // Apple Lossless Audio Codec (ALAC) APE, // Monkey's Audio DSD, // DSD + EAC3, FLAC, // Flac MP3, MP4ALS, // MPEG-4 Audio Lossless Coding @@ -68,16 +50,13 @@ namespace lms::audio WMA9Lossless, }; - core::LiteralString codecTypeToString(CodecType type); - - struct AudioProperties + struct CodecDesc { - ContainerType container; - CodecType codec; - std::chrono::milliseconds duration; - unsigned bitrate; - unsigned channelCount; - unsigned sampleRate; - std::optional bitsPerSample; + Codec type; + core::LiteralString name; + core::LiteralString longName; + bool isLossless; }; -} // namespace lms::audio \ No newline at end of file + void visitCodecs(const std::function& visitor); + const CodecDesc& getCodecDesc(Codec type); +} // namespace lms::core::media diff --git a/src/libs/core/include/core/media/Container.hpp b/src/libs/core/include/core/media/Container.hpp new file mode 100644 index 00000000..b53af373 --- /dev/null +++ b/src/libs/core/include/core/media/Container.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 "core/LiteralString.hpp" + +namespace lms::core::media +{ + enum class Container + { + AIFF, + APE, // Monkey's Audio + ASF, // Advanced Systems Format + DSF, + FLAC, + MP4, + MPC, // Musepack + MPEG, + Ogg, + Shorten, + TrueAudio, + WAV, + WavPack, + }; + + core::LiteralString containerToString(Container type); +} // namespace lms::core::media \ No newline at end of file diff --git a/src/libs/core/include/core/media/ImageType.hpp b/src/libs/core/include/core/media/ImageType.hpp new file mode 100644 index 00000000..de710938 --- /dev/null +++ b/src/libs/core/include/core/media/ImageType.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 "core/LiteralString.hpp" + +namespace lms::core::media +{ + // Based on ID3v2 APIC types + enum class ImageType + { + // No information + Unknown, + // A type not enumerated below + Other, + // 32x32 PNG image that should be used as the file icon + FileIcon, + // File icon of a different size or format + OtherFileIcon, + // Front cover image of the album + FrontCover, + // Back cover image of the album + BackCover, + // Inside leaflet page of the album + LeafletPage, + // Image from the album itself + Media, + // Picture of the lead artist or soloist + LeadArtist, + // Picture of the artist or performer + Artist, + // Picture of the conductor + Conductor, + // Picture of the band or orchestra + Band, + // Picture of the composer + Composer, + // Picture of the lyricist or text writer + Lyricist, + // Picture of the recording location or studio + RecordingLocation, + // Picture of the artists during recording + DuringRecording, + // Picture of the artists during performance + DuringPerformance, + // Picture from a movie or video related to the track + MovieScreenCapture, + // Picture of a large, coloured fish + ColouredFish, + // Illustration related to the track + Illustration, + // Logo of the band or performer + BandLogo, + // Logo of the publisher (record company) + PublisherLogo + }; + + core::LiteralString imageTypeToString(ImageType type); +} // namespace lms::core::media \ No newline at end of file diff --git a/src/libs/core/include/core/media/MimeType.hpp b/src/libs/core/include/core/media/MimeType.hpp new file mode 100644 index 00000000..6f24f545 --- /dev/null +++ b/src/libs/core/include/core/media/MimeType.hpp @@ -0,0 +1,29 @@ +/* + * 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 "core/LiteralString.hpp" +#include "core/media/Codec.hpp" +#include "core/media/Container.hpp" + +namespace lms::core::media +{ + core::LiteralString getMimeType(Container container, Codec codec); +} \ No newline at end of file diff --git a/src/libs/core/test/EnumSet.cpp b/src/libs/core/test/EnumSet.cpp index cc37ce8e..6679ccae 100644 --- a/src/libs/core/test/EnumSet.cpp +++ b/src/libs/core/test/EnumSet.cpp @@ -31,9 +31,19 @@ namespace lms::core Two, }; + { + constexpr EnumSet test; + + static_assert(test.size() == 0); + static_assert(test.empty()); + static_assert(!test.contains(Foo::One)); + static_assert(!test.contains(Foo::Two)); + } + { constexpr EnumSet test{ Foo::One }; + static_assert(test.size() == 1); static_assert(!test.empty()); static_assert(test.contains(Foo::One)); static_assert(!test.contains(Foo::Two)); @@ -49,6 +59,8 @@ namespace lms::core constexpr EnumSet test{ Foo::One, Foo::Two }; constexpr auto bitfield{ test.getBitfield() }; + static_assert(test.size() == 2); + EnumSet test2; EXPECT_FALSE(test2.contains(Foo::One)); EXPECT_FALSE(test2.contains(Foo::Two)); diff --git a/src/libs/database/CMakeLists.txt b/src/libs/database/CMakeLists.txt index 4e79b43a..2ba86032 100644 --- a/src/libs/database/CMakeLists.txt +++ b/src/libs/database/CMakeLists.txt @@ -1,4 +1,5 @@ add_library(lmsdatabase STATIC + impl/objects/detail/Types.cpp impl/objects/Artist.cpp impl/objects/ArtistInfo.cpp impl/objects/Artwork.cpp @@ -29,6 +30,7 @@ add_library(lmsdatabase STATIC impl/objects/TrackEmbeddedImage.cpp impl/objects/TrackEmbeddedImageLink.cpp impl/objects/TrackLyrics.cpp + impl/objects/Types.cpp impl/objects/UIState.cpp impl/objects/User.cpp impl/Db.cpp @@ -39,7 +41,6 @@ add_library(lmsdatabase STATIC impl/Session.cpp impl/SqlQuery.cpp impl/Transaction.cpp - impl/Types.cpp impl/Utils.cpp ) diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index cda0492a..639c8f08 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -24,6 +24,7 @@ #include "core/Exception.hpp" #include "core/ILogger.hpp" #include "core/ITraceLogger.hpp" + #include "database/Session.hpp" #include "database/objects/ScanSettings.hpp" @@ -34,7 +35,7 @@ namespace lms::db { namespace { - static constexpr Version LMS_DATABASE_VERSION{ 100 }; + static constexpr Version LMS_DATABASE_VERSION{ 102 }; } VersionInfo::VersionInfo() @@ -1581,6 +1582,105 @@ FROM track)"); constraint "fk_podcast_episode_podcast" foreign key ("podcast_id") references "podcast" ("id") on delete cascade deferrable initially deferred))"); } + void migrateFromV100(Session& session) + { + // Add container and codec to track table + made bits_per_sample optional + + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "track_backup" ( + "id" integer primary key autoincrement, + "version" integer not null, + "scan_version" integer not null, + "absolute_file_path" text not null, + "file_size" bigint not null, + "file_last_write" text, + "file_added" text, + "duration" integer, + "container" integer not null, + "codec" integer not null, + "bitrate" integer not null, + "channel_count" integer not null, + "sample_rate" integer not null, + "bits_per_sample" integer, + "replay_gain" real, + "track_number" integer, + "name" text not null, + "date" text, + "original_date" text, + "mbid" text not null, + "recording_mbid" text not null, + "copyright" text not null, + "copyright_url" text not null, + "artist_display_name" text not null, + "comment" text not null, + "advisory" integer not null, + "medium_id" bigint, + "release_id" bigint, + "media_library_id" bigint, + "directory_id" bigint, + "preferred_artwork_id" bigint, + "preferred_media_artwork_id" bigint, + constraint "fk_track_medium" foreign key ("medium_id") references "medium" ("id") on delete cascade deferrable initially deferred, + constraint "fk_track_release" foreign key ("release_id") references "release" ("id") on delete cascade deferrable initially deferred, + constraint "fk_track_media_library" foreign key ("media_library_id") references "media_library" ("id") on delete set null deferrable initially deferred, + constraint "fk_track_directory" foreign key ("directory_id") references "directory" ("id") on delete cascade deferrable initially deferred, + constraint "fk_track_preferred_artwork" foreign key ("preferred_artwork_id") references "artwork" ("id") on delete set null deferrable initially deferred, + constraint "fk_track_preferred_media_artwork" foreign key ("preferred_media_artwork_id") references "artwork" ("id") on delete set null deferrable initially deferred))"); + + // Copy data from track to track_backup + utils::executeCommand(*session.getDboSession(), R"(INSERT INTO track_backup + SELECT + id, + version, + scan_version, + absolute_file_path, + file_size, + file_last_write, + file_added, + duration, + 0 as container, + 0 as codec, + bitrate, + channel_count, + sample_rate, + CASE WHEN bits_per_sample = 0 THEN NULL ELSE bits_per_sample END as bits_per_sample, + replay_gain, + track_number, + name, + date, + original_date, + mbid, + recording_mbid, + copyright, + copyright_url, + artist_display_name, + comment, + advisory, + medium_id, + release_id, + media_library_id, + directory_id, + preferred_artwork_id, + preferred_media_artwork_id + FROM track)"); + + utils::executeCommand(*session.getDboSession(), "DROP TABLE track"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_backup RENAME TO track"); + + // Just increment the scan version of the settings to make the next scan rescan all audio files + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET audio_scan_version = audio_scan_version + 1"); + } + + void migrateFromV101(Session& session) + { + // Add audio properties for podcast episodes + utils::executeCommand(*session.getDboSession(), "ALTER TABLE podcast_episode ADD container INTEGER NOT NULL DEFAULT(0)"); // 0 means unknown + utils::executeCommand(*session.getDboSession(), "ALTER TABLE podcast_episode ADD codec INTEGER NOT NULL DEFAULT(0)"); // 0 means unknown + utils::executeCommand(*session.getDboSession(), "ALTER TABLE podcast_episode ADD bitrate INTEGER NOT NULL DEFAULT(0)"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE podcast_episode ADD channel_count INTEGER NOT NULL DEFAULT(0)"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE podcast_episode ADD sample_rate INTEGER NOT NULL DEFAULT(0)"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE podcast_episode ADD bits_per_sample INTEGER"); + } + bool doDbMigration(Session& session) { constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" }; @@ -1657,6 +1757,8 @@ FROM track)"); { 97, migrateFromV97 }, { 98, migrateFromV98 }, { 99, migrateFromV99 }, + { 100, migrateFromV100 }, + { 101, migrateFromV101 }, }; bool migrationPerformed{}; diff --git a/src/libs/database/impl/Utils.hpp b/src/libs/database/impl/Utils.hpp index 2292f596..bd8da398 100644 --- a/src/libs/database/impl/Utils.hpp +++ b/src/libs/database/impl/Utils.hpp @@ -42,7 +42,7 @@ namespace lms::db::utils Wt::WDateTime normalizeDateTime(const Wt::WDateTime& dateTime); - namespace details + namespace detail { template void recordQueryPlanIfNeeded(const Query& query) @@ -50,7 +50,7 @@ namespace lms::db::utils if (IQueryPlanRecorder * recorder{ core::Service::get() }) static_cast(recorder)->recordQueryPlanIfNeeded(query.session(), query.asString()); } - } // namespace details + } // namespace detail template void applyRange(Query& query, std::optional range) @@ -100,7 +100,7 @@ namespace lms::db::utils template void forEachQueryResult(const Query& query, UnaryFunc&& func) { - details::recordQueryPlanIfNeeded(query); + detail::recordQueryPlanIfNeeded(query); LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "ForEachQueryResult", "Query", query.asString()); @@ -110,7 +110,7 @@ namespace lms::db::utils template std::vector fetchQueryResults(const Query& query) { - details::recordQueryPlanIfNeeded(query); + detail::recordQueryPlanIfNeeded(query); LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "FetchQueryResults", "Query", query.asString()); @@ -121,7 +121,7 @@ namespace lms::db::utils template std::vector::type> fetchQueryResults(const Query& query) { - details::recordQueryPlanIfNeeded(query); + detail::recordQueryPlanIfNeeded(query); LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "FetchQueryResults", "Query", query.asString()); @@ -132,7 +132,7 @@ namespace lms::db::utils template auto fetchQuerySingleResult(const Query& query) { - details::recordQueryPlanIfNeeded(query); + detail::recordQueryPlanIfNeeded(query); LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "FetchQuerySingleResult", "Query", query.asString()); return query.resultValue(); diff --git a/src/libs/database/impl/objects/Artist.cpp b/src/libs/database/impl/objects/Artist.cpp index f74971d0..15cacc41 100644 --- a/src/libs/database/impl/objects/Artist.cpp +++ b/src/libs/database/impl/objects/Artist.cpp @@ -34,6 +34,7 @@ #include "SqlQuery.hpp" #include "Utils.hpp" +#include "objects/detail/Types.hpp" #include "traits/IdTypeTraits.hpp" #include "traits/StringViewTraits.hpp" @@ -56,6 +57,7 @@ namespace lms::db || params.track.isValid() || params.release.isValid() || params.filters.clusters.size() == 1 + || params.filters.codec.has_value() || params.filters.mediaLibrary.isValid() || params.filters.label.isValid() || params.filters.releaseType.isValid()) @@ -67,6 +69,7 @@ namespace lms::db || params.sortMethod == ArtistSortMethod::AddedDesc || params.writtenAfter.isValid() || params.release.isValid() + || params.filters.codec.has_value() || params.filters.mediaLibrary.isValid() || params.filters.label.isValid() || params.filters.releaseType.isValid()) @@ -79,6 +82,9 @@ namespace lms::db if (params.release.isValid()) query.where("t.release_id = ?").bind(params.release); + if (params.filters.codec.has_value()) + query.where("t.codec = ?").bind(detail::getDbCodec(*params.filters.codec)); + if (params.filters.mediaLibrary.isValid()) query.where("t.media_library_id = ?").bind(params.filters.mediaLibrary); diff --git a/src/libs/database/impl/objects/Listen.cpp b/src/libs/database/impl/objects/Listen.cpp index 8fd3ac05..3c146aa5 100644 --- a/src/libs/database/impl/objects/Listen.cpp +++ b/src/libs/database/impl/objects/Listen.cpp @@ -28,6 +28,7 @@ #include "SqlQuery.hpp" #include "Utils.hpp" +#include "objects/detail/Types.hpp" #include "traits/IdTypeTraits.hpp" DBO_INSTANTIATE_TEMPLATES(lms::db::Listen) @@ -49,11 +50,15 @@ namespace lms::db assert(!params.artist.isValid()); // poor check if (params.filters.mediaLibrary.isValid() + || params.filters.codec.has_value() || params.filters.label.isValid() || params.filters.releaseType.isValid()) { query.join("track t ON t.id = t_a_l.track_id"); + if (params.filters.codec.has_value()) + query.where("t.codec = ?").bind(detail::getDbCodec(*params.filters.codec)); + if (params.filters.mediaLibrary.isValid()) query.where("t.media_library_id = ?").bind(params.filters.mediaLibrary); diff --git a/src/libs/database/impl/objects/PodcastEpisode.cpp b/src/libs/database/impl/objects/PodcastEpisode.cpp index a0d52d91..d831f8da 100644 --- a/src/libs/database/impl/objects/PodcastEpisode.cpp +++ b/src/libs/database/impl/objects/PodcastEpisode.cpp @@ -27,6 +27,7 @@ #include "database/objects/Podcast.hpp" #include "Utils.hpp" +#include "detail/Types.hpp" #include "traits/IdTypeTraits.hpp" #include "traits/PathTraits.hpp" @@ -98,6 +99,16 @@ namespace lms::db utils::forEachQueryRangeResult(query, params.range, func); } + std::optional PodcastEpisode::getContainer() const + { + return detail::getMediaContainerType(_container); + } + + std::optional PodcastEpisode::getCodec() const + { + return detail::getMediaCodecType(_codec); + } + ObjectPtr PodcastEpisode::getArtwork() const { return _artwork; @@ -108,6 +119,16 @@ namespace lms::db return _artwork.id(); } + void PodcastEpisode::setContainer(core::media::Container container) + { + _container = detail::getDbContainer(container); + } + + void PodcastEpisode::setCodec(core::media::Codec codec) + { + _codec = detail::getDbCodec(codec); + } + void PodcastEpisode::setArtwork(ObjectPtr artwork) { _artwork = getDboPtr(artwork); diff --git a/src/libs/database/impl/objects/Release.cpp b/src/libs/database/impl/objects/Release.cpp index dd9cf913..4525289d 100644 --- a/src/libs/database/impl/objects/Release.cpp +++ b/src/libs/database/impl/objects/Release.cpp @@ -40,6 +40,7 @@ #include "SqlQuery.hpp" #include "Utils.hpp" +#include "detail/Types.hpp" #include "traits/EnumSetTraits.hpp" #include "traits/IdTypeTraits.hpp" #include "traits/PartialDateTimeTraits.hpp" @@ -74,6 +75,7 @@ namespace lms::db || params.artist.isValid() || params.filters.clusters.size() == 1 || params.filters.mediaLibrary.isValid() + || params.filters.codec.has_value() || params.directory.isValid() || params.parentDirectory.isValid()) { @@ -217,6 +219,9 @@ namespace lms::db query.where(oss.str()); } + if (params.filters.codec.has_value()) + query.where("t.codec = ?").bind(detail::getDbCodec(params.filters.codec.value())); + if (params.releaseGroupMBID) query.where("group_mbid = ?").bind(params.releaseGroupMBID->getAsString()); @@ -679,6 +684,22 @@ namespace lms::db return utils::fetchQuerySingleResult(session()->query("SELECT COALESCE(AVG(t.bitrate), 0) FROM track t").where("release_id = ?").bind(getId()).where("bitrate > 0")); } + std::vector Release::getCodecs() const + { + assert(session()); + + // Get the codec ordered by frequency + auto query{ session()->query("SELECT t.codec FROM track t").where("release_id = ?").bind(getId()).groupBy("t.codec").orderBy("COUNT(t.id) DESC") }; + + std::vector res; + utils::forEachQueryResult(query, [&](detail::Codec codec) { + if (const auto mediaCodecType{ detail::getMediaCodecType(codec) }) + res.push_back(*mediaCodecType); + }); + + return res; + } + std::vector Release::getArtists(TrackArtistLinkType linkType) const { assert(session()); diff --git a/src/libs/database/impl/objects/Track.cpp b/src/libs/database/impl/objects/Track.cpp index d42dcf48..bd7acaac 100644 --- a/src/libs/database/impl/objects/Track.cpp +++ b/src/libs/database/impl/objects/Track.cpp @@ -23,6 +23,7 @@ #include #include "core/ILogger.hpp" + #include "database/Session.hpp" #include "database/Types.hpp" #include "database/objects/Artist.hpp" @@ -41,6 +42,7 @@ #include "SqlQuery.hpp" #include "Utils.hpp" +#include "objects/detail/Types.hpp" #include "traits/IdTypeTraits.hpp" #include "traits/PartialDateTimeTraits.hpp" #include "traits/PathTraits.hpp" @@ -195,6 +197,9 @@ namespace lms::db query.where("t_e_i_l.track_embedded_image_id = ?").bind(params.embeddedImageId); } + if (params.filters.codec.has_value()) + query.where("t.codec = ?").bind(detail::getDbCodec(*params.filters.codec)); + switch (params.sortMethod) { case TrackSortMethod::None: @@ -523,6 +528,16 @@ namespace lms::db _absoluteFilePath = filePath; } + void Track::setContainer(core::media::Container container) + { + _container = detail::getDbContainer(container); + } + + void Track::setCodec(core::media::Codec codec) + { + _codec = detail::getDbCodec(codec); + } + void Track::setName(std::string_view name) { _name = std::string{ name, 0, _maxNameLength }; @@ -603,6 +618,16 @@ namespace lms::db _preferredMediaArtwork = getDboPtr(artwork); } + std::optional Track::getContainer() const + { + return detail::getMediaContainerType(_container); + } + + std::optional Track::getCodec() const + { + return detail::getMediaCodecType(_codec); + } + std::optional Track::getYear() const { return _date.getYear(); diff --git a/src/libs/database/impl/objects/TrackEmbeddedImage.cpp b/src/libs/database/impl/objects/TrackEmbeddedImage.cpp index c6866f13..507ca4bb 100644 --- a/src/libs/database/impl/objects/TrackEmbeddedImage.cpp +++ b/src/libs/database/impl/objects/TrackEmbeddedImage.cpp @@ -28,6 +28,8 @@ #include "database/objects/TrackEmbeddedImageLink.hpp" #include "Utils.hpp" +#include "detail/Types.hpp" +#include "objects/detail/Types.hpp" #include "traits/IdTypeTraits.hpp" #include "traits/ImageHashTypeTraits.hpp" @@ -83,7 +85,7 @@ namespace lms::db } if (params.imageType.has_value()) - query.where("t_e_i_l.type = ?").bind(params.imageType.value()); + query.where("t_e_i_l.type = ?").bind(detail::getDbImageType(params.imageType.value())); } switch (params.sortMethod) diff --git a/src/libs/database/impl/objects/TrackEmbeddedImageLink.cpp b/src/libs/database/impl/objects/TrackEmbeddedImageLink.cpp index e39b2965..4cd05a08 100644 --- a/src/libs/database/impl/objects/TrackEmbeddedImageLink.cpp +++ b/src/libs/database/impl/objects/TrackEmbeddedImageLink.cpp @@ -23,11 +23,11 @@ #include #include "database/Session.hpp" -#include "database/Types.hpp" #include "database/objects/Track.hpp" #include "database/objects/TrackEmbeddedImage.hpp" #include "Utils.hpp" +#include "detail/Types.hpp" #include "traits/IdTypeTraits.hpp" #include "traits/ImageHashTypeTraits.hpp" @@ -77,4 +77,14 @@ namespace lms::db { return _image; } + + core::media::ImageType TrackEmbeddedImageLink::getType() const + { + return detail::getMediaImageType(_type); + } + + void TrackEmbeddedImageLink::setType(core::media::ImageType type) + { + _type = detail::getDbImageType(type); + } } // namespace lms::db diff --git a/src/libs/database/impl/objects/TrackList.cpp b/src/libs/database/impl/objects/TrackList.cpp index 5ef0393c..2f35e473 100644 --- a/src/libs/database/impl/objects/TrackList.cpp +++ b/src/libs/database/impl/objects/TrackList.cpp @@ -31,6 +31,7 @@ #include "SqlQuery.hpp" #include "Utils.hpp" +#include "objects/detail/Types.hpp" #include "traits/IdTypeTraits.hpp" #include "traits/StringViewTraits.hpp" @@ -49,6 +50,7 @@ namespace lms::db auto query{ session.getDboSession()->query("SELECT " + std::string{ itemToSelect } + " FROM tracklist t_l") }; if (!params.filters.clusters.empty() + || params.filters.codec.has_value() || params.filters.mediaLibrary.isValid() || params.filters.label.isValid() || params.filters.releaseType.isValid()) @@ -61,11 +63,15 @@ namespace lms::db query.where("t_l.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeForLikeKeyword(keyword) + "%"); if (params.filters.mediaLibrary.isValid() + || params.filters.codec.has_value() || params.filters.label.isValid() || params.filters.releaseType.isValid()) { query.join("track t ON t.id = t_l_e.track_id"); + if (params.filters.codec.has_value()) + query.where("t.codec = ?").bind(detail::getDbCodec(*params.filters.codec)); + if (params.filters.mediaLibrary.isValid()) query.where("t.media_library_id = ?").bind(params.filters.mediaLibrary); diff --git a/src/libs/database/impl/Types.cpp b/src/libs/database/impl/objects/Types.cpp similarity index 94% rename from src/libs/database/impl/Types.cpp rename to src/libs/database/impl/objects/Types.cpp index f07a30fd..2ae8bede 100644 --- a/src/libs/database/impl/Types.cpp +++ b/src/libs/database/impl/objects/Types.cpp @@ -17,9 +17,9 @@ * along with LMS. If not, see . */ -#include "database/Types.hpp" +#include "database/objects/Types.hpp" -#include +#include namespace lms::db { @@ -54,7 +54,7 @@ namespace lms::db return "unknown"; } - static const std::set allowedAudioBitrates{ + static const std::unordered_set allowedAudioBitrates{ 64000, 96000, 128000, @@ -72,5 +72,4 @@ namespace lms::db { return allowedAudioBitrates.find(bitrate) != std::cend(allowedAudioBitrates); } - } // namespace lms::db diff --git a/src/libs/database/impl/objects/detail/Types.cpp b/src/libs/database/impl/objects/detail/Types.cpp new file mode 100644 index 00000000..5fe01d0b --- /dev/null +++ b/src/libs/database/impl/objects/detail/Types.cpp @@ -0,0 +1,307 @@ +/* + * Copyright (C) 2015 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 "Types.hpp" + +namespace lms::db::detail +{ + std::optional getMediaContainerType(db::detail::Container container) + { + switch (container) + { + case Container::AIFF: + return core::media::Container::AIFF; + case Container::APE: + return core::media::Container::APE; + case Container::ASF: + return core::media::Container::ASF; + case Container::DSF: + return core::media::Container::DSF; + case Container::FLAC: + return core::media::Container::FLAC; + case Container::MP4: + return core::media::Container::MP4; + case Container::MPC: + return core::media::Container::MPC; + case Container::MPEG: + return core::media::Container::MPEG; + case Container::Ogg: + return core::media::Container::Ogg; + case Container::Shorten: + return core::media::Container::Shorten; + case Container::TrueAudio: + return core::media::Container::TrueAudio; + case Container::WAV: + return core::media::Container::WAV; + case Container::WavPack: + return core::media::Container::WavPack; + + case Container::Unknown: + break; + } + + return std::nullopt; + } + + db::detail::Container getDbContainer(core::media::Container container) + { + switch (container) + { + case core::media::Container::AIFF: + return Container::AIFF; + case core::media::Container::APE: + return Container::APE; + case core::media::Container::ASF: + return Container::ASF; + case core::media::Container::DSF: + return Container::DSF; + case core::media::Container::FLAC: + return Container::FLAC; + case core::media::Container::MP4: + return Container::MP4; + case core::media::Container::MPC: + return Container::MPC; + case core::media::Container::MPEG: + return Container::MPEG; + case core::media::Container::Ogg: + return Container::Ogg; + case core::media::Container::Shorten: + return Container::Shorten; + case core::media::Container::TrueAudio: + return Container::TrueAudio; + case core::media::Container::WAV: + return Container::WAV; + case core::media::Container::WavPack: + return Container::WavPack; + } + + return Container::Unknown; + } + + std::optional getMediaCodecType(db::detail::Codec codec) + { + switch (codec) + { + case Codec::AAC: + return core::media::Codec::AAC; + case Codec::AC3: + return core::media::Codec::AC3; + case Codec::ALAC: + return core::media::Codec::ALAC; + case Codec::APE: + return core::media::Codec::APE; + case Codec::DSD: + return core::media::Codec::DSD; + case Codec::EAC3: + return core::media::Codec::EAC3; + case Codec::FLAC: + return core::media::Codec::FLAC; + case Codec::MP3: + return core::media::Codec::MP3; + case Codec::MP4ALS: + return core::media::Codec::MP4ALS; + case Codec::MPC7: + return core::media::Codec::MPC7; + case Codec::MPC8: + return core::media::Codec::MPC8; + case Codec::Opus: + return core::media::Codec::Opus; + case Codec::PCM: + return core::media::Codec::PCM; + case Codec::Shorten: + return core::media::Codec::Shorten; + case Codec::TrueAudio: + return core::media::Codec::TrueAudio; + case Codec::Vorbis: + return core::media::Codec::Vorbis; + case Codec::WavPack: + return core::media::Codec::WavPack; + case Codec::WMA1: + return core::media::Codec::WMA1; + case Codec::WMA2: + return core::media::Codec::WMA2; + case Codec::WMA9Pro: + return core::media::Codec::WMA9Pro; + case Codec::WMA9Lossless: + return core::media::Codec::WMA9Lossless; + + case Codec::Unknown: + break; + } + + return std::nullopt; + } + + db::detail::Codec getDbCodec(core::media::Codec codec) + { + switch (codec) + { + case core::media::Codec::AAC: + return Codec::AAC; + case core::media::Codec::AC3: + return Codec::AC3; + case core::media::Codec::ALAC: + return Codec::ALAC; + case core::media::Codec::APE: + return Codec::APE; + case core::media::Codec::DSD: + return Codec::DSD; + case core::media::Codec::EAC3: + return Codec::EAC3; + case core::media::Codec::FLAC: + return Codec::FLAC; + case core::media::Codec::MP3: + return Codec::MP3; + case core::media::Codec::MP4ALS: + return Codec::MP4ALS; + case core::media::Codec::MPC7: + return Codec::MPC7; + case core::media::Codec::MPC8: + return Codec::MPC8; + case core::media::Codec::Opus: + return Codec::Opus; + case core::media::Codec::PCM: + return Codec::PCM; + case core::media::Codec::Shorten: + return Codec::Shorten; + case core::media::Codec::TrueAudio: + return Codec::TrueAudio; + case core::media::Codec::Vorbis: + return Codec::Vorbis; + case core::media::Codec::WavPack: + return Codec::WavPack; + case core::media::Codec::WMA1: + return Codec::WMA1; + case core::media::Codec::WMA2: + return Codec::WMA2; + case core::media::Codec::WMA9Pro: + return Codec::WMA9Pro; + case core::media::Codec::WMA9Lossless: + return Codec::WMA9Lossless; + } + + return Codec::Unknown; + } + + core::media::ImageType getMediaImageType(db::detail::ImageType type) + { + switch (type) + { + case db::detail::ImageType::Unknown: + return core::media::ImageType::Unknown; + case db::detail::ImageType::Other: + return core::media::ImageType::Other; + case db::detail::ImageType::FileIcon: + return core::media::ImageType::FileIcon; + case db::detail::ImageType::OtherFileIcon: + return core::media::ImageType::OtherFileIcon; + case db::detail::ImageType::FrontCover: + return core::media::ImageType::FrontCover; + case db::detail::ImageType::BackCover: + return core::media::ImageType::BackCover; + case db::detail::ImageType::LeafletPage: + return core::media::ImageType::LeafletPage; + case db::detail::ImageType::Media: + return core::media::ImageType::Media; + case db::detail::ImageType::LeadArtist: + return core::media::ImageType::LeadArtist; + case db::detail::ImageType::Artist: + return core::media::ImageType::Artist; + case db::detail::ImageType::Conductor: + return core::media::ImageType::Conductor; + case db::detail::ImageType::Band: + return core::media::ImageType::Band; + case db::detail::ImageType::Composer: + return core::media::ImageType::Composer; + case db::detail::ImageType::Lyricist: + return core::media::ImageType::Lyricist; + case db::detail::ImageType::RecordingLocation: + return core::media::ImageType::RecordingLocation; + case db::detail::ImageType::DuringRecording: + return core::media::ImageType::DuringRecording; + case db::detail::ImageType::DuringPerformance: + return core::media::ImageType::DuringPerformance; + case db::detail::ImageType::MovieScreenCapture: + return core::media::ImageType::MovieScreenCapture; + case db::detail::ImageType::ColouredFish: + return core::media::ImageType::ColouredFish; + case db::detail::ImageType::Illustration: + return core::media::ImageType::Illustration; + case db::detail::ImageType::BandLogo: + return core::media::ImageType::BandLogo; + case db::detail::ImageType::PublisherLogo: + return core::media::ImageType::PublisherLogo; + } + + return core::media::ImageType::Unknown; + } + + db::detail::ImageType getDbImageType(core::media::ImageType type) + { + switch (type) + { + case core::media::ImageType::Unknown: + return db::detail::ImageType::Unknown; + case core::media::ImageType::Other: + return db::detail::ImageType::Other; + case core::media::ImageType::FileIcon: + return db::detail::ImageType::FileIcon; + case core::media::ImageType::OtherFileIcon: + return db::detail::ImageType::OtherFileIcon; + case core::media::ImageType::FrontCover: + return db::detail::ImageType::FrontCover; + case core::media::ImageType::BackCover: + return db::detail::ImageType::BackCover; + case core::media::ImageType::LeafletPage: + return db::detail::ImageType::LeafletPage; + case core::media::ImageType::Media: + return db::detail::ImageType::Media; + case core::media::ImageType::LeadArtist: + return db::detail::ImageType::LeadArtist; + case core::media::ImageType::Artist: + return db::detail::ImageType::Artist; + case core::media::ImageType::Conductor: + return db::detail::ImageType::Conductor; + case core::media::ImageType::Band: + return db::detail::ImageType::Band; + case core::media::ImageType::Composer: + return db::detail::ImageType::Composer; + case core::media::ImageType::Lyricist: + return db::detail::ImageType::Lyricist; + case core::media::ImageType::RecordingLocation: + return db::detail::ImageType::RecordingLocation; + case core::media::ImageType::DuringRecording: + return db::detail::ImageType::DuringRecording; + case core::media::ImageType::DuringPerformance: + return db::detail::ImageType::DuringPerformance; + case core::media::ImageType::MovieScreenCapture: + return db::detail::ImageType::MovieScreenCapture; + case core::media::ImageType::ColouredFish: + return db::detail::ImageType::ColouredFish; + case core::media::ImageType::Illustration: + return db::detail::ImageType::Illustration; + case core::media::ImageType::BandLogo: + return db::detail::ImageType::BandLogo; + case core::media::ImageType::PublisherLogo: + return db::detail::ImageType::PublisherLogo; + } + + return db::detail::ImageType::Unknown; + } +} // namespace lms::db::detail \ No newline at end of file diff --git a/src/libs/database/impl/objects/detail/Types.hpp b/src/libs/database/impl/objects/detail/Types.hpp new file mode 100644 index 00000000..ba7dc789 --- /dev/null +++ b/src/libs/database/impl/objects/detail/Types.hpp @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2015 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 "core/media/Codec.hpp" +#include "core/media/Container.hpp" +#include "core/media/ImageType.hpp" + +#include "database/objects/detail/Types.hpp" + +namespace lms::db::detail +{ + std::optional getMediaContainerType(db::detail::Container container); + db::detail::Container getDbContainer(core::media::Container container); + + std::optional getMediaCodecType(db::detail::Codec codec); + db::detail::Codec getDbCodec(core::media::Codec codec); + + core::media::ImageType getMediaImageType(db::detail::ImageType type); + db::detail::ImageType getDbImageType(core::media::ImageType type); +} // namespace lms::db::detail \ No newline at end of file diff --git a/src/libs/database/include/database/Types.hpp b/src/libs/database/include/database/Types.hpp index 37bfcfa4..46c1661d 100644 --- a/src/libs/database/include/database/Types.hpp +++ b/src/libs/database/include/database/Types.hpp @@ -21,14 +21,10 @@ #include #include -#include -#include #include #include "core/Exception.hpp" -#include "core/LiteralString.hpp" -#include "core/TaggedType.hpp" namespace lms::db { @@ -113,243 +109,4 @@ namespace lms::db std::size_t getTotalFileCount() const { return trackCount + imageCount + trackLyricsCount + playListCount + artistInfoCount; } }; - - struct YearRange - { - int begin{}; - int end{}; - }; - - struct FileInfo - { - Wt::WDateTime lastWrittenTime; - std::size_t scanVersion{}; - }; - - enum class ArtistSortMethod - { - None, - Id, - Name, - SortName, - Random, - LastWrittenDesc, - AddedDesc, - StarredDateDesc, - }; - - enum class ClusterSortMethod - { - None, - Name, - }; - - enum class DirectorySortMethod - { - None, - Name, - }; - - using ImageHashType = core::TaggedType; - - enum class LabelSortMethod - { - None, - Name, - }; - - enum class MediumSortMethod - { - None, - PositionAsc, - }; - - enum class PodcastEpisodeSortMode - { - None, - PubDateAsc, - PubDateDesc, - }; - - enum class ReleaseSortMethod - { - None, - Id, - Name, - SortName, - ArtistNameThenName, - DateAsc, - DateDesc, - OriginalDate, - OriginalDateDesc, - Random, - LastWrittenDesc, - AddedDesc, - StarredDateDesc, - }; - - enum class ReleaseTypeSortMethod - { - None, - Name, - }; - - enum class TrackArtistLinkSortMethod - { - None, - OriginalDateDesc, - }; - - enum class TrackEmbeddedImageSortMethod - { - None, - SizeDesc, - TrackNumberThenSizeDesc, - DiscNumberThenTrackNumberThenSizeDesc, - TrackListIndexAscThenSizeDesc, - }; - - enum class TrackListSortMethod - { - None, - Name, - LastModifiedDesc, - }; - - enum class TrackSortMethod - { - None, - Id, - Random, - LastWrittenDesc, - AddedDesc, - StarredDateDesc, - AbsoluteFilePath, - Name, - DateDescAndRelease, - OriginalDateDescAndRelease, - Release, // order by disc/track number - TrackList, // order by asc order in tracklist - TrackNumber, - }; - - enum class TrackLyricsSortMethod - { - None, - ExternalFirst, - EmbeddedFirst, - }; - - enum class ImageType - { - Unknown = 0, - Other = 1, - FileIcon = 2, - OtherFileIcon = 3, - FrontCover = 4, - BackCover = 5, - LeafletPage = 6, - Media = 7, - LeadArtist = 8, - Artist = 9, - Conductor = 10, - Band = 11, - Composer = 12, - Lyricist = 13, - RecordingLocation = 14, - DuringRecording = 15, - DuringPerformance = 16, - MovieScreenCapture = 17, - ColouredFish = 18, - Illustration = 19, - BandLogo = 20, - PublisherLogo = 21 - }; - - enum class TrackArtistLinkType - { - Artist = 0, // regular track artist - Arranger = 1, - Composer = 2, - Conductor = 3, - Lyricist = 4, - Mixer = 5, - Performer = 6, - Producer = 7, - ReleaseArtist = 8, - Remixer = 9, - Writer = 10, - }; - - core::LiteralString trackArtistLinkTypeToString(TrackArtistLinkType type); - - // User selectable transcoding output formats - enum class TranscodingOutputFormat - { - MP3 = 1, - OGG_OPUS = 2, - OGG_VORBIS = 3, - WEBM_VORBIS = 4, - MATROSKA_OPUS = 5, - }; - - using Bitrate = std::uint32_t; - // Do not remove values! - void visitAllowedAudioBitrates(std::function); - bool isAudioBitrateAllowed(Bitrate bitrate); - - using Rating = int; - - enum class ScrobblingBackend - { - Internal = 0, - ListenBrainz = 1, - }; - - enum class FeedbackBackend - { - Internal = 0, - ListenBrainz = 1, - }; - - enum class SyncState - { - PendingAdd = 0, - Synchronized = 1, - PendingRemove = 2, - }; - - enum class UserType - { - REGULAR = 0, - ADMIN = 1, - DEMO = 2, - }; - - enum class UITheme - { - Light = 0, - Dark = 1, - }; - - enum class SubsonicArtistListMode - { - AllArtists = 0, - ReleaseArtists = 1, - TrackArtists = 2, - }; - - enum class TrackListType - { - PlayList = 0, // user controlled playlists - Internal = 1, // internal usage (current playqueue, history, ...) - }; - - enum class Advisory - { - UnSet = 0, - Unknown = 1, - Clean = 2, - Explicit = 3, - }; } // namespace lms::db diff --git a/src/libs/database/include/database/objects/Artist.hpp b/src/libs/database/include/database/objects/Artist.hpp index 7cae8afd..21d9e96e 100644 --- a/src/libs/database/include/database/objects/Artist.hpp +++ b/src/libs/database/include/database/objects/Artist.hpp @@ -41,6 +41,7 @@ #include "database/objects/MediaLibraryId.hpp" #include "database/objects/ReleaseId.hpp" #include "database/objects/TrackId.hpp" +#include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" namespace lms::db diff --git a/src/libs/database/include/database/objects/Cluster.hpp b/src/libs/database/include/database/objects/Cluster.hpp index f30e343d..01a60648 100644 --- a/src/libs/database/include/database/objects/Cluster.hpp +++ b/src/libs/database/include/database/objects/Cluster.hpp @@ -33,6 +33,7 @@ #include "database/objects/ClusterId.hpp" #include "database/objects/Release.hpp" #include "database/objects/TrackId.hpp" +#include "database/objects/Types.hpp" namespace lms::db { diff --git a/src/libs/database/include/database/objects/Directory.hpp b/src/libs/database/include/database/objects/Directory.hpp index 3174f235..dd8ac918 100644 --- a/src/libs/database/include/database/objects/Directory.hpp +++ b/src/libs/database/include/database/objects/Directory.hpp @@ -29,6 +29,7 @@ #include #include "core/EnumSet.hpp" + #include "database/Object.hpp" #include "database/Types.hpp" #include "database/objects/ArtistId.hpp" @@ -36,6 +37,7 @@ #include "database/objects/MediaLibraryId.hpp" #include "database/objects/MediumId.hpp" #include "database/objects/ReleaseId.hpp" +#include "database/objects/Types.hpp" namespace lms::db { diff --git a/src/libs/database/include/database/objects/Filters.hpp b/src/libs/database/include/database/objects/Filters.hpp index 1d95a09f..a31d9de8 100644 --- a/src/libs/database/include/database/objects/Filters.hpp +++ b/src/libs/database/include/database/objects/Filters.hpp @@ -19,9 +19,12 @@ #pragma once +#include #include #include +#include "core/media/Codec.hpp" + #include "database/objects/ClusterId.hpp" #include "database/objects/LabelId.hpp" #include "database/objects/MediaLibraryId.hpp" @@ -31,10 +34,11 @@ namespace lms::db { struct Filters { - MediaLibraryId mediaLibrary; // tracks that belongs to this library - std::vector clusters; // tracks that belong to all these clusters - LabelId label; // tracks which release has this label - ReleaseTypeId releaseType; // tracks which release has this type + MediaLibraryId mediaLibrary; // tracks that belongs to this library + std::vector clusters; // tracks that belong to *all* these clusters + LabelId label; // tracks which release has this label + ReleaseTypeId releaseType; // tracks which release has this type + std::optional codec; // tracks that match this codec Filters& setClusters(std::span _clusters) { @@ -56,5 +60,10 @@ namespace lms::db releaseType = _releaseType; return *this; } + Filters& setCodec(std::optional _codec) + { + codec = _codec; + return *this; + } }; } // namespace lms::db \ No newline at end of file diff --git a/src/libs/database/include/database/objects/Listen.hpp b/src/libs/database/include/database/objects/Listen.hpp index b6882fa2..13d6f77a 100644 --- a/src/libs/database/include/database/objects/Listen.hpp +++ b/src/libs/database/include/database/objects/Listen.hpp @@ -31,6 +31,7 @@ #include "database/objects/ListenId.hpp" #include "database/objects/ReleaseId.hpp" #include "database/objects/TrackId.hpp" +#include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" namespace lms::db diff --git a/src/libs/database/include/database/objects/Medium.hpp b/src/libs/database/include/database/objects/Medium.hpp index 97e8a64e..55c3dff5 100644 --- a/src/libs/database/include/database/objects/Medium.hpp +++ b/src/libs/database/include/database/objects/Medium.hpp @@ -33,6 +33,7 @@ #include "database/objects/ArtworkId.hpp" #include "database/objects/MediumId.hpp" #include "database/objects/ReleaseId.hpp" +#include "database/objects/Types.hpp" namespace lms::db { diff --git a/src/libs/database/include/database/objects/PlayQueue.hpp b/src/libs/database/include/database/objects/PlayQueue.hpp index 845db082..981b603f 100644 --- a/src/libs/database/include/database/objects/PlayQueue.hpp +++ b/src/libs/database/include/database/objects/PlayQueue.hpp @@ -59,7 +59,8 @@ namespace lms::db // Get tracks, ordered by position std::vector getTrackIds() const; void visitTracks(const std::function& track)>& visitor) const; - const Wt::WDateTime getLastModifiedDateTime() const { return _lastModifiedDateTime; } + Wt::WDateTime getLastModifiedDateTime() const { return _lastModifiedDateTime; } + bool isEmpty() const { return _tracks.empty(); } // Modifiers void setCurrentIndex(std::size_t index) { _currentIndex = index; } diff --git a/src/libs/database/include/database/objects/PodcastEpisode.hpp b/src/libs/database/include/database/objects/PodcastEpisode.hpp index 40389793..eb226ca2 100644 --- a/src/libs/database/include/database/objects/PodcastEpisode.hpp +++ b/src/libs/database/include/database/objects/PodcastEpisode.hpp @@ -29,11 +29,16 @@ #include #include +#include "core/media/Codec.hpp" +#include "core/media/Container.hpp" + #include "database/Object.hpp" #include "database/Types.hpp" #include "database/objects/ArtworkId.hpp" #include "database/objects/PodcastEpisodeId.hpp" #include "database/objects/PodcastId.hpp" +#include "database/objects/Types.hpp" +#include "database/objects/detail/Types.hpp" namespace lms::db { @@ -90,6 +95,15 @@ namespace lms::db ManualDownloadState getManualDownloadState() const { return _manualDownloadState; } const std::filesystem::path& getAudioRelativeFilePath() const { return _audioRelativeFilePath; } + // Audio properties + std::chrono::milliseconds getDuration() const { return _duration; } + std::optional getContainer() const; + std::optional getCodec() const; + std::size_t getBitrate() const { return _bitrate; } + std::size_t getChannelCount() const { return _channelCount; } + std::size_t getSampleRate() const { return _sampleRate; } + std::optional getBitsPerSample() const { return _bitsPerSample; } + std::string_view getTitle() const { return _title; } std::string_view getLink() const { return _link; } std::string_view getDescription() const { return _description; } @@ -103,7 +117,6 @@ namespace lms::db std::string_view getSubtitle() const { return _subtitle; } std::string_view getSummary() const { return _summary; } bool isExplicit() const { return _explicit; } - std::chrono::duration getDuration() const { return _duration; } ObjectPtr getPodcast() const { return _podcast; } PodcastId getPodcastId() const { return _podcast.id(); } ObjectPtr getArtwork() const; @@ -113,6 +126,15 @@ namespace lms::db void setManualDownloadState(ManualDownloadState state) { _manualDownloadState = state; } void setAudioRelativeFilePath(const std::filesystem::path& relativeFilePath) { _audioRelativeFilePath = relativeFilePath; } + // Audio properties + void setDuration(std::chrono::milliseconds duration) { _duration = duration; } + void setContainer(core::media::Container container); + void setCodec(core::media::Codec codec); + void setBitrate(std::size_t bitrate) { _bitrate = bitrate; } + void setChannelCount(std::size_t channelCount) { _channelCount = channelCount; } + void setSampleRate(std::size_t sampleRate) { _sampleRate = sampleRate; } + void setBitsPerSample(std::optional bitsPerSample) { _bitsPerSample = bitsPerSample; } + void setTitle(std::string_view title) { _title = title; } void setLink(std::string_view link) { _link = link; } void setDescription(std::string_view description) { _description = description; } @@ -135,6 +157,14 @@ namespace lms::db Wt::Dbo::field(a, _manualDownloadState, "manual_download_state"); Wt::Dbo::field(a, _audioRelativeFilePath, "audio_relative_file_path"); + Wt::Dbo::field(a, _duration, "duration"); + Wt::Dbo::field(a, _container, "container"); + Wt::Dbo::field(a, _codec, "codec"); + Wt::Dbo::field(a, _bitrate, "bitrate"); + Wt::Dbo::field(a, _channelCount, "channel_count"); + Wt::Dbo::field(a, _sampleRate, "sample_rate"); + Wt::Dbo::field(a, _bitsPerSample, "bits_per_sample"); + Wt::Dbo::field(a, _title, "title"); Wt::Dbo::field(a, _link, "link"); Wt::Dbo::field(a, _description, "description"); @@ -148,7 +178,6 @@ namespace lms::db Wt::Dbo::field(a, _subtitle, "subtitle"); Wt::Dbo::field(a, _summary, "summary"); Wt::Dbo::field(a, _explicit, "explicit"); - Wt::Dbo::field(a, _duration, "duration"); Wt::Dbo::belongsTo(a, _artwork, "artwork", Wt::Dbo::OnDeleteSetNull); Wt::Dbo::belongsTo(a, _podcast, "podcast", Wt::Dbo::OnDeleteCascade); @@ -162,6 +191,15 @@ namespace lms::db ManualDownloadState _manualDownloadState{ ManualDownloadState::None }; std::filesystem::path _audioRelativeFilePath; // relative to cache dir, only set if downloaded + // Audio properties + std::chrono::duration _duration{ 0 }; + detail::Container _container{ detail::Container::Unknown }; + detail::Codec _codec{ detail::Codec::Unknown }; + int _bitrate{}; // in bps + int _channelCount{}; + int _sampleRate{}; + std::optional _bitsPerSample; + std::string _url; std::string _title; std::string _link; @@ -178,7 +216,6 @@ namespace lms::db std::string _subtitle; std::string _summary; bool _explicit{}; - std::chrono::duration _duration{ 0 }; Wt::Dbo::ptr _artwork; Wt::Dbo::ptr _podcast; diff --git a/src/libs/database/include/database/objects/RatedArtist.hpp b/src/libs/database/include/database/objects/RatedArtist.hpp index 0a336d08..f007c493 100644 --- a/src/libs/database/include/database/objects/RatedArtist.hpp +++ b/src/libs/database/include/database/objects/RatedArtist.hpp @@ -28,6 +28,7 @@ #include "database/Types.hpp" #include "database/objects/ArtistId.hpp" #include "database/objects/RatedArtistId.hpp" +#include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" namespace lms::db diff --git a/src/libs/database/include/database/objects/RatedRelease.hpp b/src/libs/database/include/database/objects/RatedRelease.hpp index 4096f380..3b0cf162 100644 --- a/src/libs/database/include/database/objects/RatedRelease.hpp +++ b/src/libs/database/include/database/objects/RatedRelease.hpp @@ -28,6 +28,7 @@ #include "database/Types.hpp" #include "database/objects/RatedReleaseId.hpp" #include "database/objects/ReleaseId.hpp" +#include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" namespace lms::db diff --git a/src/libs/database/include/database/objects/RatedTrack.hpp b/src/libs/database/include/database/objects/RatedTrack.hpp index de944e5b..878b935f 100644 --- a/src/libs/database/include/database/objects/RatedTrack.hpp +++ b/src/libs/database/include/database/objects/RatedTrack.hpp @@ -28,6 +28,7 @@ #include "database/Types.hpp" #include "database/objects/RatedTrackId.hpp" #include "database/objects/TrackId.hpp" +#include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" namespace lms::db diff --git a/src/libs/database/include/database/objects/Release.hpp b/src/libs/database/include/database/objects/Release.hpp index 7fe7c4c2..360b20d7 100644 --- a/src/libs/database/include/database/objects/Release.hpp +++ b/src/libs/database/include/database/objects/Release.hpp @@ -31,6 +31,8 @@ #include "core/EnumSet.hpp" #include "core/PartialDateTime.hpp" #include "core/UUID.hpp" +#include "core/media/Codec.hpp" + #include "database/IdRange.hpp" #include "database/Object.hpp" #include "database/Types.hpp" @@ -43,6 +45,7 @@ #include "database/objects/MediaLibraryId.hpp" #include "database/objects/ReleaseId.hpp" #include "database/objects/ReleaseTypeId.hpp" +#include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" namespace lms::db @@ -277,6 +280,7 @@ namespace lms::db std::optional getCopyright() const; std::optional getCopyrightURL() const; std::size_t getMeanBitrate() const; + std::vector getCodecs() const; // Accessors std::string_view getName() const { return _name; } diff --git a/src/libs/database/include/database/objects/StarredArtist.hpp b/src/libs/database/include/database/objects/StarredArtist.hpp index 3bca8939..3b91942a 100644 --- a/src/libs/database/include/database/objects/StarredArtist.hpp +++ b/src/libs/database/include/database/objects/StarredArtist.hpp @@ -26,6 +26,7 @@ #include "database/Types.hpp" #include "database/objects/ArtistId.hpp" #include "database/objects/StarredArtistId.hpp" +#include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" namespace lms::db diff --git a/src/libs/database/include/database/objects/StarredRelease.hpp b/src/libs/database/include/database/objects/StarredRelease.hpp index 6f172d38..3f81c8de 100644 --- a/src/libs/database/include/database/objects/StarredRelease.hpp +++ b/src/libs/database/include/database/objects/StarredRelease.hpp @@ -26,6 +26,7 @@ #include "database/Types.hpp" #include "database/objects/ReleaseId.hpp" #include "database/objects/StarredReleaseId.hpp" +#include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" namespace lms::db diff --git a/src/libs/database/include/database/objects/StarredTrack.hpp b/src/libs/database/include/database/objects/StarredTrack.hpp index 352b1ba7..9a6b1c58 100644 --- a/src/libs/database/include/database/objects/StarredTrack.hpp +++ b/src/libs/database/include/database/objects/StarredTrack.hpp @@ -28,6 +28,7 @@ #include "database/Types.hpp" #include "database/objects/StarredTrackId.hpp" #include "database/objects/TrackId.hpp" +#include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" namespace lms::db diff --git a/src/libs/database/include/database/objects/Track.hpp b/src/libs/database/include/database/objects/Track.hpp index 1e410d7d..e5fc2efa 100644 --- a/src/libs/database/include/database/objects/Track.hpp +++ b/src/libs/database/include/database/objects/Track.hpp @@ -34,6 +34,9 @@ #include "core/EnumSet.hpp" #include "core/PartialDateTime.hpp" #include "core/UUID.hpp" +#include "core/media/Codec.hpp" +#include "core/media/Container.hpp" + #include "database/IdRange.hpp" #include "database/Object.hpp" #include "database/Types.hpp" @@ -48,7 +51,9 @@ #include "database/objects/TrackEmbeddedImageId.hpp" #include "database/objects/TrackId.hpp" #include "database/objects/TrackListId.hpp" +#include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" +#include "database/objects/detail/Types.hpp" namespace lms::db { @@ -216,19 +221,28 @@ namespace lms::db static void updatePreferredArtwork(Session& session, TrackId trackId, ArtworkId artworkId); static void updatePreferredMediaArtwork(Session& session, TrackId trackId, ArtworkId artworkId); - // Accessors + // Setters void setScanVersion(std::size_t version) { _scanVersion = version; } - void setTrackNumber(std::optional num) { _trackNumber = num; } - void setName(std::string_view name); + + // File properties void setAbsoluteFilePath(const std::filesystem::path& filePath); void setFileSize(std::size_t fileSize) { _fileSize = fileSize; } void setLastWriteTime(const Wt::WDateTime& time) { _fileLastWrite = time; } void setAddedTime(const Wt::WDateTime& time) { _fileAdded = time; } - void setBitrate(std::size_t bitrate) { _bitrate = bitrate; } - void setBitsPerSample(std::size_t bitsPerSample) { _bitsPerSample = bitsPerSample; } + + // Audio properties void setDuration(std::chrono::milliseconds duration) { _duration = duration; } + void setContainer(core::media::Container container); + void setCodec(core::media::Codec codec); + void setBitrate(std::size_t bitrate) { _bitrate = bitrate; } void setChannelCount(std::size_t channelCount) { _channelCount = channelCount; } - void setSampleRate(std::size_t channelCount) { _sampleRate = channelCount; } + void setSampleRate(std::size_t sampleRate) { _sampleRate = sampleRate; } + void setBitsPerSample(std::optional bitsPerSample) { _bitsPerSample = bitsPerSample; } + void setReplayGain(std::optional replayGain) { _replayGain = replayGain; } + + // Metadata + void setTrackNumber(std::optional num) { _trackNumber = num; } + void setName(std::string_view name); void setDate(const core::PartialDateTime& date) { _date = date; } void setOriginalDate(const core::PartialDateTime& date) { _originalDate = date; } void setTrackMBID(const std::optional& MBID) { _trackMBID = MBID ? MBID->getAsString() : ""; } @@ -236,7 +250,6 @@ namespace lms::db void setCopyright(std::string_view copyright); void setCopyrightURL(std::string_view copyrightURL); void setAdvisory(Advisory advisory) { _advisory = advisory; } - void setReplayGain(std::optional replayGain) { _replayGain = replayGain; } void setArtistDisplayName(std::string_view name) { _artistDisplayName = name; } void setComment(std::string_view comment) { _comment = comment; } void clearArtistLinks(); @@ -254,32 +267,41 @@ namespace lms::db void setPreferredArtwork(ObjectPtr artwork); void setPreferredMediaArtwork(ObjectPtr artwork); + // Getters std::size_t getScanVersion() const { return _scanVersion; } - std::optional getTrackNumber() const { return _trackNumber; } - std::string getName() const { return _name; } + + // File properties const std::filesystem::path& getAbsoluteFilePath() const { return _absoluteFilePath; } long long getFileSize() const { return _fileSize; } - std::size_t getBitrate() const { return _bitrate; } - std::size_t getBitsPerSample() const { return _bitsPerSample; } - std::size_t getChannelCount() const { return _channelCount; } - std::chrono::milliseconds getDuration() const { return _duration; } - std::size_t getSampleRate() const { return _sampleRate; } const Wt::WDateTime& getLastWritten() const { return _fileLastWrite; } + const Wt::WDateTime& getAddedTime() const { return _fileAdded; } + + // Audio properties + std::chrono::milliseconds getDuration() const { return _duration; } + std::optional getContainer() const; + std::optional getCodec() const; + std::size_t getBitrate() const { return _bitrate; } + std::size_t getChannelCount() const { return _channelCount; } + std::size_t getSampleRate() const { return _sampleRate; } + std::optional getBitsPerSample() const { return _bitsPerSample; } + std::optional getReplayGain() const { return _replayGain; } + + // Metadata + std::optional getTrackNumber() const { return _trackNumber; } + std::string getName() const { return _name; } const core::PartialDateTime& getDate() const { return _date; } std::optional getYear() const; const core::PartialDateTime& getOriginalDate() const { return _originalDate; } std::optional getOriginalYear() const; const Wt::WDateTime& getLastWriteTime() const { return _fileLastWrite; } - const Wt::WDateTime& getAddedTime() const { return _fileAdded; } bool hasLyrics() const; std::optional getTrackMBID() const { return core::UUID::fromString(_trackMBID); } std::optional getRecordingMBID() const { return core::UUID::fromString(_recordingMBID); } std::optional getCopyright() const; std::optional getCopyrightURL() const; - Advisory getAdvisory() const { return _advisory; } - std::optional getReplayGain() const { return _replayGain; } std::string_view getArtistDisplayName() const { return _artistDisplayName; } std::string_view getComment() const { return _comment; } + Advisory getAdvisory() const { return _advisory; } // no artistLinkTypes means get all std::vector> getArtists(core::EnumSet artistLinkTypes) const; // no type means all @@ -304,27 +326,32 @@ namespace lms::db void persist(Action& a) { Wt::Dbo::field(a, _scanVersion, "scan_version"); - Wt::Dbo::field(a, _trackNumber, "track_number"); - Wt::Dbo::field(a, _name, "name"); - Wt::Dbo::field(a, _duration, "duration"); - Wt::Dbo::field(a, _bitrate, "bitrate"); - Wt::Dbo::field(a, _bitsPerSample, "bits_per_sample"); - Wt::Dbo::field(a, _channelCount, "channel_count"); - Wt::Dbo::field(a, _sampleRate, "sample_rate"); - Wt::Dbo::field(a, _date, "date"); - Wt::Dbo::field(a, _originalDate, "original_date"); + Wt::Dbo::field(a, _absoluteFilePath, "absolute_file_path"); Wt::Dbo::field(a, _fileSize, "file_size"); Wt::Dbo::field(a, _fileLastWrite, "file_last_write"); Wt::Dbo::field(a, _fileAdded, "file_added"); + + Wt::Dbo::field(a, _duration, "duration"); + Wt::Dbo::field(a, _container, "container"); + Wt::Dbo::field(a, _codec, "codec"); + Wt::Dbo::field(a, _bitrate, "bitrate"); + Wt::Dbo::field(a, _channelCount, "channel_count"); + Wt::Dbo::field(a, _sampleRate, "sample_rate"); + Wt::Dbo::field(a, _bitsPerSample, "bits_per_sample"); + Wt::Dbo::field(a, _replayGain, "replay_gain"); + + Wt::Dbo::field(a, _trackNumber, "track_number"); + Wt::Dbo::field(a, _name, "name"); + Wt::Dbo::field(a, _date, "date"); + Wt::Dbo::field(a, _originalDate, "original_date"); Wt::Dbo::field(a, _trackMBID, "mbid"); Wt::Dbo::field(a, _recordingMBID, "recording_mbid"); Wt::Dbo::field(a, _copyright, "copyright"); Wt::Dbo::field(a, _copyrightURL, "copyright_url"); - Wt::Dbo::field(a, _advisory, "advisory"); - Wt::Dbo::field(a, _replayGain, "replay_gain"); Wt::Dbo::field(a, _artistDisplayName, "artist_display_name"); - Wt::Dbo::field(a, _comment, "comment"); // TODO: move in a dedicated table + Wt::Dbo::field(a, _comment, "comment"); // TODO: move in a dedicated table? + Wt::Dbo::field(a, _advisory, "advisory"); Wt::Dbo::belongsTo(a, _medium, "medium", Wt::Dbo::OnDeleteCascade); Wt::Dbo::belongsTo(a, _release, "release", Wt::Dbo::OnDeleteCascade); @@ -347,27 +374,36 @@ namespace lms::db static constexpr std::size_t _maxCopyrightURLLength{ 512 }; int _scanVersion{}; - std::optional _trackNumber; - std::string _name; - int _bitrate{}; // in bps - int _bitsPerSample{}; - int _channelCount{}; - std::chrono::duration _duration{}; - int _sampleRate{}; - core::PartialDateTime _date; - core::PartialDateTime _originalDate; + + // File information std::filesystem::path _absoluteFilePath; // full path long long _fileSize{}; Wt::WDateTime _fileLastWrite; Wt::WDateTime _fileAdded; + + // Audio properties + std::chrono::duration _duration{}; + detail::Container _container{ detail::Container::Unknown }; + detail::Codec _codec{ detail::Codec::Unknown }; + int _bitrate{}; // in bps + int _channelCount{}; + int _sampleRate{}; + std::optional _bitsPerSample; + std::optional _replayGain; + + // Metadata + std::optional _trackNumber; + std::string _name; + core::PartialDateTime _date; + core::PartialDateTime _originalDate; std::string _trackMBID; std::string _recordingMBID; std::string _copyright; std::string _copyrightURL; - Advisory _advisory{ Advisory::UnSet }; - std::optional _replayGain; std::string _artistDisplayName; std::string _comment; + Advisory _advisory{ Advisory::UnSet }; + Wt::Dbo::ptr _medium; Wt::Dbo::ptr _release; Wt::Dbo::ptr _mediaLibrary; diff --git a/src/libs/database/include/database/objects/TrackArtistLink.hpp b/src/libs/database/include/database/objects/TrackArtistLink.hpp index f4e8406d..e10ec355 100644 --- a/src/libs/database/include/database/objects/TrackArtistLink.hpp +++ b/src/libs/database/include/database/objects/TrackArtistLink.hpp @@ -26,12 +26,14 @@ #include #include "core/EnumSet.hpp" + #include "database/IdType.hpp" #include "database/Object.hpp" #include "database/Types.hpp" #include "database/objects/ArtistId.hpp" #include "database/objects/ReleaseId.hpp" #include "database/objects/TrackId.hpp" +#include "database/objects/Types.hpp" LMS_DECLARE_IDTYPE(TrackArtistLinkId) diff --git a/src/libs/database/include/database/objects/TrackEmbeddedImage.hpp b/src/libs/database/include/database/objects/TrackEmbeddedImage.hpp index 48d472cd..2c4bf49f 100644 --- a/src/libs/database/include/database/objects/TrackEmbeddedImage.hpp +++ b/src/libs/database/include/database/objects/TrackEmbeddedImage.hpp @@ -26,6 +26,8 @@ #include #include +#include "core/media/ImageType.hpp" + #include "database/Object.hpp" #include "database/Types.hpp" #include "database/objects/MediumId.hpp" @@ -33,6 +35,7 @@ #include "database/objects/TrackEmbeddedImageId.hpp" #include "database/objects/TrackId.hpp" #include "database/objects/TrackListId.hpp" +#include "database/objects/Types.hpp" namespace lms::db { @@ -52,7 +55,7 @@ namespace lms::db ReleaseId release; MediumId medium; TrackListId trackList; - std::optional imageType; + std::optional imageType; TrackEmbeddedImageSortMethod sortMethod{ TrackEmbeddedImageSortMethod::None }; FindParameters& setRange(std::optional _range) @@ -80,7 +83,7 @@ namespace lms::db trackList = _trackList; return *this; } - FindParameters& setImageType(std::optional _imageType) + FindParameters& setImageType(std::optional _imageType) { imageType = _imageType; return *this; diff --git a/src/libs/database/include/database/objects/TrackEmbeddedImageLink.hpp b/src/libs/database/include/database/objects/TrackEmbeddedImageLink.hpp index ea670888..c99f3e0c 100644 --- a/src/libs/database/include/database/objects/TrackEmbeddedImageLink.hpp +++ b/src/libs/database/include/database/objects/TrackEmbeddedImageLink.hpp @@ -24,10 +24,13 @@ #include +#include "core/media/ImageType.hpp" + #include "database/Object.hpp" -#include "database/Types.hpp" #include "database/objects/TrackEmbeddedImageId.hpp" #include "database/objects/TrackEmbeddedImageLinkId.hpp" +#include "database/objects/Types.hpp" +#include "database/objects/detail/Types.hpp" namespace lms::db { @@ -49,12 +52,12 @@ namespace lms::db ObjectPtr getTrack() const; ObjectPtr getImage() const; std::size_t getIndex() const { return _index; } - ImageType getType() const { return _type; } + core::media::ImageType getType() const; std::string_view getDescription() const { return _description; } // setters void setIndex(std::size_t index) { _index = static_cast(index); } - void setType(ImageType type) { _type = type; } + void setType(core::media::ImageType type); void setDescription(std::string_view description) { _description = description; } template @@ -75,7 +78,7 @@ namespace lms::db static pointer create(Session& session, ObjectPtr track, ObjectPtr image); int _index{}; // index within the track - ImageType _type{ ImageType::Unknown }; + detail::ImageType _type{ detail::ImageType::Unknown }; std::string _description; Wt::Dbo::ptr _track; diff --git a/src/libs/database/include/database/objects/TrackList.hpp b/src/libs/database/include/database/objects/TrackList.hpp index 37647203..81b712da 100644 --- a/src/libs/database/include/database/objects/TrackList.hpp +++ b/src/libs/database/include/database/objects/TrackList.hpp @@ -33,6 +33,7 @@ #include "database/objects/Filters.hpp" #include "database/objects/TrackId.hpp" #include "database/objects/TrackListId.hpp" +#include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" namespace lms::db diff --git a/src/libs/database/include/database/objects/TrackLyrics.hpp b/src/libs/database/include/database/objects/TrackLyrics.hpp index dd7c5c9c..ee7e35bf 100644 --- a/src/libs/database/include/database/objects/TrackLyrics.hpp +++ b/src/libs/database/include/database/objects/TrackLyrics.hpp @@ -32,6 +32,7 @@ #include "database/Object.hpp" #include "database/Types.hpp" #include "database/objects/TrackId.hpp" +#include "database/objects/Types.hpp" LMS_DECLARE_IDTYPE(TrackLyricsId) diff --git a/src/libs/database/include/database/objects/Types.hpp b/src/libs/database/include/database/objects/Types.hpp new file mode 100644 index 00000000..47953b83 --- /dev/null +++ b/src/libs/database/include/database/objects/Types.hpp @@ -0,0 +1,243 @@ +/* + * Copyright (C) 2015 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 "core/LiteralString.hpp" +#include "core/TaggedType.hpp" + +namespace lms::db +{ + // For enums that have explicit values, never change enum values as they are stored in database. + // => only add new values or remove unused values, do not recycle values. + + struct YearRange + { + int begin{}; + int end{}; + }; + + struct FileInfo + { + Wt::WDateTime lastWrittenTime; + std::size_t scanVersion{}; + }; + + enum class ArtistSortMethod + { + None, + Id, + Name, + SortName, + Random, + LastWrittenDesc, + AddedDesc, + StarredDateDesc, + }; + + enum class ClusterSortMethod + { + None, + Name, + }; + + enum class DirectorySortMethod + { + None, + Name, + }; + + using ImageHashType = core::TaggedType; + + enum class LabelSortMethod + { + None, + Name, + }; + + enum class MediumSortMethod + { + None, + PositionAsc, + }; + + enum class PodcastEpisodeSortMode + { + None, + PubDateAsc, + PubDateDesc, + }; + + enum class ReleaseSortMethod + { + None, + Id, + Name, + SortName, + ArtistNameThenName, + DateAsc, + DateDesc, + OriginalDate, + OriginalDateDesc, + Random, + LastWrittenDesc, + AddedDesc, + StarredDateDesc, + }; + + enum class ReleaseTypeSortMethod + { + None, + Name, + }; + + enum class TrackArtistLinkSortMethod + { + None, + OriginalDateDesc, + }; + + enum class TrackEmbeddedImageSortMethod + { + None, + SizeDesc, + TrackNumberThenSizeDesc, + DiscNumberThenTrackNumberThenSizeDesc, + TrackListIndexAscThenSizeDesc, + }; + + enum class TrackListSortMethod + { + None, + Name, + LastModifiedDesc, + }; + + enum class TrackSortMethod + { + None, + Id, + Random, + LastWrittenDesc, + AddedDesc, + StarredDateDesc, + AbsoluteFilePath, + Name, + DateDescAndRelease, + OriginalDateDescAndRelease, + Release, // order by disc/track number + TrackList, // order by asc order in tracklist + TrackNumber, + }; + + enum class TrackLyricsSortMethod + { + None, + ExternalFirst, + EmbeddedFirst, + }; + + enum class TrackArtistLinkType + { + Artist = 0, // regular track artist + Arranger = 1, + Composer = 2, + Conductor = 3, + Lyricist = 4, + Mixer = 5, + Performer = 6, + Producer = 7, + ReleaseArtist = 8, + Remixer = 9, + Writer = 10, + }; + + core::LiteralString trackArtistLinkTypeToString(TrackArtistLinkType type); + + // User selectable transcoding output formats + enum class TranscodingOutputFormat + { + MP3 = 1, + OGG_OPUS = 2, + OGG_VORBIS = 3, + // WEBM_VORBIS = 4, + // MATROSKA_OPUS = 5, + }; + + using Bitrate = std::uint32_t; + void visitAllowedAudioBitrates(std::function); + bool isAudioBitrateAllowed(Bitrate bitrate); + + using Rating = int; + + enum class ScrobblingBackend + { + Internal = 0, + ListenBrainz = 1, + }; + + enum class FeedbackBackend + { + Internal = 0, + ListenBrainz = 1, + }; + + enum class SyncState + { + PendingAdd = 0, + Synchronized = 1, + PendingRemove = 2, + }; + + enum class UserType + { + REGULAR = 0, + ADMIN = 1, + DEMO = 2, + }; + + enum class UITheme + { + Light = 0, + Dark = 1, + }; + + enum class SubsonicArtistListMode + { + AllArtists = 0, + ReleaseArtists = 1, + TrackArtists = 2, + }; + + enum class TrackListType + { + PlayList = 0, // user controlled playlists + Internal = 1, // internal usage (current playqueue, history, ...) + }; + + enum class Advisory + { + UnSet = 0, + Unknown = 1, + Clean = 2, + Explicit = 3, + }; +} // namespace lms::db diff --git a/src/libs/database/include/database/objects/User.hpp b/src/libs/database/include/database/objects/User.hpp index ba731886..88dd21c0 100644 --- a/src/libs/database/include/database/objects/User.hpp +++ b/src/libs/database/include/database/objects/User.hpp @@ -28,8 +28,10 @@ #include "core/EnumSet.hpp" #include "core/UUID.hpp" + #include "database/Object.hpp" #include "database/Types.hpp" +#include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" namespace lms::db diff --git a/src/libs/database/include/database/objects/detail/Types.hpp b/src/libs/database/include/database/objects/detail/Types.hpp new file mode 100644 index 00000000..1001a2b8 --- /dev/null +++ b/src/libs/database/include/database/objects/detail/Types.hpp @@ -0,0 +1,97 @@ +/* + * 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 + +namespace lms::db::detail +{ + // For enums that have explicit values, never change enum values as they are stored in database. + // => only add new values or remove unused values, do not recycle values. + enum class ImageType + { + Unknown = 0, + Other = 1, + FileIcon = 2, + OtherFileIcon = 3, + FrontCover = 4, + BackCover = 5, + LeafletPage = 6, + Media = 7, + LeadArtist = 8, + Artist = 9, + Conductor = 10, + Band = 11, + Composer = 12, + Lyricist = 13, + RecordingLocation = 14, + DuringRecording = 15, + DuringPerformance = 16, + MovieScreenCapture = 17, + ColouredFish = 18, + Illustration = 19, + BandLogo = 20, + PublisherLogo = 21 + }; + + enum class Container + { + Unknown = 0, + + AIFF = 1, + APE = 2, + ASF = 3, + DSF = 4, + FLAC = 5, + MP4 = 6, + MPC = 7, + MPEG = 8, + Ogg = 9, + Shorten = 10, + TrueAudio = 11, + WAV = 12, + WavPack = 13, + }; + + enum class Codec + { + Unknown = 0, + + AAC = 1, + AC3 = 2, + ALAC = 3, + APE = 4, + DSD = 5, + EAC3 = 6, + FLAC = 7, + MP3 = 8, + MP4ALS = 9, + MPC7 = 10, + MPC8 = 11, + Opus = 12, + PCM = 13, + Shorten = 14, + TrueAudio = 15, + Vorbis = 16, + WavPack = 17, + WMA1 = 18, + WMA2 = 19, + WMA9Pro = 20, + WMA9Lossless = 21, + }; +} // namespace lms::db::detail diff --git a/src/libs/database/test/Artist.cpp b/src/libs/database/test/Artist.cpp index 0e443db8..3986e74a 100644 --- a/src/libs/database/test/Artist.cpp +++ b/src/libs/database/test/Artist.cpp @@ -518,6 +518,34 @@ namespace lms::db::tests } } + TEST_F(DatabaseFixture, Artist_findByCodec) + { + ScopedArtist artist1{ session, "A" }; + ScopedArtist artist2{ session, "B" }; + ScopedTrack track1{ session }; + ScopedTrack track2{ session }; + + { + auto transaction{ session.createWriteTransaction() }; + TrackArtistLink::create(session, track1.get(), artist1.get(), TrackArtistLinkType::Artist); + TrackArtistLink::create(session, track2.get(), artist2.get(), TrackArtistLinkType::Artist); + + track1.get().modify()->setCodec(core::media::Codec::MP3); + track2.get().modify()->setCodec(core::media::Codec::FLAC); + } + + { + auto transaction{ session.createReadTransaction() }; + + Artist::FindParameters params; + params.setFilters(Filters{}.setCodec(core::media::Codec::FLAC)); + + const auto artists{ Artist::find(session, params) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front()->getId(), artist2.getId()); + } + } + TEST_F(DatabaseFixture, Artist_findByName) { ScopedArtist artist{ session, "AAA" }; diff --git a/src/libs/database/test/Migration.cpp b/src/libs/database/test/Migration.cpp index 09de4d17..2d62e054 100644 --- a/src/libs/database/test/Migration.cpp +++ b/src/libs/database/test/Migration.cpp @@ -20,6 +20,7 @@ #include "Common.hpp" #include "core/String.hpp" + #include "database/objects/Artist.hpp" #include "database/objects/ArtistInfo.hpp" #include "database/objects/AuthToken.hpp" diff --git a/src/libs/database/test/Release.cpp b/src/libs/database/test/Release.cpp index 07e18749..940f7b73 100644 --- a/src/libs/database/test/Release.cpp +++ b/src/libs/database/test/Release.cpp @@ -1214,6 +1214,74 @@ namespace lms::db::tests checkExpectedBitrate(192); // 0 should not be taken into account } + TEST_F(DatabaseFixture, Release_codec) + { + ScopedRelease release1{ session, "MyRelease1" }; + ScopedTrack track1{ session }; + ScopedTrack track2{ session }; + ScopedTrack track3{ session }; + ScopedTrack track4{ session }; + + { + auto transaction{ session.createReadTransaction() }; + ASSERT_EQ(release1->getCodecs().size(), 0); + } + + { + auto transaction{ session.createWriteTransaction() }; + track1.get().modify()->setCodec(core::media::Codec::FLAC); + track1.get().modify()->setRelease(release1.get()); + + track2.get().modify()->setCodec(core::media::Codec::MP3); + track2.get().modify()->setRelease(release1.get()); + + track3.get().modify()->setCodec(core::media::Codec::MP3); + track3.get().modify()->setRelease(release1.get()); + + track4.get().modify()->setCodec(core::media::Codec::AAC); + track4.get().modify()->setRelease(release1.get()); + } + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(release1->getCodecs(), (std::vector{ core::media::Codec::MP3, core::media::Codec::FLAC, core::media::Codec::AAC })); + } + } + + TEST_F(DatabaseFixture, Release_findBycodec) + { + ScopedRelease release1{ session, "MyRelease1" }; + ScopedTrack track1{ session }; + + ScopedRelease release2{ session, "MyRelease2" }; + ScopedTrack track2{ session }; + + { + auto transaction{ session.createReadTransaction() }; + ASSERT_EQ(release1->getCodecs().size(), 0); + } + + { + auto transaction{ session.createWriteTransaction() }; + track1.get().modify()->setCodec(core::media::Codec::FLAC); + track1.get().modify()->setRelease(release1.get()); + + track2.get().modify()->setCodec(core::media::Codec::MP3); + track2.get().modify()->setRelease(release2.get()); + } + + { + auto transaction{ session.createReadTransaction() }; + + Release::FindParameters params; + params.setFilters(Filters{}.setCodec(core::media::Codec::MP3)); + + auto releases{ Release::findIds(session, params) }; + ASSERT_EQ(releases.results.size(), 1); + EXPECT_EQ(releases.results.front(), release2.getId()); + } + } + TEST_F(DatabaseFixture, Release_trackCount) { ScopedRelease release1{ session, "MyRelease1" }; diff --git a/src/libs/database/test/Track.cpp b/src/libs/database/test/Track.cpp index 9b83cf63..2cea765d 100644 --- a/src/libs/database/test/Track.cpp +++ b/src/libs/database/test/Track.cpp @@ -238,27 +238,26 @@ namespace lms::db::tests } } - TEST_F(DatabaseFixture, Track_findAbsoluteFilePath) + TEST_F(DatabaseFixture, Track_findByCodec) { - ScopedTrack track{ session }; - const std::filesystem::path absoluteFilePath{ "/path/to/track.mp3" }; + ScopedTrack track1{ session }; + ScopedTrack track2{ session }; + { auto transaction{ session.createWriteTransaction() }; - track.get().modify()->setAbsoluteFilePath(absoluteFilePath); + track1.get().modify()->setCodec(core::media::Codec::MP3); + track2.get().modify()->setCodec(core::media::Codec::FLAC); } { auto transaction{ session.createReadTransaction() }; - TrackId lastRetrievedTrackId; - std::vector> visitedTracks; - Track::findAbsoluteFilePath(session, lastRetrievedTrackId, 10, [&](TrackId trackId, const std::filesystem::path& filePath) { - visitedTracks.emplace_back(trackId, filePath); - }); - ASSERT_EQ(visitedTracks.size(), 1); - EXPECT_EQ(visitedTracks[0].first, track.getId()); - EXPECT_EQ(visitedTracks[0].second, absoluteFilePath); - EXPECT_EQ(lastRetrievedTrackId, track.getId()); + Track::FindParameters params; + params.setFilters(Filters{}.setCodec(core::media::Codec::FLAC)); + + const auto tracks{ Track::find(session, params) }; + ASSERT_EQ(tracks.results.size(), 1); + EXPECT_EQ(tracks.results[0]->getId(), track2.getId()); } } diff --git a/src/libs/database/test/TrackEmbeddedImage.cpp b/src/libs/database/test/TrackEmbeddedImage.cpp index 161e215c..61bcda1a 100644 --- a/src/libs/database/test/TrackEmbeddedImage.cpp +++ b/src/libs/database/test/TrackEmbeddedImage.cpp @@ -130,7 +130,7 @@ namespace lms::db::tests { auto transaction{ session.createWriteTransaction() }; - link.get().modify()->setType(ImageType::FrontCover); + link.get().modify()->setType(core::media::ImageType::FrontCover); } { @@ -173,6 +173,30 @@ namespace lms::db::tests TrackEmbeddedImage::find(session, params, [&](const auto&) { visited = true; }); EXPECT_TRUE(visited); } + + { + auto transaction{ session.createReadTransaction() }; + + TrackEmbeddedImage::FindParameters params; + params.setTrack(track.getId()); + params.setImageType(core::media::ImageType::FrontCover); + + bool visited{}; + TrackEmbeddedImage::find(session, params, [&](const auto&) { visited = true; }); + EXPECT_TRUE(visited); + } + + { + auto transaction{ session.createReadTransaction() }; + + TrackEmbeddedImage::FindParameters params; + params.setTrack(track.getId()); + params.setImageType(core::media::ImageType::BackCover); + + bool visited{}; + TrackEmbeddedImage::find(session, params, [&](const auto&) { visited = true; }); + EXPECT_FALSE(visited); + } } TEST_F(DatabaseFixture, TrackEmbeddedImage_findByParams_sorts) @@ -200,18 +224,18 @@ namespace lms::db::tests track1.get().modify()->setMedium(medium1.get()); track1.get().modify()->setTrackNumber(2); - link1.get().modify()->setType(ImageType::FrontCover); + link1.get().modify()->setType(core::media::ImageType::FrontCover); image1.get().modify()->setSize(750); - link2.get().modify()->setType(ImageType::Media); + link2.get().modify()->setType(core::media::ImageType::Media); image2.get().modify()->setSize(1000); - link3.get().modify()->setType(ImageType::Media); + link3.get().modify()->setType(core::media::ImageType::Media); image3.get().modify()->setSize(2000); track2.get().modify()->setRelease(release.get()); track2.get().modify()->setMedium(medium2.get()); track2.get().modify()->setTrackNumber(1); - link4.get().modify()->setType(ImageType::Media); + link4.get().modify()->setType(core::media::ImageType::Media); image4.get().modify()->setSize(1500); } @@ -220,7 +244,7 @@ namespace lms::db::tests TrackEmbeddedImage::FindParameters params; params.setRelease(release.getId()); - params.setImageType(ImageType::Media); + params.setImageType(core::media::ImageType::Media); params.setSortMethod(TrackEmbeddedImageSortMethod::SizeDesc); std::vector visitedIds; @@ -236,7 +260,7 @@ namespace lms::db::tests TrackEmbeddedImage::FindParameters params; params.setMedium(medium1.getId()); - params.setImageType(ImageType::Media); + params.setImageType(core::media::ImageType::Media); params.setSortMethod(TrackEmbeddedImageSortMethod::TrackNumberThenSizeDesc); std::vector visitedIds; @@ -251,7 +275,7 @@ namespace lms::db::tests TrackEmbeddedImage::FindParameters params; params.setRelease(release.getId()); - params.setImageType(ImageType::Media); + params.setImageType(core::media::ImageType::Media); params.setSortMethod(TrackEmbeddedImageSortMethod::DiscNumberThenTrackNumberThenSizeDesc); std::vector visitedIds; @@ -267,7 +291,7 @@ namespace lms::db::tests TrackEmbeddedImage::FindParameters params; params.setRelease(release.getId()); - params.setImageType(ImageType::BackCover); + params.setImageType(core::media::ImageType::BackCover); params.setSortMethod(TrackEmbeddedImageSortMethod::DiscNumberThenTrackNumberThenSizeDesc); std::vector visitedIds; @@ -383,7 +407,7 @@ namespace lms::db::tests const TrackEmbeddedImageLink::pointer link{ TrackEmbeddedImageLink::find(session, imageLink.getId()) }; ASSERT_NE(link, TrackEmbeddedImageLink::pointer{}); EXPECT_EQ(link->getIndex(), 0); - EXPECT_EQ(link->getType(), ImageType::Unknown); + EXPECT_EQ(link->getType(), core::media::ImageType::Unknown); EXPECT_EQ(link->getDescription(), ""); EXPECT_EQ(link->getTrack(), track.get()); EXPECT_EQ(link->getImage(), image.get()); @@ -395,7 +419,7 @@ namespace lms::db::tests TrackEmbeddedImageLink::pointer link{ TrackEmbeddedImageLink::find(session, imageLink.getId()) }; ASSERT_NE(link, TrackEmbeddedImage::pointer{}); link.modify()->setIndex(2); - link.modify()->setType(ImageType::FrontCover); + link.modify()->setType(core::media::ImageType::FrontCover); link.modify()->setDescription("MyDesc"); } @@ -405,7 +429,7 @@ namespace lms::db::tests const TrackEmbeddedImageLink::pointer img{ TrackEmbeddedImageLink::find(session, imageLink.getId()) }; ASSERT_NE(img, TrackEmbeddedImage::pointer{}); EXPECT_EQ(img->getIndex(), 2); - EXPECT_EQ(img->getType(), ImageType::FrontCover); + EXPECT_EQ(img->getType(), core::media::ImageType::FrontCover); EXPECT_EQ(img->getDescription(), "MyDesc"); } @@ -415,7 +439,7 @@ namespace lms::db::tests bool visited{}; TrackEmbeddedImageLink::find(session, image->getId(), [&](const TrackEmbeddedImageLink::pointer& link) { EXPECT_EQ(link->getIndex(), 2); - EXPECT_EQ(link->getType(), ImageType::FrontCover); + EXPECT_EQ(link->getType(), core::media::ImageType::FrontCover); EXPECT_EQ(link->getDescription(), "MyDesc"); EXPECT_EQ(link->getTrack(), track.get()); diff --git a/src/libs/services/artwork/impl/ArtworkService.cpp b/src/libs/services/artwork/impl/ArtworkService.cpp index 7395ed20..8dda3d27 100644 --- a/src/libs/services/artwork/impl/ArtworkService.cpp +++ b/src/libs/services/artwork/impl/ArtworkService.cpp @@ -24,7 +24,9 @@ #include "core/IConfig.hpp" #include "core/ILogger.hpp" +#include "audio/Exception.hpp" #include "audio/IAudioFileInfo.hpp" +#include "audio/IAudioFileInfoParser.hpp" #include "audio/IImageReader.hpp" #include "database/IDb.hpp" #include "database/Session.hpp" @@ -53,6 +55,7 @@ namespace lms::artwork const std::filesystem::path& defaultArtistImageSvgPath) : _db{ db } , _cache{ core::Service::get()->getULong("cover-max-cache-size", 30) * 1000 * 1000 } + , _audioFileInfoParser{ audio::createAudioFileInfoParser() } { setJpegQuality(core::Service::get()->getULong("cover-jpeg-quality", 75)); @@ -108,11 +111,14 @@ namespace lms::artwork { std::size_t currentIndex{}; - audio::ParserOptions options; - options.readStyle = audio::ParserOptions::AudioPropertiesReadStyle::Fast; // only for images + audio::AudioFileInfoParseOptions options; + options.audioPropertiesReadStyle = audio::AudioFileInfoParseOptions::AudioPropertiesReadStyle::Fast; // only for images + options.readTags = false; + options.readImages = true; - auto audioFile{ audio::parseAudioFile(p) }; - audioFile->getImageReader().visitImages([&](const audio::Image& parsedImage) { + const auto audioFileInfo{ _audioFileInfoParser->parse(p, options) }; + assert(audioFileInfo->getImageReader()); + audioFileInfo->getImageReader()->visitImages([&](const audio::Image& parsedImage) { if (currentIndex++ != index) return; diff --git a/src/libs/services/artwork/impl/ArtworkService.hpp b/src/libs/services/artwork/impl/ArtworkService.hpp index 53bd1455..e698cc35 100644 --- a/src/libs/services/artwork/impl/ArtworkService.hpp +++ b/src/libs/services/artwork/impl/ArtworkService.hpp @@ -28,6 +28,11 @@ #include "ImageCache.hpp" +namespace lms::audio +{ + class IAudioFileInfoParser; +} + namespace lms::db { class Session; @@ -65,6 +70,7 @@ namespace lms::artwork ImageCache _cache; std::shared_ptr _defaultReleaseCover; std::shared_ptr _defaultArtistImage; + std::unique_ptr _audioFileInfoParser; static inline const std::vector _fileExtensions{ ".jpg", ".jpeg", ".png", ".bmp" }; // TODO parametrize unsigned _jpegQuality; diff --git a/src/libs/services/auth/include/services/auth/Types.hpp b/src/libs/services/auth/include/services/auth/Types.hpp index 4122b2d2..d1a4027e 100644 --- a/src/libs/services/auth/include/services/auth/Types.hpp +++ b/src/libs/services/auth/include/services/auth/Types.hpp @@ -22,7 +22,8 @@ #include #include "core/Exception.hpp" -#include "database/Types.hpp" + +#include "database/objects/Types.hpp" namespace lms::auth { diff --git a/src/libs/services/feedback/impl/internal/InternalBackend.cpp b/src/libs/services/feedback/impl/internal/InternalBackend.cpp index 0c90b540..526efa47 100644 --- a/src/libs/services/feedback/impl/internal/InternalBackend.cpp +++ b/src/libs/services/feedback/impl/internal/InternalBackend.cpp @@ -27,7 +27,7 @@ namespace lms::feedback { - namespace details + namespace detail { template void onStarred(db::Session& session, typename StarredObjType::IdType id) @@ -46,7 +46,7 @@ namespace lms::feedback if (auto starredObj{ StarredObjType::find(session, id) }) starredObj.remove(); } - } // namespace details + } // namespace detail InternalBackend::InternalBackend(db::IDb& db) : _db{ db } @@ -55,31 +55,31 @@ namespace lms::feedback void InternalBackend::onStarred(db::StarredArtistId artistId) { - details::onStarred(_db.getTLSSession(), artistId); + detail::onStarred(_db.getTLSSession(), artistId); } void InternalBackend::onUnstarred(db::StarredArtistId artistId) { - details::onUnstarred(_db.getTLSSession(), artistId); + detail::onUnstarred(_db.getTLSSession(), artistId); } void InternalBackend::onStarred(db::StarredReleaseId releaseId) { - details::onStarred(_db.getTLSSession(), releaseId); + detail::onStarred(_db.getTLSSession(), releaseId); } void InternalBackend::onUnstarred(db::StarredReleaseId releaseId) { - details::onUnstarred(_db.getTLSSession(), releaseId); + detail::onUnstarred(_db.getTLSSession(), releaseId); } void InternalBackend::onStarred(db::StarredTrackId trackId) { - details::onStarred(_db.getTLSSession(), trackId); + detail::onStarred(_db.getTLSSession(), trackId); } void InternalBackend::onUnstarred(db::StarredTrackId trackId) { - details::onUnstarred(_db.getTLSSession(), trackId); + detail::onUnstarred(_db.getTLSSession(), trackId); } } // namespace lms::feedback diff --git a/src/libs/services/feedback/impl/listenbrainz/ListenBrainzBackend.cpp b/src/libs/services/feedback/impl/listenbrainz/ListenBrainzBackend.cpp index 59a7fc71..dbd936d8 100644 --- a/src/libs/services/feedback/impl/listenbrainz/ListenBrainzBackend.cpp +++ b/src/libs/services/feedback/impl/listenbrainz/ListenBrainzBackend.cpp @@ -32,7 +32,7 @@ namespace lms::feedback::listenBrainz { - namespace details + namespace detail { template void onStarred(db::Session& session, typename StarredObjType::IdType id) @@ -54,7 +54,7 @@ namespace lms::feedback::listenBrainz if (auto starredObj{ StarredObjType::find(session, id) }) starredObj.remove(); } - } // namespace details + } // namespace detail ListenBrainzBackend::ListenBrainzBackend(boost::asio::io_context& ioContext, db::IDb& db) : _ioContext{ ioContext } @@ -73,22 +73,22 @@ namespace lms::feedback::listenBrainz void ListenBrainzBackend::onStarred(db::StarredArtistId starredArtistId) { - details::onStarred(_db.getTLSSession(), starredArtistId); + detail::onStarred(_db.getTLSSession(), starredArtistId); } void ListenBrainzBackend::onUnstarred(db::StarredArtistId starredArtistId) { - details::onUnstarred(_db.getTLSSession(), starredArtistId); + detail::onUnstarred(_db.getTLSSession(), starredArtistId); } void ListenBrainzBackend::onStarred(db::StarredReleaseId starredReleaseId) { - details::onStarred(_db.getTLSSession(), starredReleaseId); + detail::onStarred(_db.getTLSSession(), starredReleaseId); } void ListenBrainzBackend::onUnstarred(db::StarredReleaseId starredReleaseId) { - details::onUnstarred(_db.getTLSSession(), starredReleaseId); + detail::onUnstarred(_db.getTLSSession(), starredReleaseId); } void ListenBrainzBackend::onStarred(db::StarredTrackId starredTrackId) diff --git a/src/libs/services/feedback/include/services/feedback/IFeedbackService.hpp b/src/libs/services/feedback/include/services/feedback/IFeedbackService.hpp index e1353b4a..f54858e2 100644 --- a/src/libs/services/feedback/include/services/feedback/IFeedbackService.hpp +++ b/src/libs/services/feedback/include/services/feedback/IFeedbackService.hpp @@ -30,6 +30,7 @@ #include "database/objects/Filters.hpp" #include "database/objects/ReleaseId.hpp" #include "database/objects/TrackId.hpp" +#include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" namespace lms::db diff --git a/src/libs/services/podcast/CMakeLists.txt b/src/libs/services/podcast/CMakeLists.txt index 54b4a77f..1921939b 100644 --- a/src/libs/services/podcast/CMakeLists.txt +++ b/src/libs/services/podcast/CMakeLists.txt @@ -24,6 +24,7 @@ target_include_directories(lmspodcast PRIVATE target_link_libraries(lmspodcast PRIVATE lmscore + lmsaudio lmsimage pugixml::pugixml ) diff --git a/src/libs/services/podcast/impl/PodcastService.cpp b/src/libs/services/podcast/impl/PodcastService.cpp index bb36b245..18f8f747 100644 --- a/src/libs/services/podcast/impl/PodcastService.cpp +++ b/src/libs/services/podcast/impl/PodcastService.cpp @@ -26,6 +26,7 @@ #include "core/ITraceLogger.hpp" #include "core/Service.hpp" #include "core/http/IClient.hpp" + #include "database/IDb.hpp" #include "database/Session.hpp" #include "database/objects/Podcast.hpp" diff --git a/src/libs/services/podcast/impl/steps/CheckForMissingFilesStep.cpp b/src/libs/services/podcast/impl/steps/CheckForMissingFilesStep.cpp index 9ff46aa4..03ca5991 100644 --- a/src/libs/services/podcast/impl/steps/CheckForMissingFilesStep.cpp +++ b/src/libs/services/podcast/impl/steps/CheckForMissingFilesStep.cpp @@ -51,13 +51,7 @@ namespace lms::podcast assert(std::holds_alternative(artwork->getUnderlyingId())); // these artworks can only be an image const std::filesystem::path filePath{ artwork->getAbsoluteFilePath() }; - if (!fileExists(filePath.string())) - { - LMS_LOG(PODCAST, DEBUG, "Artwork file is missing: " << filePath); - return false; - } - - return true; + return fileExists(filePath.string()); } } // namespace @@ -86,7 +80,10 @@ namespace lms::podcast if (const db::Artwork::pointer artwork{ podcast->getArtwork() }) { if (!checkArtworkFile(artwork)) + { + LMS_LOG(PODCAST, DEBUG, "Artwork file " << artwork->getAbsoluteFilePath() << " is missing for podcast '" << podcast->getTitle() << "'"); missingImages.push_back(artwork->getImageId()); + } } }); @@ -94,7 +91,10 @@ namespace lms::podcast if (const db::Artwork::pointer artwork{ episode->getArtwork() }) { if (!checkArtworkFile(artwork)) + { + LMS_LOG(PODCAST, DEBUG, "Artwork file " << artwork->getAbsoluteFilePath() << " is missing for podcast episode '" << episode->getTitle() << "'"); missingImages.push_back(artwork->getImageId()); + } } }); } diff --git a/src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.cpp b/src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.cpp index 0d95573e..1cc6bd6a 100644 --- a/src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.cpp +++ b/src/libs/services/podcast/impl/steps/DownloadEpisodeArtworksStep.cpp @@ -112,7 +112,7 @@ namespace lms::podcast const std::string url{ episode->getImageUrl() }; const std::filesystem::path finalFilePath{ getCachePath() / utils::generateRandomFileName() }; - LMS_LOG(PODCAST, DEBUG, "Downloading episode artwork for episode '" << episode->getTitle() << "' from '" << url << "' in file '" << finalFilePath << "'"); + LMS_LOG(PODCAST, DEBUG, "Downloading episode artwork for episode '" << episode->getTitle() << "' from '" << url << "' in file " << finalFilePath); core::http::ClientGETRequestParameters params; params.relativeUrl = episode->getImageUrl(); diff --git a/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.cpp b/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.cpp index 26cb2cf7..a3704984 100644 --- a/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.cpp +++ b/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.cpp @@ -26,6 +26,10 @@ #include "core/Service.hpp" #include "core/http/IClient.hpp" +#include "audio/AudioProperties.hpp" +#include "audio/Exception.hpp" +#include "audio/IAudioFileInfo.hpp" +#include "audio/IAudioFileInfoParser.hpp" #include "database/IDb.hpp" #include "database/Session.hpp" #include "database/objects/Podcast.hpp" @@ -38,7 +42,7 @@ namespace lms::podcast { namespace { - void updateEpisode(db::Session& session, db::PodcastEpisodeId episodeId, const std::filesystem::path& relativeFilePath) + void updateEpisode(db::Session& session, db::PodcastEpisodeId episodeId, const std::filesystem::path& relativeFilePath, const audio::AudioProperties& audioProperties) { auto transaction{ session.createWriteTransaction() }; @@ -47,6 +51,14 @@ namespace lms::podcast return; // may have been deleted by admin dbEpisode.modify()->setAudioRelativeFilePath(relativeFilePath); + + dbEpisode.modify()->setDuration(audioProperties.duration); + dbEpisode.modify()->setContainer(audioProperties.container); + dbEpisode.modify()->setCodec(audioProperties.codec); + dbEpisode.modify()->setBitrate(audioProperties.bitrate); + dbEpisode.modify()->setChannelCount(audioProperties.channelCount); + dbEpisode.modify()->setSampleRate(audioProperties.sampleRate); + dbEpisode.modify()->setBitsPerSample(audioProperties.bitsPerSample); } } // namespace @@ -54,10 +66,12 @@ namespace lms::podcast : RefreshStep{ context, std::move(callback) } , _autoDownloadEpisodes{ core::Service::get()->getBool("podcast-auto-download-episodes", true) } , _autoDownloadEpisodesMaxAge{ core::Service::get()->getULong("podcast-auto-download-episodes-max-age-days", 30) } - + , _audioFileInfoParser{ audio::createAudioFileInfoParser(audio::AudioFileInfoParserBackend::FFmpeg) } { } + DownloadEpisodesStep::~DownloadEpisodesStep() = default; + core::LiteralString DownloadEpisodesStep::getName() const { return "Download episodes"; @@ -171,18 +185,40 @@ namespace lms::podcast assert(msg.body().empty()); getExecutor().post([=, this] { LMS_LOG(PODCAST, DEBUG, "Download episode from '" << url << "' complete"); - LMS_LOG(PODCAST, DEBUG, "Renaming temp file " << tmpFilePath << " to " << finalFilePath); - std::error_code ec; - std::filesystem::rename(tmpFilePath, finalFilePath, ec); - if (ec) - LMS_LOG(PODCAST, ERROR, "Failed to rename temp file " << tmpFilePath << " to " << finalFilePath << ": " << ec.message()); - else - updateEpisode(getDb().getTLSSession(), episodeId, randomName); + try + { + audio::AudioFileInfoParseOptions options; + options.audioPropertiesReadStyle = audio::AudioFileInfoParseOptions::AudioPropertiesReadStyle::Average; + options.readImages = false; + options.readTags = false; - // TODO: now the file is complete, should we attempt to read it and get the real information like duration and size? + const auto audioFileInfo{ _audioFileInfoParser->parse(tmpFilePath, options) }; + const auto* audioProperties{ audioFileInfo->getAudioProperties() }; + if (audioProperties) + { + std::error_code ec; + std::filesystem::rename(tmpFilePath, finalFilePath, ec); + if (ec) + { + LMS_LOG(PODCAST, ERROR, "Failed to rename temp file " << tmpFilePath << " to " << finalFilePath << ": " << ec.message()); + } + else + { + updateEpisode(getDb().getTLSSession(), episodeId, randomName, *audioProperties); + LMS_LOG(PODCAST, INFO, "Downloaded episode '" << episode->getTitle() << "'"); + } + } + else + { + LMS_LOG(PODCAST, WARNING, "Failed to get audio properties from downloaded episode from '" << url << "'"); + } + } + catch (const audio::Exception& e) + { + LMS_LOG(PODCAST, WARNING, "Failed to parse downloaded episode from '" << url << "': " << e.what()); + } - LMS_LOG(PODCAST, INFO, "Downloaded episode '" << episode->getTitle() << "'"); processNext(); }); }; diff --git a/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.hpp b/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.hpp index 35d23265..b430cb7f 100644 --- a/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.hpp +++ b/src/libs/services/podcast/impl/steps/DownloadEpisodesStep.hpp @@ -25,12 +25,18 @@ #include "RefreshStep.hpp" +namespace lms::audio +{ + class IAudioFileInfoParser; +} + namespace lms::podcast { class DownloadEpisodesStep : public RefreshStep { public: DownloadEpisodesStep(RefreshContext& context, OnDoneCallback callback); + ~DownloadEpisodesStep() override; private: core::LiteralString getName() const override; @@ -44,6 +50,8 @@ namespace lms::podcast const bool _autoDownloadEpisodes; const std::chrono::days _autoDownloadEpisodesMaxAge; + const std::unique_ptr _audioFileInfoParser; + std::deque _episodesToDownload; }; diff --git a/src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.cpp b/src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.cpp index c27e1912..2db0e696 100644 --- a/src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.cpp +++ b/src/libs/services/podcast/impl/steps/DownloadPodcastArtworksStep.cpp @@ -111,7 +111,7 @@ namespace lms::podcast const std::string url{ podcast->getImageUrl() }; const std::filesystem::path finalFilePath{ getCachePath() / utils::generateRandomFileName() }; - LMS_LOG(PODCAST, DEBUG, "Downloading podcast artwork '" << podcast->getTitle() << "' from '" << url << "' in file '" << finalFilePath << "'"); + LMS_LOG(PODCAST, DEBUG, "Downloading podcast artwork '" << podcast->getTitle() << "' from '" << url << "' in file " << finalFilePath); core::http::ClientGETRequestParameters params; params.relativeUrl = podcast->getImageUrl(); diff --git a/src/libs/services/recommendation/impl/IEngine.hpp b/src/libs/services/recommendation/impl/IEngine.hpp index a072fba4..bdf48f7b 100644 --- a/src/libs/services/recommendation/impl/IEngine.hpp +++ b/src/libs/services/recommendation/impl/IEngine.hpp @@ -22,8 +22,9 @@ #include #include "core/EnumSet.hpp" -#include "database/Types.hpp" + #include "database/objects/TrackListId.hpp" +#include "database/objects/Types.hpp" #include "services/recommendation/Types.hpp" namespace lms::db diff --git a/src/libs/services/recommendation/include/services/recommendation/IRecommendationService.hpp b/src/libs/services/recommendation/include/services/recommendation/IRecommendationService.hpp index fabb9a0d..b0520482 100644 --- a/src/libs/services/recommendation/include/services/recommendation/IRecommendationService.hpp +++ b/src/libs/services/recommendation/include/services/recommendation/IRecommendationService.hpp @@ -23,10 +23,11 @@ #include #include "core/EnumSet.hpp" -#include "database/Types.hpp" + #include "database/objects/ArtistId.hpp" #include "database/objects/ReleaseId.hpp" #include "database/objects/TrackListId.hpp" +#include "database/objects/Types.hpp" #include "services/recommendation/Types.hpp" namespace lms::db diff --git a/src/libs/services/recommendation/include/services/recommendation/Types.hpp b/src/libs/services/recommendation/include/services/recommendation/Types.hpp index 5389640e..b593ce80 100644 --- a/src/libs/services/recommendation/include/services/recommendation/Types.hpp +++ b/src/libs/services/recommendation/include/services/recommendation/Types.hpp @@ -1,3 +1,22 @@ +/* + * 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 diff --git a/src/libs/services/scanner/CMakeLists.txt b/src/libs/services/scanner/CMakeLists.txt index 055371cc..69ef7c3e 100644 --- a/src/libs/services/scanner/CMakeLists.txt +++ b/src/libs/services/scanner/CMakeLists.txt @@ -2,6 +2,7 @@ add_library(lmsscanner STATIC impl/helpers/ArtistHelpers.cpp impl/scanners/artistinfo/ArtistInfoParser.cpp impl/scanners/artistinfo/ArtistInfoFileScanner.cpp + impl/scanners/audiofile/AudioFileInfoParserSet.cpp impl/scanners/audiofile/AudioFileScanOperation.cpp impl/scanners/audiofile/AudioFileScanner.cpp impl/scanners/audiofile/TrackMetadataParser.cpp diff --git a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileInfoParserSet.cpp b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileInfoParserSet.cpp new file mode 100644 index 00000000..738a04dc --- /dev/null +++ b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileInfoParserSet.cpp @@ -0,0 +1,60 @@ +/* + * 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 "audio/IAudioFileInfoParser.hpp" +#include "core/Exception.hpp" +#include "core/IConfig.hpp" +#include "core/Service.hpp" + +#include "scanners/audiofile/AudioFileInfoParserSet.hpp" + +namespace lms::scanner +{ + namespace + { + audio::AudioFileInfoParseOptions::AudioPropertiesReadStyle getParserReadStyle() + { + std::string_view readStyle{ core::Service::get()->getString("scanner-parser-read-style", "average") }; + + if (readStyle == "fast") + return audio::AudioFileInfoParseOptions::AudioPropertiesReadStyle::Fast; + if (readStyle == "average") + return audio::AudioFileInfoParseOptions::AudioPropertiesReadStyle::Average; + if (readStyle == "accurate") + return audio::AudioFileInfoParseOptions::AudioPropertiesReadStyle::Accurate; + + throw core::LmsException{ "Invalid value for 'scanner-parser-read-style'" }; + } + } // namespace + + AudioFileInfoParserSet createAudioFileInfoParserSet() + { + AudioFileInfoParserSet parserSet; + + parserSet.taglibParser = audio::createAudioFileInfoParser(audio::AudioFileInfoParserBackend::TagLib); + parserSet.ffmpegParser = audio::createAudioFileInfoParser(audio::AudioFileInfoParserBackend::FFmpeg); + + const auto extensions{ parserSet.taglibParser->getSupportedExtensions() }; + parserSet.supportedExtensions.assign(std::cbegin(extensions), std::cend(extensions)); + + parserSet.audioPropertiesReadStyle = getParserReadStyle(); + + return parserSet; + } +} // namespace lms::scanner diff --git a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileInfoParserSet.hpp b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileInfoParserSet.hpp new file mode 100644 index 00000000..f097d548 --- /dev/null +++ b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileInfoParserSet.hpp @@ -0,0 +1,39 @@ +/* + * 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 "audio/IAudioFileInfoParser.hpp" + +namespace lms::scanner +{ + struct AudioFileInfoParserSet + { + std::unique_ptr taglibParser; + std::unique_ptr ffmpegParser; + std::vector supportedExtensions; + + audio::AudioFileInfoParseOptions::AudioPropertiesReadStyle audioPropertiesReadStyle; // a bit hacky to have this here + }; + + AudioFileInfoParserSet createAudioFileInfoParserSet(); +} // namespace lms::scanner diff --git a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp index 814b9242..51e94eb1 100644 --- a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp +++ b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp @@ -25,13 +25,12 @@ #include "core/Path.hpp" #include "core/XxHash3.hpp" +#include "audio/Exception.hpp" #include "audio/IAudioFileInfo.hpp" -#include "image/Exception.hpp" -#include "image/Image.hpp" - +#include "audio/IAudioFileInfoParser.hpp" +#include "audio/IImageReader.hpp" #include "database/IDb.hpp" #include "database/Session.hpp" -#include "database/Types.hpp" #include "database/objects/Artist.hpp" #include "database/objects/Artwork.hpp" #include "database/objects/Cluster.hpp" @@ -45,6 +44,8 @@ #include "database/objects/TrackEmbeddedImageLink.hpp" #include "database/objects/TrackFeatures.hpp" #include "database/objects/TrackLyrics.hpp" +#include "image/Exception.hpp" +#include "image/Image.hpp" #include "services/scanner/ScanErrors.hpp" @@ -52,6 +53,7 @@ #include "helpers/ArtistHelpers.hpp" #include "scanners/IFileScanOperation.hpp" #include "scanners/Utils.hpp" +#include "scanners/audiofile/AudioFileInfoParserSet.hpp" #include "scanners/audiofile/TrackMetadataParser.hpp" namespace lms::scanner @@ -293,59 +295,6 @@ namespace lms::scanner return dbLyrics; } - db::ImageType convertImageType(audio::Image::Type type) - { - switch (type) - { - case audio::Image::Type::Unknown: - return db::ImageType::Unknown; - case audio::Image::Type::Other: - return db::ImageType::Other; - case audio::Image::Type::FileIcon: - return db::ImageType::FileIcon; - case audio::Image::Type::OtherFileIcon: - return db::ImageType::OtherFileIcon; - case audio::Image::Type::FrontCover: - return db::ImageType::FrontCover; - case audio::Image::Type::BackCover: - return db::ImageType::BackCover; - case audio::Image::Type::LeafletPage: - return db::ImageType::LeafletPage; - case audio::Image::Type::Media: - return db::ImageType::Media; - case audio::Image::Type::LeadArtist: - return db::ImageType::LeadArtist; - case audio::Image::Type::Artist: - return db::ImageType::Artist; - case audio::Image::Type::Conductor: - return db::ImageType::Conductor; - case audio::Image::Type::Band: - return db::ImageType::Band; - case audio::Image::Type::Composer: - return db::ImageType::Composer; - case audio::Image::Type::Lyricist: - return db::ImageType::Lyricist; - case audio::Image::Type::RecordingLocation: - return db::ImageType::RecordingLocation; - case audio::Image::Type::DuringRecording: - return db::ImageType::DuringRecording; - case audio::Image::Type::DuringPerformance: - return db::ImageType::DuringPerformance; - case audio::Image::Type::MovieScreenCapture: - return db::ImageType::MovieScreenCapture; - case audio::Image::Type::ColouredFish: - return db::ImageType::ColouredFish; - case audio::Image::Type::Illustration: - return db::ImageType::Illustration; - case audio::Image::Type::BandLogo: - return db::ImageType::BandLogo; - case audio::Image::Type::PublisherLogo: - return db::ImageType::PublisherLogo; - } - - return db::ImageType::Unknown; - } - db::TrackEmbeddedImage::pointer getOrCreateTrackEmbeddedImage(db::Session& session, const ImageInfo& imageInfo) { db::TrackEmbeddedImage::pointer image{ db::TrackEmbeddedImage::find(session, imageInfo.size, db::ImageHashType{ imageInfo.hash }) }; @@ -369,7 +318,7 @@ namespace lms::scanner const db::TrackEmbeddedImage::pointer image{ getOrCreateTrackEmbeddedImage(session, imageInfo) }; db::TrackEmbeddedImageLink::pointer imageLink{ session.create(dbTrack, image) }; imageLink.modify()->setIndex(imageInfo.index); - imageLink.modify()->setType(convertImageType(imageInfo.type)); + imageLink.modify()->setType(imageInfo.type); imageLink.modify()->setDescription(imageInfo.description); return imageLink; @@ -488,10 +437,10 @@ namespace lms::scanner } } // namespace - AudioFileScanOperation::AudioFileScanOperation(FileToScan&& fileToScan, db::IDb& db, const ScannerSettings& settings, const TrackMetadataParser& metadataParser, const audio::ParserOptions& parserOptions) + AudioFileScanOperation::AudioFileScanOperation(FileToScan&& fileToScan, db::IDb& db, const ScannerSettings& settings, const AudioFileInfoParserSet& audioFileInfoParserSet, const TrackMetadataParser& metadataParser) : FileScanOperationBase{ std::move(fileToScan), db, settings } + , _audioFileInfoParserSet{ audioFileInfoParserSet } , _metadataParser{ metadataParser } - , _parserOptions{ parserOptions } { } @@ -501,18 +450,42 @@ namespace lms::scanner { try { - auto audioFileInfo{ audio::parseAudioFile(getFilePath(), _parserOptions) }; + audio::AudioFileInfoParseOptions options; + options.audioPropertiesReadStyle = _audioFileInfoParserSet.audioPropertiesReadStyle; + options.readImages = true; + options.readTags = true; + + const auto audioFileInfo{ _audioFileInfoParserSet.taglibParser->parse(getFilePath(), options) }; _file.emplace(); - _file->audioProperties = audioFileInfo->getAudioProperties(); - _file->track = _metadataParser.parseTrackMetaData(audioFileInfo->getTagReader()); + // Fallback on ffmpeg in case no audio properties are found by taglib + if (!audioFileInfo->getAudioProperties()) + { + LMS_LOG(DBUPDATER, DEBUG, "Cannot parse audio properties in " << getFilePath() << " using TagLib, switching to ffmpeg"); + + options.readTags = false; + options.readImages = false; + const auto ffmpegAudioFileInfo{ _audioFileInfoParserSet.ffmpegParser->parse(getFilePath(), options) }; + if (!ffmpegAudioFileInfo->getAudioProperties()) + { + addError(getFilePath()); + return; + } + _file->audioProperties = *ffmpegAudioFileInfo->getAudioProperties(); + } + else + { + _file->audioProperties = *audioFileInfo->getAudioProperties(); + } + + _file->track = _metadataParser.parseTrackMetaData(*audioFileInfo->getTagReader()); // We fill missing artist mbids with mbids found on other artist roles fillMissingMbids(_file->track); std::size_t index{}; - audioFileInfo->getImageReader().visitImages([&](const audio::Image& image) { + audioFileInfo->getImageReader()->visitImages([&](const audio::Image& image) { try { image::ImageProperties properties{ image::probeImage(image.data) }; @@ -539,9 +512,9 @@ namespace lms::scanner index++; }); } - catch (const audio::IOException& e) + catch (const audio::IOFileException& e) { - addError(getFilePath(), e.getErrorCode()); + addError(e.getPath(), e.getErrorCode()); } catch (const audio::Exception& e) { @@ -668,11 +641,13 @@ namespace lms::scanner track.modify()->setScanVersion(getScannerSettings().audioScanVersion); // Audio properties - track.modify()->setBitrate(_file->audioProperties.bitrate); - track.modify()->setBitsPerSample(_file->audioProperties.bitsPerSample ? *_file->audioProperties.bitsPerSample : 0); - track.modify()->setChannelCount(_file->audioProperties.channelCount); track.modify()->setDuration(_file->audioProperties.duration); + track.modify()->setContainer(_file->audioProperties.container); + track.modify()->setCodec(_file->audioProperties.codec); + track.modify()->setBitrate(_file->audioProperties.bitrate); + track.modify()->setChannelCount(_file->audioProperties.channelCount); track.modify()->setSampleRate(_file->audioProperties.sampleRate); + track.modify()->setBitsPerSample(_file->audioProperties.bitsPerSample); track.modify()->setFileSize(getFileSize()); track.modify()->setLastWriteTime(getLastWriteTime()); diff --git a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.hpp b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.hpp index 7d5e00ff..37e03242 100644 --- a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.hpp +++ b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.hpp @@ -21,9 +21,9 @@ #include -#include "audio/AudioTypes.hpp" -#include "audio/IAudioFileInfo.hpp" -#include "audio/IImageReader.hpp" +#include "core/media/ImageType.hpp" + +#include "audio/AudioProperties.hpp" #include "image/Types.hpp" #include "scanners/FileScanOperationBase.hpp" @@ -39,12 +39,13 @@ namespace lms::db namespace lms::scanner { + struct AudioFileInfoParserSet; class TrackMetadataParser; struct ImageInfo { std::size_t index; - audio::Image::Type type{ audio::Image::Type::Unknown }; + core::media::ImageType type{ core::media::ImageType::Unknown }; std::uint64_t hash{}; std::size_t size{}; image::ImageProperties properties; @@ -55,7 +56,7 @@ namespace lms::scanner class AudioFileScanOperation : public FileScanOperationBase { public: - AudioFileScanOperation(FileToScan&& fileToScan, db::IDb& db, const ScannerSettings& settings, const TrackMetadataParser& metadataParser, const audio::ParserOptions& parserOptions); + AudioFileScanOperation(FileToScan&& fileToScan, db::IDb& db, const ScannerSettings& settings, const AudioFileInfoParserSet& audioFileInfoParserSet, const TrackMetadataParser& metadataParser); ~AudioFileScanOperation() override; AudioFileScanOperation(const AudioFileScanOperation&) = delete; AudioFileScanOperation& operator=(const AudioFileScanOperation&) = delete; @@ -65,8 +66,8 @@ namespace lms::scanner void scan() override; OperationResult processResult() override; + const AudioFileInfoParserSet& _audioFileInfoParserSet; const TrackMetadataParser& _metadataParser; - const audio::ParserOptions& _parserOptions; struct AudioFileInfo { diff --git a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanner.cpp b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanner.cpp index f38d65af..4d796301 100644 --- a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanner.cpp +++ b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanner.cpp @@ -19,11 +19,6 @@ #include "AudioFileScanner.hpp" -#include "core/IConfig.hpp" -#include "core/Service.hpp" - -#include "audio/IAudioFileInfo.hpp" - #include "database/IDb.hpp" #include "database/Session.hpp" #include "database/objects/MediaLibrary.hpp" @@ -31,6 +26,7 @@ #include "ScannerSettings.hpp" #include "scanners/Utils.hpp" +#include "scanners/audiofile/AudioFileInfoParserSet.hpp" #include "scanners/audiofile/AudioFileScanOperation.hpp" #include "scanners/audiofile/TrackMetadataParser.hpp" @@ -38,20 +34,6 @@ namespace lms::scanner { namespace { - audio::ParserOptions::AudioPropertiesReadStyle getParserReadStyle() - { - std::string_view readStyle{ core::Service::get()->getString("scanner-parser-read-style", "average") }; - - if (readStyle == "fast") - return audio::ParserOptions::AudioPropertiesReadStyle::Fast; - if (readStyle == "average") - return audio::ParserOptions::AudioPropertiesReadStyle::Average; - if (readStyle == "accurate") - return audio::ParserOptions::AudioPropertiesReadStyle::Accurate; - - throw core::LmsException{ "Invalid value for 'scanner-parser-read-style'" }; - } - TrackMetadataParser::Parameters createTrackMetadataParserParameters(const ScannerSettings& settings) { TrackMetadataParser::Parameters params; @@ -62,23 +44,13 @@ namespace lms::scanner return params; } - - audio::ParserOptions createAudioFileParserOptions() - { - audio::ParserOptions options; - options.readStyle = getParserReadStyle(); - options.parser = audio::ParserOptions::Parser::TagLib; // For now, always use TagLib - - return options; - } - } // namespace AudioFileScanner::AudioFileScanner(db::IDb& db, const ScannerSettings& settings) : _db{ db } , _settings{ settings } , _trackMetadataParser{ createTrackMetadataParserParameters(settings) } - , _parserOptions{ createAudioFileParserOptions() } + , _audioFileInfoParserSet{ createAudioFileInfoParserSet() } { } @@ -96,7 +68,7 @@ namespace lms::scanner std::span AudioFileScanner::getSupportedExtensions() const { - return audio::getSupportedExtensions(_parserOptions.parser); + return _audioFileInfoParserSet.supportedExtensions; } bool AudioFileScanner::needsScan(const FileToScan& file) const @@ -112,6 +84,6 @@ namespace lms::scanner std::unique_ptr AudioFileScanner::createScanOperation(FileToScan&& fileToScan) const { - return std::make_unique(std::move(fileToScan), _db, _settings, _trackMetadataParser, _parserOptions); + return std::make_unique(std::move(fileToScan), _db, _settings, _audioFileInfoParserSet, _trackMetadataParser); } } // namespace lms::scanner diff --git a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanner.hpp b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanner.hpp index 5a8b5c32..a7e3527b 100644 --- a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanner.hpp +++ b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanner.hpp @@ -19,23 +19,14 @@ #pragma once -#include "audio/IAudioFileInfo.hpp" - #include "scanners/IFileScanner.hpp" +#include "scanners/audiofile/AudioFileInfoParserSet.hpp" #include "scanners/audiofile/TrackMetadataParser.hpp" -namespace lms +namespace lms::db { - namespace db - { - class IDb; - } - - namespace metadata - { - class IAudioFileParser; - } -} // namespace lms + class IDb; +} namespace lms::scanner { @@ -59,6 +50,6 @@ namespace lms::scanner db::IDb& _db; const ScannerSettings& _settings; const TrackMetadataParser _trackMetadataParser; - const audio::ParserOptions _parserOptions; + const AudioFileInfoParserSet _audioFileInfoParserSet; }; } // namespace lms::scanner \ No newline at end of file diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp index 331bf201..895e0a9f 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp @@ -125,7 +125,7 @@ namespace lms::scanner db::TrackEmbeddedImage::FindParameters params; params.setMedium(medium->getId()); - params.setImageType(db::ImageType::Media); + params.setImageType(core::media::ImageType::Media); params.setSortMethod(db::TrackEmbeddedImageSortMethod::TrackNumberThenSizeDesc); db::TrackEmbeddedImage::find(session, params, [&](const db::TrackEmbeddedImage::pointer& foundImage) { diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp index b32b1ab5..5a91a074 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp @@ -140,7 +140,7 @@ namespace lms::scanner { db::TrackEmbeddedImage::FindParameters params; params.setRelease(release->getId()); - params.setImageType(db::ImageType::FrontCover); + params.setImageType(core::media::ImageType::FrontCover); params.setSortMethod(db::TrackEmbeddedImageSortMethod::DiscNumberThenTrackNumberThenSizeDesc); db::TrackEmbeddedImage::find(session, params, [&](const db::TrackEmbeddedImage::pointer& image) { if (!artwork) @@ -155,7 +155,7 @@ namespace lms::scanner { db::TrackEmbeddedImage::FindParameters params; params.setRelease(release->getId()); - params.setImageType(db::ImageType::Media); + params.setImageType(core::media::ImageType::Media); params.setSortMethod(db::TrackEmbeddedImageSortMethod::DiscNumberThenTrackNumberThenSizeDesc); db::TrackEmbeddedImage::find(session, params, [&](const db::TrackEmbeddedImage::pointer& image) { if (!artwork) @@ -167,7 +167,7 @@ namespace lms::scanner { db::TrackEmbeddedImage::FindParameters params; params.setRelease(release->getId()); - params.setImageType(db::ImageType::Other); + params.setImageType(core::media::ImageType::Other); params.setSortMethod(db::TrackEmbeddedImageSortMethod::DiscNumberThenTrackNumberThenSizeDesc); db::TrackEmbeddedImage::find(session, params, [&](const db::TrackEmbeddedImage::pointer& image) { if (!artwork) diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociateTrackImages.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociateTrackImages.cpp index e24f97a9..592b0b56 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociateTrackImages.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociateTrackImages.cpp @@ -64,7 +64,7 @@ namespace lms::scanner { db::TrackEmbeddedImage::FindParameters params; params.setTrack(track->getId()); - params.setImageType(db::ImageType::FrontCover); + params.setImageType(core::media::ImageType::FrontCover); params.setSortMethod(db::TrackEmbeddedImageSortMethod::SizeDesc); db::TrackEmbeddedImage::find(session, params, [&](const db::TrackEmbeddedImage::pointer& image) { @@ -94,7 +94,7 @@ namespace lms::scanner { db::TrackEmbeddedImage::FindParameters params; params.setTrack(track->getId()); - params.setImageType(db::ImageType::Media); + params.setImageType(core::media::ImageType::Media); params.setSortMethod(db::TrackEmbeddedImageSortMethod::SizeDesc); db::TrackEmbeddedImage::find(session, params, [&](const db::TrackEmbeddedImage::pointer& image) { diff --git a/src/libs/services/scanner/test/TrackMetadataParser.cpp b/src/libs/services/scanner/test/TrackMetadataParser.cpp index ddfb87ab..6f94bf4b 100644 --- a/src/libs/services/scanner/test/TrackMetadataParser.cpp +++ b/src/libs/services/scanner/test/TrackMetadataParser.cpp @@ -720,6 +720,57 @@ namespace lms::scanner::tests EXPECT_EQ(track.artistDisplayName, "Artist1, Artist2"); // reconstruct the artist display name } + TEST(TrackMetadataParser, heterogeneousArtistMbids) + { + { + const TestTagReader testTags{ + { + { audio::TagType::Artists, { "MyArtist" } }, + { audio::TagType::MusicBrainzArtistID, { "970f720b-c955-4ae7-870e-3a3716577cbf", "58eea0c6-1327-4354-8438-13e581894f35" } }, + + { audio::TagType::Album, { "MyAlbum" } }, + { audio::TagType::AlbumArtists, { "MyArtist" } }, + { audio::TagType::MusicBrainzReleaseArtistID, { "970f720b-c955-4ae7-870e-3a3716577cbf", "58eea0c6-1327-4354-8438-13e581894f35" } }, + } + }; + const Track track{ TrackMetadataParser{}.parseTrackMetaData(testTags) }; + + ASSERT_EQ(track.artists.size(), 1); + EXPECT_EQ(track.artists[0].name, "MyArtist"); + EXPECT_EQ(track.artists[0].mbid, std::nullopt); + + ASSERT_TRUE(track.medium.has_value()); + ASSERT_TRUE(track.medium->release.has_value()); + ASSERT_EQ(track.medium->release->artists.size(), 1); + EXPECT_EQ(track.medium->release->artists[0].name, "MyArtist"); + EXPECT_EQ(track.medium->release->artists[0].mbid, std::nullopt); + } + + { + const TestTagReader testTags{ + { + { audio::TagType::Artist, { "MyArtist" } }, + { audio::TagType::MusicBrainzArtistID, { "970f720b-c955-4ae7-870e-3a3716577cbf", "58eea0c6-1327-4354-8438-13e581894f35" } }, + + { audio::TagType::Album, { "MyAlbum" } }, + { audio::TagType::AlbumArtist, { "MyArtist" } }, + { audio::TagType::MusicBrainzReleaseArtistID, { "970f720b-c955-4ae7-870e-3a3716577cbf", "58eea0c6-1327-4354-8438-13e581894f35" } }, + } + }; + const Track track{ TrackMetadataParser{}.parseTrackMetaData(testTags) }; + + ASSERT_EQ(track.artists.size(), 1); + EXPECT_EQ(track.artists[0].name, "MyArtist"); + EXPECT_EQ(track.artists[0].mbid, std::nullopt); + + ASSERT_TRUE(track.medium.has_value()); + ASSERT_TRUE(track.medium->release.has_value()); + ASSERT_EQ(track.medium->release->artists.size(), 1); + EXPECT_EQ(track.medium->release->artists[0].name, "MyArtist"); + EXPECT_EQ(track.medium->release->artists[0].mbid, std::nullopt); + } + } + TEST(TrackMetadataParser, release_sortNameFallback) { const TestTagReader testTags{ diff --git a/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.hpp b/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.hpp index 25523e3b..3bd41c2f 100644 --- a/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.hpp +++ b/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.hpp @@ -26,7 +26,7 @@ #include #include -#include "database/Types.hpp" +#include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" #include "services/scrobbling/Listen.hpp" diff --git a/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp b/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp index 3d0b2e09..ff5c016f 100644 --- a/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp +++ b/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp @@ -31,6 +31,7 @@ #include "database/objects/Filters.hpp" #include "database/objects/ReleaseId.hpp" #include "database/objects/TrackId.hpp" +#include "database/objects/Types.hpp" #include "services/scrobbling/Listen.hpp" namespace lms::db diff --git a/src/libs/services/transcoding/impl/TranscodeService.cpp b/src/libs/services/transcoding/impl/TranscodeService.cpp index f9b503b5..3306f22f 100644 --- a/src/libs/services/transcoding/impl/TranscodeService.cpp +++ b/src/libs/services/transcoding/impl/TranscodeService.cpp @@ -19,11 +19,7 @@ #include "TranscodeService.hpp" -#include "audio/ITranscoder.hpp" #include "core/ILogger.hpp" -#include "core/UUID.hpp" -#include "database/IDb.hpp" -#include "database/Session.hpp" #include "TranscodeResourceHandler.hpp" @@ -38,14 +34,12 @@ namespace lms::transcoding } } // namespace - std::unique_ptr createTranscodeService(db::IDb& db, core::IChildProcessManager& childProcessManager) + std::unique_ptr createTranscodeService() { - return std::make_unique(db, childProcessManager); + return std::make_unique(); } - TranscodeService::TranscodeService(db::IDb& db, core::IChildProcessManager& childProcessManager) - : _db{ db } - , _childProcessManager(childProcessManager) + TranscodeService::TranscodeService() { LMS_LOG(TRANSCODING, INFO, "Service started!"); } @@ -61,10 +55,10 @@ namespace lms::transcoding if (estimateContentLength) { - if (parameters.inputParameters.offset < parameters.inputParameters.duration) - estimatedContentLength = doEstimateContentLength(*parameters.outputParameters.bitrate, parameters.inputParameters.duration - parameters.inputParameters.offset); + if (parameters.inputParameters.offset < parameters.inputParameters.audioProperties.duration) + estimatedContentLength = doEstimateContentLength(*parameters.outputParameters.bitrate, parameters.inputParameters.audioProperties.duration - parameters.inputParameters.offset); else - LMS_LOG(TRANSCODING, WARNING, "Offset " << parameters.inputParameters.offset << " is greater than audio file duration " << parameters.inputParameters.duration << ": not estimating content length"); + LMS_LOG(TRANSCODING, WARNING, "Offset " << parameters.inputParameters.offset << " is greater than audio file duration " << parameters.inputParameters.audioProperties.duration << ": not estimating content length"); } return std::make_unique(parameters, estimatedContentLength); diff --git a/src/libs/services/transcoding/impl/TranscodeService.hpp b/src/libs/services/transcoding/impl/TranscodeService.hpp index 82b86f88..9626bdfc 100644 --- a/src/libs/services/transcoding/impl/TranscodeService.hpp +++ b/src/libs/services/transcoding/impl/TranscodeService.hpp @@ -26,7 +26,7 @@ namespace lms::transcoding class TranscodeService : public ITranscodeService { public: - explicit TranscodeService(db::IDb& db, core::IChildProcessManager& childProcessManager); + explicit TranscodeService(); ~TranscodeService() override; TranscodeService(const TranscodeService&) = delete; @@ -34,8 +34,5 @@ namespace lms::transcoding private: std::unique_ptr createTranscodeResourceHandler(const audio::TranscodeParameters& parameters, bool estimateContentLength) override; - - db::IDb& _db; - core::IChildProcessManager& _childProcessManager; }; } // namespace lms::transcoding diff --git a/src/libs/services/transcoding/include/services/transcoding/ITranscodeService.hpp b/src/libs/services/transcoding/include/services/transcoding/ITranscodeService.hpp index a4def09c..b7a14df2 100644 --- a/src/libs/services/transcoding/include/services/transcoding/ITranscodeService.hpp +++ b/src/libs/services/transcoding/include/services/transcoding/ITranscodeService.hpp @@ -23,19 +23,10 @@ #include "audio/TranscodeTypes.hpp" -namespace lms +namespace lms::core { - namespace core - { - class IChildProcessManager; - class IResourceHandler; - } // namespace core - - namespace db - { - class IDb; - } -} // namespace lms + class IResourceHandler; +} // namespace lms::core namespace lms::transcoding { @@ -47,5 +38,5 @@ namespace lms::transcoding virtual std::unique_ptr createTranscodeResourceHandler(const audio::TranscodeParameters& parameters, bool estimateContentLength = false) = 0; }; - std::unique_ptr createTranscodeService(db::IDb& db, core::IChildProcessManager& childProcessManager); + std::unique_ptr createTranscodeService(); } // namespace lms::transcoding 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 ac1b4316..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 } }, @@ -230,6 +234,8 @@ namespace lms::api::subsonic { "/deleteBookmark", { handleDeleteBookmark } }, { "/getPlayQueue", { handleGetPlayQueue } }, { "/savePlayQueue", { handleSavePlayQueue } }, + { "/getPlayQueueByIndex", { handleGetPlayQueueByIndex } }, + { "/savePlayQueueByIndex", { handleSavePlayQueueByIndex } }, // Media library scanning { "/getScanStatus", { Scan::handleGetScanStatus } }, @@ -242,6 +248,9 @@ namespace lms::api::subsonic { "/download", handleDownload }, { "/stream", handleStream }, { "/getCoverArt", handleGetCoverArt }, + + // Transcoding extension + { "/getTranscodeStream", handleGetTranscodeStream }, }; struct TLSMonotonicMemoryResourceCleaner @@ -350,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/Bookmarks.cpp b/src/libs/subsonic/impl/endpoints/Bookmarks.cpp index 41ea02cc..3f9758ec 100644 --- a/src/libs/subsonic/impl/endpoints/Bookmarks.cpp +++ b/src/libs/subsonic/impl/endpoints/Bookmarks.cpp @@ -30,24 +30,20 @@ #include "responses/Bookmark.hpp" #include "responses/Song.hpp" -#include "core/String.hpp" - namespace lms::api::subsonic { - using namespace db; - Response handleGetBookmarks(RequestContext& context) { auto transaction{ context.getDbSession().createReadTransaction() }; - const auto bookmarkIds{ TrackBookmark::find(context.getDbSession(), context.getUser()->getId()) }; + const auto bookmarkIds{ db::TrackBookmark::find(context.getDbSession(), context.getUser()->getId()) }; Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; Response::Node& bookmarksNode{ response.createNode("bookmarks") }; - for (const TrackBookmarkId bookmarkId : bookmarkIds.results) + for (const db::TrackBookmarkId bookmarkId : bookmarkIds.results) { - const TrackBookmark::pointer bookmark{ TrackBookmark::find(context.getDbSession(), bookmarkId) }; + const db::TrackBookmark::pointer bookmark{ db::TrackBookmark::find(context.getDbSession(), bookmarkId) }; Response::Node bookmarkNode{ createBookmarkNode(bookmark) }; bookmarkNode.addChild("entry", createSongNode(context, bookmark->getTrack(), context.getUser())); bookmarksNode.addArrayChild("bookmark", std::move(bookmarkNode)); @@ -59,20 +55,20 @@ namespace lms::api::subsonic Response handleCreateBookmark(RequestContext& context) { // Mandatory params - TrackId trackId{ getMandatoryParameterAs(context.getParameters(), "id") }; + db::TrackId trackId{ getMandatoryParameterAs(context.getParameters(), "id") }; unsigned long position{ getMandatoryParameterAs(context.getParameters(), "position") }; const std::optional comment{ getParameterAs(context.getParameters(), "comment") }; auto transaction{ context.getDbSession().createWriteTransaction() }; - const Track::pointer track{ Track::find(context.getDbSession(), trackId) }; + const db::Track::pointer track{ db::Track::find(context.getDbSession(), trackId) }; if (!track) throw RequestedDataNotFoundError{}; // Replace any existing bookmark - auto bookmark{ TrackBookmark::find(context.getDbSession(), context.getUser()->getId(), trackId) }; + auto bookmark{ db::TrackBookmark::find(context.getDbSession(), context.getUser()->getId(), trackId) }; if (!bookmark) - bookmark = context.getDbSession().create(context.getUser(), track); + bookmark = context.getDbSession().create(context.getUser(), track); bookmark.modify()->setOffset(std::chrono::milliseconds{ position }); if (comment) @@ -84,11 +80,11 @@ namespace lms::api::subsonic Response handleDeleteBookmark(RequestContext& context) { // Mandatory params - TrackId trackId{ getMandatoryParameterAs(context.getParameters(), "id") }; + db::TrackId trackId{ getMandatoryParameterAs(context.getParameters(), "id") }; auto transaction{ context.getDbSession().createWriteTransaction() }; - auto bookmark{ TrackBookmark::find(context.getDbSession(), context.getUser()->getId(), trackId) }; + auto bookmark{ db::TrackBookmark::find(context.getDbSession(), context.getUser()->getId(), trackId) }; if (!bookmark) throw RequestedDataNotFoundError{}; @@ -97,27 +93,58 @@ namespace lms::api::subsonic return Response::createOkResponse(context.getServerProtocolVersion()); } - // Use a dedicated internal playlist - Response handleGetPlayQueue(RequestContext& context) + static db::PlayQueue::pointer getOrCreatePlayQueue(RequestContext& context) + { + constexpr std::string_view name; + + db::PlayQueue::pointer playQueue; + { + auto transaction{ context.getDbSession().createReadTransaction() }; + + playQueue = db::PlayQueue::find(context.getDbSession(), context.getUser()->getId(), name); + } + + if (!playQueue) + { + auto transaction{ context.getDbSession().createWriteTransaction() }; + + playQueue = db::PlayQueue::find(context.getDbSession(), context.getUser()->getId(), name); + if (!playQueue) + { + playQueue = context.getDbSession().create(context.getUser(), name); + playQueue.modify()->setLastModifiedDateTime(Wt::WDateTime::currentDateTime()); + } + } + + return playQueue; + } + + // PlayQueue makes use of a dedicated internal playlist + static Response handleGetPlayQueueCommon(RequestContext& context, bool byIndex) { Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; - auto transaction{ context.getDbSession().createReadTransaction() }; - const db::PlayQueue::pointer playQueue{ db::PlayQueue::find(context.getDbSession(), context.getUser()->getId(), "subsonic") }; - if (playQueue) - { - Response::Node& playQueueNode{ response.createNode("playQueue") }; - if (auto currentTrack{ playQueue->getTrackAtCurrentIndex() }) - { - // optional fields - playQueueNode.setAttribute("current", idToString(currentTrack->getId())); - playQueueNode.setAttribute("position", playQueue->getCurrentPositionInTrack().count()); - } + const db::PlayQueue::pointer playQueue{ getOrCreatePlayQueue(context) }; + assert(playQueue); - // mandatory fields - playQueueNode.setAttribute("username", context.getUser()->getLoginName()); - playQueueNode.setAttribute("changed", core::stringUtils::toISO8601String(playQueue->getLastModifiedDateTime())); - playQueueNode.setAttribute("changedBy", "unknown"); // we don't store the client name (could be several same clients on several devices...) + auto transaction{ context.getDbSession().createReadTransaction() }; + + Response::Node& playQueueNode{ response.createNode(byIndex ? core::LiteralString{ "playQueueByIndex" } : core::LiteralString{ "playQueue" }) }; + + // mandatory fields + playQueueNode.setAttribute("username", context.getUser()->getLoginName()); + playQueueNode.setAttribute("changed", core::stringUtils::toISO8601String(playQueue->getLastModifiedDateTime())); + playQueueNode.setAttribute("changedBy", ""); // we don't store the client name (could be several same clients on several devices...) + + // optional fields + if (!playQueue->isEmpty()) + { + if (byIndex) + playQueueNode.setAttribute("index", playQueue->getCurrentIndex()); + else if (const db::Track::pointer currentTrack{ playQueue->getTrackAtCurrentIndex() }) + playQueueNode.setAttribute("current", idToString(currentTrack->getId())); + + playQueueNode.setAttribute("position", std::chrono::duration_cast(playQueue->getCurrentPositionInTrack()).count()); playQueue->visitTracks([&](const db::Track::pointer& track) { playQueueNode.addArrayChild("entry", createSongNode(context, track, true /* id3 */)); @@ -127,50 +154,66 @@ namespace lms::api::subsonic return response; } - Response handleSavePlayQueue(RequestContext& context) + Response handleGetPlayQueue(RequestContext& context) { - // optional params - std::vector trackIds{ getMultiParametersAs(context.getParameters(), "id") }; - const std::optional currentTrackId{ getParameterAs(context.getParameters(), "current") }; + return handleGetPlayQueueCommon(context, false); + } + + Response handleGetPlayQueueByIndex(RequestContext& context) + { + return handleGetPlayQueueCommon(context, true); + } + + static Response handleSavePlayQueueCommon(RequestContext& context, bool byIndex) + { + std::vector trackIds{ getMultiParametersAs(context.getParameters(), "id") }; + const std::optional currentTrackId{ byIndex ? std::nullopt : getParameterAs(context.getParameters(), "current") }; + const std::optional currentIndex{ byIndex ? getParameterAs(context.getParameters(), "currentIndex") : std::nullopt }; const std::chrono::milliseconds currentPositionInTrack{ getParameterAs(context.getParameters(), "current").value_or(0) }; - std::vector tracks; - tracks.reserve(trackIds.size()); - - // no id means we clear the play queue (see https://github.com/opensubsonic/open-subsonic-api/pull/106) - if (!trackIds.empty()) - { - auto transaction{ context.getDbSession().createReadTransaction() }; - for (db::TrackId trackId : trackIds) - { - if (db::Track::pointer track{ db::Track::find(context.getDbSession(), trackId) }) - tracks.push_back(track); - } - } + db::PlayQueue::pointer playQueue{ getOrCreatePlayQueue(context) }; + assert(playQueue); { auto transaction{ context.getDbSession().createWriteTransaction() }; - db::PlayQueue::pointer playQueue{ db::PlayQueue::find(context.getDbSession(), context.getUser()->getId(), "subsonic") }; - if (!playQueue) - playQueue = context.getDbSession().create(context.getUser(), "subsonic"); - + // no id means we clear the play queue (see https://github.com/opensubsonic/open-subsonic-api/pull/106) playQueue.modify()->clear(); - std::size_t index{}; - for (std::size_t i{}; i < tracks.size(); ++i) - { - db::Track::pointer& track{ tracks[i] }; - playQueue.modify()->addTrack(track); - if (track->getId() == currentTrackId) - index = i; + std::size_t currentPlayQueueIndex{}; + std::size_t currentTrackIndex{}; + for (db::TrackId trackId : trackIds) + { + if (const db::Track::pointer track{ db::Track::find(context.getDbSession(), trackId) }) + { + playQueue.modify()->addTrack(track); + + if ((currentTrackId && *currentTrackId == track->getId()) + || (currentIndex && *currentIndex == currentTrackIndex)) + { + playQueue.modify()->setCurrentIndex(currentPlayQueueIndex); + } + + currentPlayQueueIndex++; + } + + currentTrackIndex++; } - playQueue.modify()->setCurrentIndex(index); playQueue.modify()->setCurrentPositionInTrack(currentPositionInTrack); playQueue.modify()->setLastModifiedDateTime(Wt::WDateTime::currentDateTime()); } return Response::createOkResponse(context.getServerProtocolVersion()); } + + Response handleSavePlayQueue(RequestContext& context) + { + return handleSavePlayQueueCommon(context, false); + } + + Response handleSavePlayQueueByIndex(RequestContext& context) + { + return handleSavePlayQueueCommon(context, true); + } } // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/endpoints/Bookmarks.hpp b/src/libs/subsonic/impl/endpoints/Bookmarks.hpp index 223582ed..4e7f0247 100644 --- a/src/libs/subsonic/impl/endpoints/Bookmarks.hpp +++ b/src/libs/subsonic/impl/endpoints/Bookmarks.hpp @@ -29,4 +29,6 @@ namespace lms::api::subsonic Response handleDeleteBookmark(RequestContext& context); Response handleGetPlayQueue(RequestContext& context); Response handleSavePlayQueue(RequestContext& context); + Response handleGetPlayQueueByIndex(RequestContext& context); + Response handleSavePlayQueueByIndex(RequestContext& context); } // namespace lms::api::subsonic 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 8254c229..9f2db823 100644 --- a/src/libs/subsonic/impl/endpoints/MediaRetrieval.cpp +++ b/src/libs/subsonic/impl/endpoints/MediaRetrieval.cpp @@ -26,20 +26,16 @@ #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 "core/media/MimeType.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/Types.hpp" #include "database/objects/User.hpp" #include "services/artwork/IArtworkService.hpp" -#include "services/podcast/IPodcastService.hpp" #include "services/transcoding/ITranscodeService.hpp" #include "CoverArtId.hpp" @@ -48,129 +44,70 @@ #include "SubsonicId.hpp" #include "SubsonicResponse.hpp" #include "responses/Lyrics.hpp" +#include "transcoding/AudioFileInfo.hpp" namespace lms::api::subsonic { namespace { - std::optional subsonicStreamFormatToTranscodingOutputFormat(std::string_view format) + struct OutputFormat { - 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; + std::string name; + core::media::Container container; + core::media::Codec codec; + }; + constexpr std::array outputFormats{ + OutputFormat{ "mp3", core::media::Container::MPEG, core::media::Codec::MP3 }, + OutputFormat{ "opus", core::media::Container::Ogg, core::media::Codec::Opus }, + OutputFormat{ "vorbis", core::media::Container::Ogg, core::media::Codec::Vorbis }, + }; + + std::optional getOutputFormatByName(std::string_view format) + { + std::optional res; + + const auto itFormat{ std::find_if(outputFormats.begin(), outputFormats.end(), [&format, &res](const OutputFormat& outputFormat) { + if (core::stringUtils::stringCaseInsensitiveEqual(format, outputFormat.name)) + { + res = outputFormat; + return true; + } + return false; + }) }; + if (itFormat != outputFormats.end()) + res = *itFormat; + + return res; } - audio::OutputFormat userTranscodeFormatToTranscodingFormat(db::TranscodingOutputFormat format) + std::optional userTranscodeFormatToOutputFormat(db::TranscodingOutputFormat format) { + std::optional res; + switch (format) { case db::TranscodingOutputFormat::MP3: - return audio::OutputFormat::MP3; + res = getOutputFormatByName("mp3"); + break; case db::TranscodingOutputFormat::OGG_OPUS: - return audio::OutputFormat::OGG_OPUS; - case db::TranscodingOutputFormat::MATROSKA_OPUS: - return audio::OutputFormat::MATROSKA_OPUS; + res = getOutputFormatByName("opus"); + break; case db::TranscodingOutputFormat::OGG_VORBIS: - return audio::OutputFormat::OGG_VORBIS; - case db::TranscodingOutputFormat::WEBM_VORBIS: - return audio::OutputFormat::WEBM_VORBIS; + res = getOutputFormatByName("vorbis"); + break; } - 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; + return res; } struct StreamParameters { std::filesystem::path filePath; - std::string fileMimeType; // set if known + audio::AudioProperties audioProperties; std::optional transcodeParameters; bool estimateContentLength{}; }; - bool isOutputFormatCompatible(const std::filesystem::path& trackPath, audio::OutputFormat outputFormat) - { - // TODO: put this information in db during scan - try - { - const auto audioFile{ audio::parseAudioFile(trackPath) }; - - return isCodecCompatibleWithOutputFormat(audioFile->getAudioProperties().codec, outputFormat); - } - catch (const audio::Exception& e) - { - // TODO 404? - 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 @@ -191,32 +128,31 @@ namespace lms::api::subsonic 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) }; + std::optional requestedFormat{ getOutputFormatByName(format) }; if (!requestedFormat) { if (context.getUser()->getSubsonicEnableTranscodingByDefault()) - requestedFormat = userTranscodeFormatToTranscodingFormat(context.getUser()->getSubsonicDefaultTranscodingOutputFormat()); + requestedFormat = userTranscodeFormatToOutputFormat(context.getUser()->getSubsonicDefaultTranscodingOutputFormat()); } - if (!requestedFormat && (maxBitRate == 0 || audioFileInfo.bitrate <= maxBitRate)) + if (!requestedFormat && (maxBitRate == 0 || audioFileInfo.audioProperties.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 + // Check if the input file 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 (requestedFormat && requestedFormat->container == audioFileInfo.audioProperties.container && requestedFormat->codec == audioFileInfo.audioProperties.codec) { - if (maxBitRate == 0 || audioFileInfo.bitrate <= maxBitRate) + if (maxBitRate == 0 || audioFileInfo.audioProperties.bitrate <= maxBitRate) { LMS_LOG(API_SUBSONIC, DEBUG, "File's bitrate and format are compatible with parameters => no transcoding"); return parameters; // no transcoding needed @@ -225,22 +161,24 @@ namespace lms::api::subsonic } // 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() }; + if (!requestedFormat) // no format provided => use user's default + requestedFormat = userTranscodeFormatToOutputFormat(context.getUser()->getSubsonicDefaultTranscodingOutputFormat()); + if (!bitrate) // no bitrate provided => use user's default + bitrate = context.getUser()->getSubsonicDefaultTranscodingOutputBitrate(); + if (maxBitRate) // but still honor provided maxBitRate, if any + bitrate = std::min(bitrate, maxBitRate); + transcodeParameters.inputParameters.filePath = audioFileInfo.path; - transcodeParameters.inputParameters.duration = audioFileInfo.duration; + transcodeParameters.inputParameters.audioProperties = audioFileInfo.audioProperties; transcodeParameters.inputParameters.offset = std::chrono::seconds{ timeOffset }; - ; transcodeParameters.outputParameters.bitrate = bitrate; - transcodeParameters.outputParameters.format = *requestedFormat; + transcodeParameters.outputParameters.format.emplace(); + transcodeParameters.outputParameters.format->container = requestedFormat->container; + transcodeParameters.outputParameters.format->codec = requestedFormat->codec; + transcodeParameters.outputParameters.stripMetadata = false; // We want clients to use metadata (offline use, replay gain, etc.) return parameters; @@ -352,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, streamParameters.fileMimeType); - } + 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/MediaRetrieval.hpp b/src/libs/subsonic/impl/endpoints/MediaRetrieval.hpp index 45da25a9..4291466c 100644 --- a/src/libs/subsonic/impl/endpoints/MediaRetrieval.hpp +++ b/src/libs/subsonic/impl/endpoints/MediaRetrieval.hpp @@ -26,7 +26,7 @@ namespace lms::api::subsonic { - struct RequestContext; + class RequestContext; Response handleGetLyrics(RequestContext& context); Response handleGetLyricsBySongId(RequestContext& context); 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 1b195949..9cc6eeee 100644 --- a/src/libs/subsonic/impl/endpoints/System.cpp +++ b/src/libs/subsonic/impl/endpoints/System.cpp @@ -1,3 +1,24 @@ +/* + * 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 "endpoints/System.hpp" namespace lms::api::subsonic @@ -23,34 +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& 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..b9112e5b --- /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::containerToString(audioProperties.container).str(); + res.codec = core::media::getCodecDesc(audioProperties.codec).name.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 detail::TranscodeDecisionResult transcodeDecision{ detail::computeTranscodeDecision(clientInfo, audioFileInfo.audioProperties) }; + + std::visit(core::utils::overloads{ + [&](const detail::DirectPlayResult&) { + transcodeNode.setAttribute("canDirectPlay", true); + transcodeNode.setAttribute("canTranscode", false); + }, + [&](const detail::TranscodeResult& transcodeRes) { + transcodeNode.setAttribute("canDirectPlay", false); + transcodeNode.setAttribute("canTranscode", true); + + for (detail::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 detail::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{ detail::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/UserManagement.cpp b/src/libs/subsonic/impl/endpoints/UserManagement.cpp index 2781db15..02b32e75 100644 --- a/src/libs/subsonic/impl/endpoints/UserManagement.cpp +++ b/src/libs/subsonic/impl/endpoints/UserManagement.cpp @@ -1,9 +1,26 @@ +/* + * 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 "UserManagement.hpp" -#include "core/Service.hpp" #include "database/Session.hpp" #include "database/objects/User.hpp" -#include "services/auth/IPasswordService.hpp" #include "ParameterParsing.hpp" #include "responses/User.hpp" 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..a5b877bb --- /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::detail +{ + namespace + { + constexpr std::array supportedTranscodeOutputFormats{ + audio::TranscodeOutputFormat{ .container = core::media::Container::MPEG, .codec = core::media::Codec::MP3 }, + audio::TranscodeOutputFormat{ .container = core::media::Container::Ogg, .codec = core::media::Codec::Vorbis }, + audio::TranscodeOutputFormat{ .container = core::media::Container::Ogg, .codec = core::media::Codec::Opus }, + audio::TranscodeOutputFormat{ .container = core::media::Container::FLAC, .codec = core::media::Codec::FLAC }, + }; + + bool isMatchingContainerName(core::media::Container 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::Container::AIFF: + containerNames = aiffNames; + break; + case core::media::Container::APE: + containerNames = apeNames; + break; + case core::media::Container::ASF: + containerNames = asfNames; + break; + case core::media::Container::DSF: + containerNames = dsfNames; + break; + case core::media::Container::MPC: + containerNames = mpcNames; + break; + case core::media::Container::MPEG: + containerNames = mpegNames; + break; + case core::media::Container::Ogg: + containerNames = oggNames; + break; + case core::media::Container::FLAC: + containerNames = flacNames; + break; + case core::media::Container::MP4: + containerNames = mp4Names; + break; + case core::media::Container::Shorten: + containerNames = shortenNames; + break; + case core::media::Container::TrueAudio: + containerNames = trueAudioNames; + break; + case core::media::Container::WAV: + containerNames = wavNames; + break; + case core::media::Container::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::Codec 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::Codec::AAC: + codecNames = aacCodecNames; + break; + case core::media::Codec::AC3: + codecNames = ac3CodecNames; + break; + case core::media::Codec::ALAC: + codecNames = alacCodecNames; + break; + case core::media::Codec::APE: + codecNames = apeCodecNames; + break; + case core::media::Codec::DSD: + codecNames = dsdCodecNames; + break; + case core::media::Codec::EAC3: + codecNames = eac3CodecNames; + break; + case core::media::Codec::FLAC: + codecNames = flacCodecNames; + break; + case core::media::Codec::MP3: + codecNames = mp3CodecNames; + break; + case core::media::Codec::MP4ALS: + codecNames = mp4alsCodecNames; + break; + case core::media::Codec::MPC7: + codecNames = mpc7CodecNames; + break; + case core::media::Codec::MPC8: + codecNames = mpc8CodecNames; + break; + case core::media::Codec::Opus: + codecNames = opusCodecNames; + break; + case core::media::Codec::PCM: + codecNames = pcmCodecNames; + break; + case core::media::Codec::Shorten: + codecNames = shortenCodecNames; + break; + case core::media::Codec::TrueAudio: + codecNames = trueAudioCodecNames; + break; + case core::media::Codec::Vorbis: + codecNames = vorbisCodecNames; + break; + case core::media::Codec::WavPack: + codecNames = wavPackCodecNames; + break; + case core::media::Codec::WMA1: + codecNames = wma1CodecNames; + break; + case core::media::Codec::WMA2: + codecNames = wma2CodecNames; + break; + case core::media::Codec::WMA9Lossless: + codecNames = wma9LosslessCodecNames; + break; + case core::media::Codec::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::Codec 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::getCodecDesc(source.codec).isLossless) + { + if (!core::media::getCodecDesc(transcodeFormat->codec).isLossless) + { + // 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::getCodecDesc(transcodeFormat->codec).isLossless) + 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::getCodecDesc(transcodeFormat->codec).isLossless && 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::detail 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..1a77061c --- /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 detail + { + 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 detail +} // 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/impl/responses/Album.hpp b/src/libs/subsonic/impl/responses/Album.hpp index 7af99b53..99688f4f 100644 --- a/src/libs/subsonic/impl/responses/Album.hpp +++ b/src/libs/subsonic/impl/responses/Album.hpp @@ -33,7 +33,7 @@ namespace lms::db namespace lms::api::subsonic { - struct RequestContext; + class RequestContext; Response::Node createAlbumNode(RequestContext& context, const db::ObjectPtr& release, bool id3, const db::ObjectPtr& directory = {}); } // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/AlbumInfo.hpp b/src/libs/subsonic/impl/responses/AlbumInfo.hpp index fac1c759..77c90a63 100644 --- a/src/libs/subsonic/impl/responses/AlbumInfo.hpp +++ b/src/libs/subsonic/impl/responses/AlbumInfo.hpp @@ -30,7 +30,7 @@ namespace lms::db namespace lms::api::subsonic { - struct RequestContext; + class RequestContext; Response::Node createAlbumInfoNode(RequestContext& context, const db::ObjectPtr& release); } // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Artist.hpp b/src/libs/subsonic/impl/responses/Artist.hpp index 3420d38b..1ae4ec86 100644 --- a/src/libs/subsonic/impl/responses/Artist.hpp +++ b/src/libs/subsonic/impl/responses/Artist.hpp @@ -23,7 +23,7 @@ #include #include "database/Object.hpp" -#include "database/Types.hpp" +#include "database/objects/Types.hpp" #include "SubsonicResponse.hpp" @@ -36,7 +36,7 @@ namespace lms::db namespace lms::api::subsonic { - struct RequestContext; + class RequestContext; namespace utils { diff --git a/src/libs/subsonic/impl/responses/Bookmark.cpp b/src/libs/subsonic/impl/responses/Bookmark.cpp index 07069412..34fa86e8 100644 --- a/src/libs/subsonic/impl/responses/Bookmark.cpp +++ b/src/libs/subsonic/impl/responses/Bookmark.cpp @@ -24,7 +24,7 @@ namespace lms::api::subsonic { - static const std::string_view reportedDummyDate{ "2000-01-01T00:00:00" }; + constexpr std::string_view reportedDummyDate{ "2000-01-01T00:00:00" }; Response::Node createBookmarkNode(const db::ObjectPtr& trackBookmark) { diff --git a/src/libs/subsonic/impl/responses/Genre.hpp b/src/libs/subsonic/impl/responses/Genre.hpp index cc269676..cd34c9dd 100644 --- a/src/libs/subsonic/impl/responses/Genre.hpp +++ b/src/libs/subsonic/impl/responses/Genre.hpp @@ -30,7 +30,7 @@ namespace lms::db namespace lms::api::subsonic { - struct RequestContext; + class RequestContext; Response::Node createGenreNode(RequestContext& context, const db::ObjectPtr& cluster); } // namespace lms::api::subsonic diff --git a/src/libs/subsonic/impl/responses/Lyrics.hpp b/src/libs/subsonic/impl/responses/Lyrics.hpp index 78c2f1b4..0a7fdb67 100644 --- a/src/libs/subsonic/impl/responses/Lyrics.hpp +++ b/src/libs/subsonic/impl/responses/Lyrics.hpp @@ -30,7 +30,7 @@ namespace lms::db namespace lms::api::subsonic { - struct RequestContext; + class RequestContext; Response::Node createLyricsNode(RequestContext& context, const db::ObjectPtr& lyrics); Response::Node createStructuredLyricsNode(RequestContext& context, const db::ObjectPtr& lyrics); diff --git a/src/libs/subsonic/impl/responses/Playlist.hpp b/src/libs/subsonic/impl/responses/Playlist.hpp index 6c6c8e00..d4aaca07 100644 --- a/src/libs/subsonic/impl/responses/Playlist.hpp +++ b/src/libs/subsonic/impl/responses/Playlist.hpp @@ -30,7 +30,7 @@ namespace lms::db namespace lms::api::subsonic { - struct RequestContext; + class RequestContext; Response::Node createPlaylistNode(RequestContext& context, const db::ObjectPtr& tracklist); } // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Podcast.hpp b/src/libs/subsonic/impl/responses/Podcast.hpp index 51eda2c9..06cab0f6 100644 --- a/src/libs/subsonic/impl/responses/Podcast.hpp +++ b/src/libs/subsonic/impl/responses/Podcast.hpp @@ -31,7 +31,7 @@ namespace lms::db namespace lms::api::subsonic { - struct RequestContext; + class RequestContext; Response::Node createPodcastEpisodeNode(const db::ObjectPtr& episode); Response::Node createPodcastNode(RequestContext& context, const db::ObjectPtr& podcast, bool includeEpisodes); diff --git a/src/libs/subsonic/impl/responses/Song.cpp b/src/libs/subsonic/impl/responses/Song.cpp index 87a0d753..b7a44fda 100644 --- a/src/libs/subsonic/impl/responses/Song.cpp +++ b/src/libs/subsonic/impl/responses/Song.cpp @@ -51,31 +51,25 @@ namespace lms::api::subsonic { - using namespace db; - namespace { - std::string_view formatToSuffix(TranscodingOutputFormat format) + std::string_view formatToSuffix(db::TranscodingOutputFormat format) { switch (format) { - case TranscodingOutputFormat::MP3: + case db::TranscodingOutputFormat::MP3: return "mp3"; - case TranscodingOutputFormat::OGG_OPUS: + case db::TranscodingOutputFormat::OGG_OPUS: return "opus"; - case TranscodingOutputFormat::MATROSKA_OPUS: - return "mka"; - case TranscodingOutputFormat::OGG_VORBIS: + case db::TranscodingOutputFormat::OGG_VORBIS: return "ogg"; - case TranscodingOutputFormat::WEBM_VORBIS: - return "webm"; } return ""; } } // namespace - Response::Node createSongNode(RequestContext& context, const Track::pointer& track, bool id3) + Response::Node createSongNode(RequestContext& context, const db::Track::pointer& track, bool id3) { LMS_SCOPED_TRACE_DETAILED("Subsonic", "CreateSong"); @@ -136,7 +130,7 @@ namespace lms::api::subsonic trackResponse.setAttribute("coverArt", idToString(coverArtId)); } - const std::vector& artists{ track->getArtists({ TrackArtistLinkType::Artist }) }; + const std::vector& artists{ track->getArtists({ db::TrackArtistLinkType::Artist }) }; if (!artists.empty()) { if (!track->getArtistDisplayName().empty()) @@ -148,7 +142,7 @@ namespace lms::api::subsonic trackResponse.setAttribute("artistId", idToString(artists.front()->getId())); } - const Release::pointer release{ track->getRelease() }; + const db::Release::pointer release{ track->getRelease() }; if (release) { trackResponse.setAttribute("album", release->getName()); @@ -167,13 +161,13 @@ namespace lms::api::subsonic trackResponse.setAttribute("starred", core::stringUtils::toISO8601String(dateTime)); // Report the first GENRE for this track - std::vector genres; + std::vector genres; { - Cluster::FindParameters params; + db::Cluster::FindParameters params; params.setTrack(track->getId()); params.setClusterTypeName("GENRE"); - genres = Cluster::find(context.getDbSession(), params).results; + genres = db::Cluster::find(context.getDbSession(), params).results; if (!genres.empty()) trackResponse.setAttribute("genre", genres.front()->getName()); } @@ -183,7 +177,7 @@ namespace lms::api::subsonic return trackResponse; trackResponse.setAttribute("comment", track->getComment()); - trackResponse.setAttribute("bitDepth", track->getBitsPerSample()); + trackResponse.setAttribute("bitDepth", track->getBitsPerSample() ? *track->getBitsPerSample() : 0); trackResponse.setAttribute("samplingRate", track->getSampleRate()); trackResponse.setAttribute("channelCount", track->getChannelCount()); @@ -204,13 +198,13 @@ namespace lms::api::subsonic trackResponse.createEmptyArrayChild("artists"); trackResponse.createEmptyArrayChild("contributors"); - TrackArtistLink::find(context.getDbSession(), track->getId(), [&](const TrackArtistLink::pointer& link, const Artist::pointer& artist) { + db::TrackArtistLink::find(context.getDbSession(), track->getId(), [&](const db::TrackArtistLink::pointer& link, const db::Artist::pointer& artist) { switch (link->getType()) { - case TrackArtistLinkType::Artist: + case db::TrackArtistLinkType::Artist: trackResponse.addArrayChild("artists", createArtistNode(artist)); break; - case TrackArtistLinkType::ReleaseArtist: + case db::TrackArtistLinkType::ReleaseArtist: trackResponse.addArrayChild("albumartists", createArtistNode(artist)); break; default: @@ -226,11 +220,11 @@ namespace lms::api::subsonic auto addClusters{ [&](Response::Node::Key field, std::string_view clusterTypeName) { trackResponse.createEmptyArrayValue(field); - Cluster::FindParameters params; + db::Cluster::FindParameters params; params.setTrack(track->getId()); params.setClusterTypeName(clusterTypeName); - for (const auto& cluster : Cluster::find(context.getDbSession(), params).results) + for (const auto& cluster : db::Cluster::find(context.getDbSession(), params).results) trackResponse.addArrayValue(field, cluster->getName()); } }; diff --git a/src/libs/subsonic/impl/responses/Song.hpp b/src/libs/subsonic/impl/responses/Song.hpp index 87761172..ac22ba1a 100644 --- a/src/libs/subsonic/impl/responses/Song.hpp +++ b/src/libs/subsonic/impl/responses/Song.hpp @@ -32,7 +32,7 @@ namespace lms::db namespace lms::api::subsonic { - struct RequestContext; + class RequestContext; Response::Node createSongNode(RequestContext& context, const db::ObjectPtr& track, bool id3); } // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/User.hpp b/src/libs/subsonic/impl/responses/User.hpp index 6eb758a9..68bf29e6 100644 --- a/src/libs/subsonic/impl/responses/User.hpp +++ b/src/libs/subsonic/impl/responses/User.hpp @@ -30,7 +30,7 @@ namespace lms::db namespace lms::api::subsonic { - struct RequestContext; + class RequestContext; Response::Node createUserNode(RequestContext& context, const db::ObjectPtr& user); } // 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..e9db7dcf --- /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/Codec.hpp" +#include "core/media/Container.hpp" + +#include "endpoints/transcoding/TranscodeDecision.hpp" +#include "payloads/ClientInfo.hpp" + +namespace lms::api::subsonic +{ + namespace detail + { + std::ostream& operator<<(std::ostream& os, const detail::TranscodeDecisionResult& result) + { + std::visit(core::utils::overloads{ + [&](const detail::DirectPlayResult&) { os << "direct play"; }, + [&](const detail::FailureResult& res) { os << "failure: " << res.reason; }, + [&](const detail::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 detail + + namespace + { + struct TestCase + { + ClientInfo clientInfo; + audio::AudioProperties source; + + detail::TranscodeDecisionResult expected; + }; + + void processTests(std::span testCases) + { + for (std::size_t testCaseIndex{ 0 }; testCaseIndex < std::size(testCases); ++testCaseIndex) + { + const auto& testCase{ testCases[testCaseIndex] }; + const detail::TranscodeDecisionResult decision{ detail::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::Container::MPEG, + .codec = core::media::Codec::MP3, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 128'000, + .channelCount = 2, + .sampleRate = 44'100, + .bitsPerSample = std::nullopt, + }, + + .expected = { detail::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::Container::MPEG, + .codec = core::media::Codec::MP3, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 128'000, + .channelCount = 2, + .sampleRate = 44'100, + .bitsPerSample = std::nullopt, + }, + + .expected = { detail::TranscodeResult{ .reasons = { detail::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::Container::MPEG, + .codec = core::media::Codec::MP3, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 128'000, + .channelCount = 2, + .sampleRate = 44'100, + .bitsPerSample = std::nullopt, + }, + + .expected = { detail::TranscodeResult{ .reasons = { detail::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::Container::MPEG, + .codec = core::media::Codec::MP3, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 192'000, + .channelCount = 2, + .sampleRate = 44'100, + .bitsPerSample = std::nullopt, + }, + + .expected = { detail::TranscodeResult{ .reasons = { detail::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::Container::MPEG, + .codec = core::media::Codec::MP3, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 192'000, + .channelCount = 2, + .sampleRate = 96'000, + .bitsPerSample = std::nullopt, + }, + + .expected = { detail::TranscodeResult{ .reasons = { detail::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::Container::MPEG, + .codec = core::media::Codec::MP3, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 192'000, + .channelCount = 5, + .sampleRate = 48'000, + .bitsPerSample = std::nullopt, + }, + + .expected = { detail::TranscodeResult{ .reasons = { detail::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::Container::MPEG, + .codec = core::media::Codec::MP3, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 192'000, + .channelCount = 5, + .sampleRate = 48'000, + .bitsPerSample = std::nullopt, + }, + + .expected = { detail::TranscodeResult{ .reasons = { detail::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::Container::Ogg, + .codec = core::media::Codec::Opus, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 128'000, + .channelCount = 2, + .sampleRate = 48'000, + .bitsPerSample = std::nullopt, + }, + + .expected = { detail::TranscodeResult{ .reasons = { detail::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::Container::FLAC, + .codec = core::media::Codec::FLAC, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 750'000, + .channelCount = 2, + .sampleRate = 48'000, + .bitsPerSample = 16, + }, + + .expected = { detail::TranscodeResult{ .reasons = { detail::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::Container::FLAC, + .codec = core::media::Codec::FLAC, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 750'000, + .channelCount = 2, + .sampleRate = 48'000, + .bitsPerSample = 16, + }, + + .expected = { detail::TranscodeResult{ .reasons = { detail::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::Container::MPEG, + .codec = core::media::Codec::MP3, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 128'000, + .channelCount = 2, + .sampleRate = 48'000, + .bitsPerSample = std::nullopt, + }, + + .expected = { detail::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::Container::MPEG, + .codec = core::media::Codec::MP3, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 128'000, + .channelCount = 2, + .sampleRate = 48'000, + .bitsPerSample = std::nullopt, + }, + + .expected = { detail::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::Container::FLAC, + .codec = core::media::Codec::FLAC, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 1'000'000, + .channelCount = 2, + .sampleRate = 48'000, + .bitsPerSample = 16, + }, + + .expected = { detail::TranscodeResult{ .reasons = { detail::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::Container::FLAC, + .codec = core::media::Codec::FLAC, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 950'000, + .channelCount = 2, + .sampleRate = 96'000, + .bitsPerSample = 24, + }, + + .expected = { detail::TranscodeResult{ .reasons = { detail::TranscodeReason::AudioSampleRateNotSupported, detail::TranscodeReason::ContainerNotSupported, detail::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::Container::FLAC, + .codec = core::media::Codec::FLAC, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 950'000, + .channelCount = 2, + .sampleRate = 96'000, + .bitsPerSample = 24, + }, + + .expected = { detail::TranscodeResult{ .reasons = { detail::TranscodeReason::AudioSampleRateNotSupported, detail::TranscodeReason::ContainerNotSupported, detail::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::Container::Ogg, + .codec = core::media::Codec::Vorbis, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 128'000, + .channelCount = 2, + .sampleRate = 48'000, + .bitsPerSample = 16, + }, + + .expected = { detail::TranscodeResult{ .reasons = { detail::TranscodeReason::ContainerNotSupported, detail::TranscodeReason::ContainerNotSupported, detail::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::Container::DSF, + .codec = core::media::Codec::DSD, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 950'000, + .channelCount = 2, + .sampleRate = 96'000, + .bitsPerSample = 24, + }, + + .expected = { detail::TranscodeResult{ .reasons = { detail::TranscodeReason::ContainerNotSupported, detail::TranscodeReason::ContainerNotSupported, detail::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::Container::MPEG, + .codec = core::media::Codec::MP3, + .duration = std::chrono::seconds{ 60 }, + .bitrate = 128'000, + .channelCount = 2, + .sampleRate = 44'100, + .bitsPerSample = std::nullopt, + }, + + .expected = { detail::TranscodeResult{ .reasons = { detail::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 diff --git a/src/lms/main.cpp b/src/lms/main.cpp index 14c149d2..7b6f40c8 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -428,7 +428,7 @@ namespace lms core::Service recommendationService{ recommendation::createRecommendationService(*database) }; core::Service playlistGeneratorService{ recommendation::createPlaylistGeneratorService(*database, *recommendationService) }; core::Service scannerService{ scanner::createScannerService(*database, cachePath) }; - core::Service transcodingService{ transcoding::createTranscodeService(*database, *childProcessManagerService) }; + core::Service transcodingService{ transcoding::createTranscodeService() }; core::Service podcastService{ podcast::createPodcastService(ioContext, *database, cachePath / "podcasts") }; scannerService->getEvents().scanComplete.connect([&] { diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index 271b043a..216f0a36 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -26,9 +26,11 @@ #include #include +#include "core/IConfig.hpp" #include "core/ILogger.hpp" #include "core/ITraceLogger.hpp" #include "core/Service.hpp" + #include "database/IDb.hpp" #include "database/IQueryPlanRecorder.hpp" #include "database/Session.hpp" @@ -198,6 +200,31 @@ namespace lms::ui return static_cast(Wt::WApplication::instance()); } + LmsApplication::LmsApplication(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend) + : Wt::WApplication{ env } + , _db{ db } + , _appManager{ appManager } + , _authBackend{ authBackend } + , _areDownloadsEnabled(core::Service::get()->getBool("ui-allow-downloads", true)) + { + try + { + init(); + } + catch (LmsApplicationException& e) + { + LMS_LOG(UI, WARNING, "Caught a LmsApplication exception: " << e.what()); + handleException(e); + } + catch (std::exception& e) + { + LMS_LOG(UI, ERROR, "Caught exception: " << e.what()); + throw core::LmsException{ "Internal error" }; // Do not put details here at it may appear on the user rendered html + } + } + + LmsApplication::~LmsApplication() = default; + db::IDb& LmsApplication::getDb() { return _db; @@ -240,30 +267,11 @@ namespace lms::ui return _user->userLoginName; } - LmsApplication::LmsApplication(const Wt::WEnvironment& env, db::IDb& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend) - : Wt::WApplication{ env } - , _db{ db } - , _appManager{ appManager } - , _authBackend{ authBackend } + bool LmsApplication::areDownloadsEnabled() const { - try - { - init(); - } - catch (LmsApplicationException& e) - { - LMS_LOG(UI, WARNING, "Caught a LmsApplication exception: " << e.what()); - handleException(e); - } - catch (std::exception& e) - { - LMS_LOG(UI, ERROR, "Caught exception: " << e.what()); - throw core::LmsException{ "Internal error" }; // Do not put details here at it may appear on the user rendered html - } + return _areDownloadsEnabled; } - LmsApplication::~LmsApplication() = default; - void LmsApplication::init() { LMS_SCOPED_TRACE_OVERVIEW("UI", "ApplicationInit"); diff --git a/src/lms/ui/LmsApplication.hpp b/src/lms/ui/LmsApplication.hpp index 75dfee52..c54646c4 100644 --- a/src/lms/ui/LmsApplication.hpp +++ b/src/lms/ui/LmsApplication.hpp @@ -26,7 +26,7 @@ #include #include "database/Object.hpp" -#include "database/Types.hpp" +#include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" #include "services/scanner/ScannerEvents.hpp" @@ -70,6 +70,8 @@ namespace lms::ui db::UserType getUserType() const; // user must be logged in prior this call std::string_view getUserLoginName() const; // user must be logged in prior this call + bool areDownloadsEnabled() const; + // Proxified scanner events scanner::Events& getScannerEvents() { return _scannerEvents; } @@ -109,6 +111,7 @@ namespace lms::ui Wt::Signal<> _preQuit; LmsApplicationManager& _appManager; const AuthenticationBackend _authBackend; + const bool _areDownloadsEnabled; scanner::Events _scannerEvents; struct UserAuthInfo { diff --git a/src/lms/ui/MediaPlayer.cpp b/src/lms/ui/MediaPlayer.cpp index 6fa7e23f..6155327d 100644 --- a/src/lms/ui/MediaPlayer.cpp +++ b/src/lms/ui/MediaPlayer.cpp @@ -101,9 +101,7 @@ namespace lms::ui { case MediaPlayer::Format::MP3: case MediaPlayer::Format::OGG_OPUS: - case MediaPlayer::Format::MATROSKA_OPUS: case MediaPlayer::Format::OGG_VORBIS: - case MediaPlayer::Format::WEBM_VORBIS: return format; } diff --git a/src/lms/ui/MediaPlayer.hpp b/src/lms/ui/MediaPlayer.hpp index ea5472ac..7620b6fe 100644 --- a/src/lms/ui/MediaPlayer.hpp +++ b/src/lms/ui/MediaPlayer.hpp @@ -27,8 +27,8 @@ #include #include -#include "database/Types.hpp" #include "database/objects/TrackId.hpp" +#include "database/objects/Types.hpp" namespace lms::ui { @@ -129,5 +129,4 @@ namespace lms::ui Wt::WAnchor* _artist{}; Wt::WPushButton* _playQueue{}; }; - } // namespace lms::ui diff --git a/src/lms/ui/PlayQueue.cpp b/src/lms/ui/PlayQueue.cpp index b5328b9a..3bdfe3e5 100644 --- a/src/lms/ui/PlayQueue.cpp +++ b/src/lms/ui/PlayQueue.cpp @@ -114,7 +114,7 @@ namespace lms::ui PlayQueue::PlayQueue() : Template{ Wt::WString::tr("Lms.PlayQueue.template") } - , _capacity{ core::Service::get()->getULong("playqueue-max-entry-count", 1000) } + , _capacity{ core::Service::get()->getULong("ui-playqueue-max-entry-count", 1000) } { initTrackLists(); @@ -578,8 +578,12 @@ namespace lms::ui } }); - entry->bindNew("download", Wt::WString::tr("Lms.Explore.download")) - ->setLink(Wt::WLink{ std::make_unique(trackId) }); + if (LmsApp->areDownloadsEnabled()) + { + entry->setCondition("if-has-download", true); + entry->bindNew("download", Wt::WString::tr("Lms.Explore.download")) + ->setLink(Wt::WLink{ std::make_unique(trackId) }); + } } void PlayQueue::enqueueRadioTracksIfNeeded() diff --git a/src/lms/ui/SettingsView.cpp b/src/lms/ui/SettingsView.cpp index 94a3078a..8707d4a7 100644 --- a/src/lms/ui/SettingsView.cpp +++ b/src/lms/ui/SettingsView.cpp @@ -478,9 +478,7 @@ namespace lms::ui _transcodingOutputFormatModel = std::make_shared>(); _transcodingOutputFormatModel->add(Wt::WString::tr("Lms.Settings.transcoding-output-format.mp3"), db::TranscodingOutputFormat::MP3); _transcodingOutputFormatModel->add(Wt::WString::tr("Lms.Settings.transcoding-output-format.ogg_opus"), db::TranscodingOutputFormat::OGG_OPUS); - _transcodingOutputFormatModel->add(Wt::WString::tr("Lms.Settings.transcoding-output-format.matroska_opus"), db::TranscodingOutputFormat::MATROSKA_OPUS); _transcodingOutputFormatModel->add(Wt::WString::tr("Lms.Settings.transcoding-output-format.ogg_vorbis"), db::TranscodingOutputFormat::OGG_VORBIS); - _transcodingOutputFormatModel->add(Wt::WString::tr("Lms.Settings.transcoding-output-format.webm_vorbis"), db::TranscodingOutputFormat::WEBM_VORBIS); _replayGainModeModel = std::make_shared(); _replayGainModeModel->add(Wt::WString::tr("Lms.Settings.replaygain-mode.none"), MediaPlayer::Settings::ReplayGain::Mode::None); diff --git a/src/lms/ui/State.cpp b/src/lms/ui/State.cpp index 52f6c7e1..6d7d657c 100644 --- a/src/lms/ui/State.cpp +++ b/src/lms/ui/State.cpp @@ -25,7 +25,7 @@ #include "LmsApplication.hpp" -namespace lms::ui::state::details +namespace lms::ui::state::detail { void writeValue(std::string_view item, std::string_view value) { @@ -81,5 +81,4 @@ namespace lms::ui::state::details state.remove(); } } - -} // namespace lms::ui::state::details +} // namespace lms::ui::state::detail diff --git a/src/lms/ui/State.hpp b/src/lms/ui/State.hpp index 603233ab..8f51338c 100644 --- a/src/lms/ui/State.hpp +++ b/src/lms/ui/State.hpp @@ -27,12 +27,12 @@ namespace lms::ui::state { - namespace details + namespace detail { std::optional readValue(std::string_view item); void writeValue(std::string_view item, std::string_view value); void eraseValue(std::string_view item); - } // namespace details + } // namespace detail template void writeValue(std::string_view item, std::optional value) @@ -40,18 +40,18 @@ namespace lms::ui::state if (value.has_value()) { if constexpr (std::is_enum_v) - details::writeValue(item, std::to_string(static_cast>(*value))); + detail::writeValue(item, std::to_string(static_cast>(*value))); else - details::writeValue(item, std::to_string(*value)); + detail::writeValue(item, std::to_string(*value)); } else - details::eraseValue(item); + detail::eraseValue(item); } template std::optional readValue(std::string_view item) { - if (std::optional res{ details::readValue(item) }) + if (std::optional res{ detail::readValue(item) }) return core::stringUtils::readAs(*res); return std::nullopt; diff --git a/src/lms/ui/explore/ArtistCollector.hpp b/src/lms/ui/explore/ArtistCollector.hpp index 7d09cdb1..6d96602b 100644 --- a/src/lms/ui/explore/ArtistCollector.hpp +++ b/src/lms/ui/explore/ArtistCollector.hpp @@ -21,9 +21,10 @@ #include -#include "DatabaseCollectorBase.hpp" -#include "database/Types.hpp" #include "database/objects/ArtistId.hpp" +#include "database/objects/Types.hpp" + +#include "DatabaseCollectorBase.hpp" namespace lms::db { diff --git a/src/lms/ui/explore/ArtistListHelpers.hpp b/src/lms/ui/explore/ArtistListHelpers.hpp index 55a7fd0c..edde480a 100644 --- a/src/lms/ui/explore/ArtistListHelpers.hpp +++ b/src/lms/ui/explore/ArtistListHelpers.hpp @@ -23,9 +23,10 @@ #include -#include "common/ValueStringModel.hpp" #include "database/Object.hpp" -#include "database/Types.hpp" +#include "database/objects/Types.hpp" + +#include "common/ValueStringModel.hpp" namespace lms::db { diff --git a/src/lms/ui/explore/ArtistView.cpp b/src/lms/ui/explore/ArtistView.cpp index 35ed3989..297f58eb 100644 --- a/src/lms/ui/explore/ArtistView.cpp +++ b/src/lms/ui/explore/ArtistView.cpp @@ -80,11 +80,6 @@ namespace lms::ui refreshView(); }); - filters.updated().connect([this] { - _needForceRefresh = true; - refreshView(); - }); - refreshView(); } @@ -96,13 +91,12 @@ namespace lms::ui const auto artistId{ extractArtistIdFromInternalPath() }; // consider everything is up to date is the same artist is being rendered - if (!_needForceRefresh && artistId && *artistId == _artistId) + if (artistId && *artistId == _artistId) return; clear(); _artistId = {}; _trackContainer = nullptr; - _needForceRefresh = false; if (!artistId) throw ArtistNotFoundException{}; @@ -168,8 +162,13 @@ namespace lms::ui .connect([this] { _playQueueController.processCommand(PlayQueueController::Command::PlayOrAddLast, { _artistId }); }); - bindNew("download", Wt::WString::tr("Lms.Explore.download")) - ->setLink(Wt::WLink{ std::make_unique(_artistId) }); + + if (LmsApp->areDownloadsEnabled()) + { + setCondition("if-has-download", true); + bindNew("download", Wt::WString::tr("Lms.Explore.download")) + ->setLink(Wt::WLink{ std::make_unique(_artistId) }); + } { auto isStarred{ [this] { return core::Service::get()->isStarred(LmsApp->getUserId(), _artistId); } }; @@ -231,7 +230,6 @@ namespace lms::ui _releaseContainers.clear(); db::Release::FindParameters params; - params.setFilters(_filters.getDbFilters()); params.setArtist(_artistId, { db::TrackArtistLinkType::ReleaseArtist }, {}); params.setSortMethod(LmsApp->getUser()->getUIArtistReleaseSortMethod()); diff --git a/src/lms/ui/explore/ArtistView.hpp b/src/lms/ui/explore/ArtistView.hpp index a05f3de3..521cd03e 100644 --- a/src/lms/ui/explore/ArtistView.hpp +++ b/src/lms/ui/explore/ArtistView.hpp @@ -76,6 +76,5 @@ namespace lms::ui ReleaseContainer _appearsOnReleaseContainer{}; InfiniteScrollingContainer* _trackContainer{}; db::ArtistId _artistId{}; - bool _needForceRefresh{}; }; } // namespace lms::ui diff --git a/src/lms/ui/explore/Filters.cpp b/src/lms/ui/explore/Filters.cpp index 7d90ef87..696c7795 100644 --- a/src/lms/ui/explore/Filters.cpp +++ b/src/lms/ui/explore/Filters.cpp @@ -26,6 +26,9 @@ #include #include +#include "core/Utils.hpp" + +#include "core/media/Codec.hpp" #include "database/Session.hpp" #include "database/objects/Cluster.hpp" #include "database/objects/LabelId.hpp" @@ -55,7 +58,11 @@ namespace lms::ui { }; - using TypeVariant = std::variant; + struct CodecTag + { + }; + + using TypeVariant = std::variant; using TypeModel = ValueStringModel; std::unique_ptr createTypeModel() @@ -72,11 +79,12 @@ namespace lms::ui typeModel->add(Wt::WString::tr("Lms.Explore.media-library"), MediaLibraryTag{}); typeModel->add(Wt::WString::tr("Lms.Explore.label"), LabelTag{}); typeModel->add(Wt::WString::tr("Lms.Explore.release-type"), ReleaseTypeTag{}); + typeModel->add(Wt::WString::tr("Lms.Explore.codec"), CodecTag{}); return typeModel; } - using ValueVariant = std::variant; + using ValueVariant = std::variant; using ValueModel = ValueStringModel; std::unique_ptr createValueModel(TypeVariant type) @@ -87,35 +95,37 @@ namespace lms::ui auto transaction{ session.createReadTransaction() }; - if (std::holds_alternative(type)) - { - db::MediaLibrary::find(session, [&](const db::MediaLibrary::pointer& library) { - valueModel->add(Wt::WString::fromUTF8(std::string{ library->getName() }), library->getId()); - }); - } - else if (std::holds_alternative(type)) - { - db::Label::find(session, db::LabelSortMethod::Name, [&](const db::Label::pointer& label) { - valueModel->add(Wt::WString::fromUTF8(std::string{ label->getName() }), label->getId()); - }); - } - else if (std::holds_alternative(type)) - { - db::ReleaseType::find(session, db::ReleaseTypeSortMethod::Name, [&](const db::ReleaseType::pointer& releaseType) { - valueModel->add(Wt::WString::fromUTF8(std::string{ releaseType->getName() }), releaseType->getId()); - }); - } - else if (const db::ClusterTypeId * clusterTypeId{ std::get_if(&type) }) - { - db::Cluster::FindParameters params; - params.setClusterType(*clusterTypeId); - params.setSortMethod(db::ClusterSortMethod::Name); - - db::Cluster::find(session, params, [&](const db::Cluster::pointer& cluster) { - valueModel->add(Wt::WString::fromUTF8(std::string{ cluster->getName() }), cluster->getId()); - }); - } + std::visit(core::utils::overloads{ + [&](const MediaLibraryTag&) { + db::MediaLibrary::find(session, [&](const db::MediaLibrary::pointer& library) { + valueModel->add(Wt::WString::fromUTF8(std::string{ library->getName() }), library->getId()); + }); + }, + [&](const LabelTag&) { + db::Label::find(session, db::LabelSortMethod::Name, [&](const db::Label::pointer& label) { + valueModel->add(Wt::WString::fromUTF8(std::string{ label->getName() }), label->getId()); + }); + }, + [&](const ReleaseTypeTag&) { + db::ReleaseType::find(session, db::ReleaseTypeSortMethod::Name, [&](const db::ReleaseType::pointer& releaseType) { + valueModel->add(Wt::WString::fromUTF8(std::string{ releaseType->getName() }), releaseType->getId()); + }); + }, + [&](const CodecTag&) { + core::media::visitCodecs([&](const core::media::CodecDesc& desc) { + valueModel->add(Wt::WString::fromUTF8(desc.longName.c_str()), desc.type); + }); + }, + [&](db::ClusterTypeId clusterTypeId) { + db::Cluster::FindParameters params; + params.setClusterType(clusterTypeId); + params.setSortMethod(db::ClusterSortMethod::Name); + db::Cluster::find(session, params, [&](const db::Cluster::pointer& cluster) { + valueModel->add(Wt::WString::fromUTF8(std::string{ cluster->getName() }), cluster->getId()); + }); + } }, + type); return valueModel; } } // namespace @@ -138,25 +148,28 @@ namespace lms::ui const auto valueModel{ std::static_pointer_cast(valueCombo->model()) }; const ValueVariant value{ valueModel->getValue(valueCombo->currentIndex()) }; - if (const db::MediaLibraryId * mediaLibraryId{ std::get_if(&value) }) - { - set(*mediaLibraryId); - state::writeValue("filters_media_library_id", mediaLibraryId->getValue()); - } - else if (const db::LabelId * labelId{ std::get_if(&value) }) - { - set(*labelId); - state::writeValue("filters_label_id", labelId->getValue()); - } - else if (const db::ReleaseTypeId * releaseTypeId{ std::get_if(&value) }) - { - set(*releaseTypeId); - state::writeValue("filters_release_type_id", releaseTypeId->getValue()); - } - else if (const db::ClusterId * clusterId{ std::get_if(&value) }) - { - add(*clusterId); - } + std::visit(core::utils::overloads{ + [&](db::MediaLibraryId mediaLibraryId) { + set(mediaLibraryId); + state::writeValue("filters_media_library_id", mediaLibraryId.getValue()); + }, + [&](db::LabelId labelId) { + set(labelId); + state::writeValue("filters_label_id", labelId.getValue()); + }, + [&](db::ReleaseTypeId releaseTypeId) { + set(releaseTypeId); + state::writeValue("filters_release_type_id", releaseTypeId.getValue()); + }, + [&](db::ClusterId clusterId) { + add(clusterId); + }, + [&](core::media::Codec codec) { + set(codec); + state::writeValue("filters_codec", codec); + }, + }, + value); // TODO LmsApp->getModalManager().dispose(dialogPtr); @@ -197,6 +210,8 @@ namespace lms::ui set(db::LabelId{ *labelId }); if (const std::optional releaseTypeId{ state::readValue("filters_release_type_id") }) set(db::ReleaseTypeId{ *releaseTypeId }); + if (const std::optional codec{ state::readValue("filters_codec") }) + set(*codec); } void Filters::add(db::ClusterId clusterId) @@ -324,6 +339,28 @@ namespace lms::ui emitFilterAddedNotification(); } + void Filters::set(core::media::Codec codec) + { + if (_codecFilter) + { + _filters->removeWidget(_codecFilter); + _codecFilter = nullptr; + _dbFilters.codec.reset(); + } + + _dbFilters.codec = codec; + _codecFilter = _filters->addWidget(utils::createFilter(Wt::WString::fromUTF8(core::media::getCodecDesc(codec).longName.c_str()), Wt::WString::tr("Lms.Explore.codec"), "bg-success", true)); + _codecFilter->clicked().connect(_codecFilter, [this] { + _filters->removeWidget(_codecFilter); + _dbFilters.codec.reset(); + _codecFilter = nullptr; + _sigUpdated.emit(); + state::writeValue("filters_codec", std::nullopt); + }); + + emitFilterAddedNotification(); + } + void Filters::emitFilterAddedNotification() { LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Explore.filter-added"), std::chrono::seconds{ 2 }); diff --git a/src/lms/ui/explore/Filters.hpp b/src/lms/ui/explore/Filters.hpp index c63b7398..99cdcf35 100644 --- a/src/lms/ui/explore/Filters.hpp +++ b/src/lms/ui/explore/Filters.hpp @@ -23,6 +23,8 @@ #include #include +#include "core/media/Codec.hpp" + #include "database/objects/Filters.hpp" namespace lms::ui @@ -43,12 +45,14 @@ namespace lms::ui void set(db::LabelId labelId); void set(db::MediaLibraryId mediaLibraryId); void set(db::ReleaseTypeId releaseTypeId); + void set(core::media::Codec codec); void emitFilterAddedNotification(); Wt::WContainerWidget* _filters{}; Wt::WInteractWidget* _mediaLibraryFilter{}; Wt::WInteractWidget* _labelFilter{}; Wt::WInteractWidget* _releaseTypeFilter{}; + Wt::WInteractWidget* _codecFilter{}; Wt::Signal<> _sigUpdated; diff --git a/src/lms/ui/explore/ReleaseView.cpp b/src/lms/ui/explore/ReleaseView.cpp index 63c451d6..f2ea10dc 100644 --- a/src/lms/ui/explore/ReleaseView.cpp +++ b/src/lms/ui/explore/ReleaseView.cpp @@ -19,18 +19,17 @@ #include "ReleaseView.hpp" -#include #include +#include #include +#include #include #include #include #include "core/String.hpp" -#include "audio/AudioTypes.hpp" -#include "audio/IAudioFileInfo.hpp" #include "database/Session.hpp" #include "database/Types.hpp" #include "database/objects/Artist.hpp" @@ -140,20 +139,20 @@ namespace lms::ui releaseInfo->bindString("release-labels", core::stringUtils::joinStrings(labels, " · ")); } - // TODO: save in DB and aggregate all this - for (const db::Track::pointer& track : db::Track::find(LmsApp->getDbSession(), db::Track::FindParameters{}.setRelease(releaseId).setRange(db::Range{ 0, 1 })).results) + // Codecs { - try + std::string codecStr; + for (core::media::Codec codec : release->getCodecs()) { - if (const auto audioFile{ audio::parseAudioFile(track->getAbsoluteFilePath()) }) - { - releaseInfo->setCondition("if-has-codec", true); - releaseInfo->bindString("codec", audio::codecTypeToString(audioFile->getAudioProperties().codec).c_str(), Wt::TextFormat::Plain); - break; - } + if (!codecStr.empty()) + codecStr += " · "; + codecStr += core::media::getCodecDesc(codec).name.str(); } - catch (const audio::Exception& e) + + if (!codecStr.empty()) { + releaseInfo->setCondition("if-has-codec", true); + releaseInfo->bindString("codec", codecStr, Wt::TextFormat::Plain); } } @@ -256,11 +255,6 @@ namespace lms::ui refreshView(); }); - _filters.updated().connect([this] { - _needForceRefresh = true; - refreshView(); - }); - refreshView(); } @@ -272,12 +266,11 @@ namespace lms::ui const auto releaseId{ extractReleaseIdFromInternalPath() }; // consider everything is up to date is the same release is being rendered - if (!_needForceRefresh && releaseId && *releaseId == _releaseId) + if (releaseId && *releaseId == _releaseId) return; clear(); _releaseId = {}; - _needForceRefresh = false; if (!releaseId) throw ReleaseNotFoundException{}; @@ -360,8 +353,12 @@ namespace lms::ui _playQueueController.processCommand(PlayQueueController::Command::PlayOrAddLast, { _releaseId }); }); - bindNew("download", Wt::WString::tr("Lms.Explore.download")) - ->setLink(Wt::WLink{ std::make_unique(_releaseId) }); + if (LmsApp->areDownloadsEnabled()) + { + setCondition("if-has-download", true); + bindNew("download", Wt::WString::tr("Lms.Explore.download")) + ->setLink(Wt::WLink{ std::make_unique(_releaseId) }); + } bindNew("release-info", Wt::WString::tr("Lms.Explore.release-info")) ->clicked() @@ -448,7 +445,6 @@ namespace lms::ui db::Track::FindParameters params; params.setMedium(medium->getId()); params.setSortMethod(db::TrackSortMethod::TrackNumber); - params.setFilters(_filters.getDbFilters()); // TODO: do we really want to hide all tracks when a release does not match the current label filter? db::Track::find(LmsApp->getDbSession(), params, [&](const db::Track::pointer& track) { const db::TrackId trackId{ track->getId() }; @@ -528,8 +524,12 @@ namespace lms::ui starBtn->clicked().connect([=] { toggle(); }); } - entry->bindNew("download", Wt::WString::tr("Lms.Explore.download")) - ->setLink(Wt::WLink{ std::make_unique(trackId) }); + if (LmsApp->areDownloadsEnabled()) + { + entry->setCondition("if-has-download", true); + entry->bindNew("download", Wt::WString::tr("Lms.Explore.download")) + ->setLink(Wt::WLink{ std::make_unique(trackId) }); + } entry->bindNew("track-info", Wt::WString::tr("Lms.Explore.track-info")) ->clicked() diff --git a/src/lms/ui/explore/ReleaseView.hpp b/src/lms/ui/explore/ReleaseView.hpp index 768fdbe6..76de048c 100644 --- a/src/lms/ui/explore/ReleaseView.hpp +++ b/src/lms/ui/explore/ReleaseView.hpp @@ -57,6 +57,5 @@ namespace lms::ui Filters& _filters; PlayQueueController& _playQueueController; db::ReleaseId _releaseId; - bool _needForceRefresh{}; }; } // namespace lms::ui diff --git a/src/lms/ui/explore/TrackListHelpers.cpp b/src/lms/ui/explore/TrackListHelpers.cpp index 8721745b..f52d0dc2 100644 --- a/src/lms/ui/explore/TrackListHelpers.cpp +++ b/src/lms/ui/explore/TrackListHelpers.cpp @@ -25,8 +25,6 @@ #include "core/Service.hpp" -#include "audio/AudioTypes.hpp" -#include "audio/IAudioFileInfo.hpp" #include "database/Session.hpp" #include "database/Types.hpp" #include "database/objects/Artist.hpp" @@ -129,16 +127,10 @@ namespace lms::ui::TrackListHelpers } } - try - { - if (const auto audioFile{ audio::parseAudioFile(track->getAbsoluteFilePath()) }) - { - trackInfo->setCondition("if-has-codec", true); - trackInfo->bindString("codec", audio::codecTypeToString(audioFile->getAudioProperties().codec).c_str(), Wt::TextFormat::Plain); - } - } - catch (const audio::Exception& e) + if (const auto codec{ track->getCodec() }) { + trackInfo->setCondition("if-has-codec", true); + trackInfo->bindString("codec", core::media::getCodecDesc(*codec).name.c_str(), Wt::TextFormat::Plain); } trackInfo->bindString("duration", utils::durationToString(track->getDuration())); @@ -307,8 +299,12 @@ namespace lms::ui::TrackListHelpers starBtn->clicked().connect([=] { toggle(); }); } - entry->bindNew("download", Wt::WString::tr("Lms.Explore.download")) - ->setLink(Wt::WLink{ std::make_unique(trackId) }); + if (LmsApp->areDownloadsEnabled()) + { + entry->setCondition("if-has-download", true); + entry->bindNew("download", Wt::WString::tr("Lms.Explore.download")) + ->setLink(Wt::WLink{ std::make_unique(trackId) }); + } entry->bindNew("track-info", Wt::WString::tr("Lms.Explore.track-info")) ->clicked() diff --git a/src/lms/ui/explore/TrackListHelpers.hpp b/src/lms/ui/explore/TrackListHelpers.hpp index 3dbae791..ef4c0c36 100644 --- a/src/lms/ui/explore/TrackListHelpers.hpp +++ b/src/lms/ui/explore/TrackListHelpers.hpp @@ -27,10 +27,11 @@ #include #include "core/EnumSet.hpp" + #include "database/Object.hpp" -#include "database/Types.hpp" #include "database/objects/ArtistId.hpp" #include "database/objects/TrackId.hpp" +#include "database/objects/Types.hpp" namespace lms::db { diff --git a/src/lms/ui/explore/TrackListView.cpp b/src/lms/ui/explore/TrackListView.cpp index 2bd658d2..6dc4717e 100644 --- a/src/lms/ui/explore/TrackListView.cpp +++ b/src/lms/ui/explore/TrackListView.cpp @@ -135,8 +135,12 @@ namespace lms::ui _playQueueController.processCommand(PlayQueueController::Command::PlayOrAddLast, *trackListId); }); - bindNew("download", Wt::WString::tr("Lms.Explore.download")) - ->setLink(Wt::WLink{ std::make_unique(*trackListId) }); + if (LmsApp->areDownloadsEnabled()) + { + setCondition("if-has-download", true); + bindNew("download", Wt::WString::tr("Lms.Explore.download")) + ->setLink(Wt::WLink{ std::make_unique(*trackListId) }); + } if (trackList->getUserId() == LmsApp->getUserId()) { diff --git a/src/lms/ui/resource/AudioTranscodingResource.cpp b/src/lms/ui/resource/AudioTranscodingResource.cpp index a5977b8c..d30c960b 100644 --- a/src/lms/ui/resource/AudioTranscodingResource.cpp +++ b/src/lms/ui/resource/AudioTranscodingResource.cpp @@ -27,6 +27,11 @@ #include "core/IResourceHandler.hpp" #include "core/Service.hpp" #include "core/String.hpp" + +#include "audio/AudioProperties.hpp" +#include "audio/Exception.hpp" +#include "audio/IAudioFileInfo.hpp" +#include "audio/IAudioFileInfoParser.hpp" #include "database/Session.hpp" #include "database/objects/Track.hpp" #include "database/objects/User.hpp" @@ -51,14 +56,8 @@ namespace lms::core::stringUtils switch (static_cast(*encodedFormat)) { case db::TranscodingOutputFormat::MP3: - [[fallthrough]]; case db::TranscodingOutputFormat::OGG_OPUS: - [[fallthrough]]; - case db::TranscodingOutputFormat::MATROSKA_OPUS: - [[fallthrough]]; case db::TranscodingOutputFormat::OGG_VORBIS: - [[fallthrough]]; - case db::TranscodingOutputFormat::WEBM_VORBIS: return format; } @@ -72,20 +71,64 @@ namespace lms::ui { namespace { - std::optional AudioFormatToTranscodingFormat(db::TranscodingOutputFormat format) + std::optional getAudioProperties(const std::filesystem::path& trackPath) + { + std::optional res; + + 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) }; + + if (const audio::AudioProperties * properties{ audioFile->getAudioProperties() }) + res = *properties; + } + catch (const audio::Exception& e) + { + LMS_LOG(UI, DEBUG, "Cannot parse audio properties in " << trackPath << ": " << e.what()); + } + + return res; + } + + std::optional getAudioProperties(const db::Track::pointer& track) + { + std::optional res; + + if (track->getContainer() && track->getCodec()) + { + res.emplace(); + res->container = *track->getContainer(); + res->codec = *track->getCodec(); + res->duration = track->getDuration(); + res->bitrate = track->getBitrate(); + res->channelCount = track->getChannelCount(); + res->sampleRate = track->getSampleRate(); + res->bitsPerSample = track->getBitsPerSample(); + } + else + { + res = getAudioProperties(track->getAbsoluteFilePath()); + } + + return res; + } + + std::optional audioFormatToTranscodingFormat(db::TranscodingOutputFormat format) { switch (format) { case db::TranscodingOutputFormat::MP3: - return audio::OutputFormat::MP3; + return audio::TranscodeOutputFormat{ core::media::Container::MPEG, core::media::Codec::MP3 }; case db::TranscodingOutputFormat::OGG_OPUS: - return audio::OutputFormat::OGG_OPUS; - case db::TranscodingOutputFormat::MATROSKA_OPUS: - return audio::OutputFormat::MATROSKA_OPUS; + return audio::TranscodeOutputFormat{ core::media::Container::Ogg, core::media::Codec::Opus }; case db::TranscodingOutputFormat::OGG_VORBIS: - return audio::OutputFormat::OGG_VORBIS; - case db::TranscodingOutputFormat::WEBM_VORBIS: - return audio::OutputFormat::WEBM_VORBIS; + return audio::TranscodeOutputFormat{ core::media::Container::Ogg, core::media::Codec::Vorbis }; } TRANSCODE_LOG(ERROR, "Cannot convert from db audio format to transcoding format"); @@ -128,8 +171,8 @@ namespace lms::ui return std::nullopt; } - const std::optional avFormat{ AudioFormatToTranscodingFormat(*format) }; - if (!avFormat) + const std::optional outputFormat{ audioFormatToTranscodingFormat(*format) }; + if (!outputFormat) return std::nullopt; // optional parameter @@ -145,12 +188,16 @@ namespace lms::ui return std::nullopt; parameters.inputParameters.filePath = track->getAbsoluteFilePath(); - parameters.inputParameters.duration = track->getDuration(); + const auto audioProperties{ getAudioProperties(track) }; + if (!audioProperties) + return std::nullopt; + + parameters.inputParameters.audioProperties = *audioProperties; } parameters.inputParameters.offset = std::chrono::seconds{ offset }; parameters.outputParameters.stripMetadata = true; - parameters.outputParameters.format = *avFormat; + parameters.outputParameters.format = *outputFormat; parameters.outputParameters.bitrate = *bitrate; return parameters; diff --git a/src/lms/ui/resource/DownloadResource.cpp b/src/lms/ui/resource/DownloadResource.cpp index 9997d9e4..8762fe76 100644 --- a/src/lms/ui/resource/DownloadResource.cpp +++ b/src/lms/ui/resource/DownloadResource.cpp @@ -115,7 +115,7 @@ namespace lms::ui } } // namespace - namespace details + namespace detail { std::string getTrackPathName(const db::Track::pointer& track) { @@ -165,7 +165,7 @@ namespace lms::ui return zip::createArchiveZipper(files); } - } // namespace details + } // namespace detail DownloadArtistResource::DownloadArtistResource(db::ArtistId artistId) : _artistId{ artistId } @@ -182,7 +182,7 @@ namespace lms::ui auto transaction{ LmsApp->getDbSession().createReadTransaction() }; const auto trackResults{ db::Track::find(LmsApp->getDbSession(), db::Track::FindParameters{}.setArtist(_artistId).setSortMethod(db::TrackSortMethod::DateDescAndRelease)) }; - return details::createZipper(trackResults.results); + return detail::createZipper(trackResults.results); } DownloadReleaseResource::DownloadReleaseResource(db::ReleaseId releaseId) @@ -200,7 +200,7 @@ namespace lms::ui auto transaction{ LmsApp->getDbSession().createReadTransaction() }; auto tracks{ db::Track::find(LmsApp->getDbSession(), db::Track::FindParameters{}.setRelease(_releaseId).setSortMethod(db::TrackSortMethod::Release)) }; - return details::createZipper(tracks.results); + return detail::createZipper(tracks.results); } DownloadTrackResource::DownloadTrackResource(db::TrackId trackId) @@ -210,7 +210,7 @@ namespace lms::ui db::Track::pointer track{ db::Track::find(LmsApp->getDbSession(), trackId) }; if (track) - suggestFileName(details::getTrackPathName(track) + ".zip"); + suggestFileName(detail::getTrackPathName(track) + ".zip"); } std::unique_ptr DownloadTrackResource::createZipper() @@ -224,7 +224,7 @@ namespace lms::ui return {}; } - return details::createZipper({ track }); + return detail::createZipper({ track }); } DownloadTrackListResource::DownloadTrackListResource(db::TrackListId trackListId) @@ -234,7 +234,7 @@ namespace lms::ui const db::TrackList::pointer trackList{ db::TrackList::find(LmsApp->getDbSession(), trackListId) }; if (trackList) - suggestFileName(details::getTrackListPathName(trackList) + ".zip"); + suggestFileName(detail::getTrackListPathName(trackList) + ".zip"); } std::unique_ptr DownloadTrackListResource::createZipper() @@ -244,6 +244,6 @@ namespace lms::ui db::Track::FindParameters params; params.setTrackList(_trackListId); const auto tracks{ db::Track::find(LmsApp->getDbSession(), params) }; - return details::createZipper(tracks.results); + return detail::createZipper(tracks.results); } } // namespace lms::ui diff --git a/src/tools/audioinfo/LmsAudioInfo.cpp b/src/tools/audioinfo/LmsAudioInfo.cpp index 2a05ad15..f4c6a967 100644 --- a/src/tools/audioinfo/LmsAudioInfo.cpp +++ b/src/tools/audioinfo/LmsAudioInfo.cpp @@ -27,10 +27,12 @@ #include "core/ILogger.hpp" #include "core/String.hpp" +#include "core/media/MimeType.hpp" -#include "audio/AudioTypes.hpp" +#include "audio/AudioProperties.hpp" #include "audio/Exception.hpp" #include "audio/IAudioFileInfo.hpp" +#include "audio/IAudioFileInfoParser.hpp" #include "audio/IImageReader.hpp" #include "audio/ITagReader.hpp" @@ -38,14 +40,15 @@ namespace lms::audio { std::ostream& operator<<(std::ostream& os, const AudioProperties& audioProperties) { - os << "\tDuration: " << std::fixed << std::setprecision(2) << std::chrono::duration_cast>(audioProperties.duration) << std::endl; - os << "\tContainer: " << containerTypeToString(audioProperties.container) << std::endl; - os << "\tCodec: " << codecTypeToString(audioProperties.codec) << std::endl; - os << "\tBitrate: " << audioProperties.bitrate << " bps" << std::endl; + os << "\tDuration: " << std::fixed << std::setprecision(2) << std::chrono::duration_cast>(audioProperties.duration) << '\n'; + os << "\tContainer: " << core::media::containerToString(audioProperties.container) << '\n'; + os << "\tCodec: " << core::media::getCodecDesc(audioProperties.codec).name << '\n'; + os << "\tMimeType: " << core::media::getMimeType(audioProperties.container, audioProperties.codec) << '\n'; + os << "\tBitrate: " << audioProperties.bitrate << " bps" << '\n'; if (audioProperties.bitsPerSample) - os << "\tBitsPerSample: " << *audioProperties.bitsPerSample << std::endl; - os << "\tChannelCount: " << audioProperties.channelCount << std::endl; - os << "\tSampleRate: " << audioProperties.sampleRate << std::endl; + os << "\tBitsPerSample: " << *audioProperties.bitsPerSample << '\n'; + os << "\tChannelCount: " << audioProperties.channelCount << '\n'; + os << "\tSampleRate: " << audioProperties.sampleRate << '\n'; return os; } @@ -114,7 +117,7 @@ namespace lms::audio std::ostream& operator<<(std::ostream& os, const Image& image) { - os << "\ttype = " << imageTypeToString(image.type) << std::endl; + os << "\ttype = " << core::media::imageTypeToString(image.type) << std::endl; if (!image.description.empty()) os << "\tdesc: " << image.description << std::endl; os << "\tmimeType: " << image.mimeType << std::endl; @@ -133,11 +136,21 @@ namespace lms::audio void displayInfo(const IAudioFileInfo& fileInfo) { - std::cout << "Audio properties:\n" - << fileInfo.getAudioProperties() << std::endl; + if (fileInfo.getAudioProperties()) + { + std::cout << "Audio properties:\n" + << *fileInfo.getAudioProperties() << "\n"; + } + else + { + std::cout << "Failed to parse audio properties!\n\n"; + } - displayImages(fileInfo.getImageReader()); - displayTags(fileInfo.getTagReader()); + if (fileInfo.getImageReader()) + displayImages(*fileInfo.getImageReader()); + + if (fileInfo.getTagReader()) + displayTags(*fileInfo.getTagReader()); std::cout << "\n"; } @@ -200,15 +213,16 @@ int main(int argc, char* argv[]) return EXIT_FAILURE; } - audio::ParserOptions parserOptions; - parserOptions.enableExtraDebugLogs = true; + audio::AudioFileInfoParserBackend parserBackend{ audio::defaultAudioFileInfoParserBackend }; if (core::stringUtils::stringCaseInsensitiveEqual(vm["parser"].as(), "taglib")) - parserOptions.parser = audio::ParserOptions::Parser::TagLib; + parserBackend = audio::AudioFileInfoParserBackend::TagLib; else if (core::stringUtils::stringCaseInsensitiveEqual(vm["parser"].as(), "ffmpeg")) - parserOptions.parser = audio::ParserOptions::Parser::FFmpeg; + parserBackend = audio::AudioFileInfoParserBackend::FFmpeg; else throw program_options::validation_error{ program_options::validation_error::invalid_option_value, "parser" }; + const auto parser{ audio::createAudioFileInfoParser(parserBackend) }; + const auto& inputFiles{ vm["file"].as>() }; // log to stdout core::Service logger{ core::logging::createLogger(core::logging::Severity::DEBUG) }; @@ -219,8 +233,11 @@ int main(int argc, char* argv[]) try { + audio::AudioFileInfoParseOptions parseOptions; + parseOptions.enableExtraDebugLogs = true; + std::cout << "Parsing file " << file << ":\n"; - const auto audioFileInfo{ audio::parseAudioFile(file, parserOptions) }; + const auto audioFileInfo{ parser->parse(file, parseOptions) }; displayInfo(*audioFileInfo); }