From c3cd30f2481b9d830d026ce67a963c69697a05fc Mon Sep 17 00:00:00 2001 From: emeric Date: Sat, 29 Nov 2025 15:37:04 +0100 Subject: [PATCH] Store audio properties in database also for podcasts --- src/libs/database/impl/Migration.cpp | 15 +- .../database/objects/PodcastEpisode.hpp | 38 ++++- src/libs/database/test/Migration.cpp | 1 + src/libs/services/podcast/CMakeLists.txt | 1 + .../services/podcast/impl/PodcastService.cpp | 1 + .../impl/steps/CheckForMissingFilesStep.cpp | 14 +- .../steps/DownloadEpisodeArtworksStep.cpp | 2 +- .../impl/steps/DownloadEpisodesStep.cpp | 144 ++++++++++++++++-- .../impl/steps/DownloadEpisodesStep.hpp | 8 + .../steps/DownloadPodcastArtworksStep.cpp | 2 +- 10 files changed, 202 insertions(+), 24 deletions(-) diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index f8eca7ac..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{ 101 }; + static constexpr Version LMS_DATABASE_VERSION{ 102 }; } VersionInfo::VersionInfo() @@ -1669,6 +1670,17 @@ FROM track)"); 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)" }; @@ -1746,6 +1758,7 @@ FROM track)"); { 98, migrateFromV98 }, { 99, migrateFromV99 }, { 100, migrateFromV100 }, + { 101, migrateFromV101 }, }; bool migrationPerformed{}; diff --git a/src/libs/database/include/database/objects/PodcastEpisode.hpp b/src/libs/database/include/database/objects/PodcastEpisode.hpp index 8db9c541..0f1dc25c 100644 --- a/src/libs/database/include/database/objects/PodcastEpisode.hpp +++ b/src/libs/database/include/database/objects/PodcastEpisode.hpp @@ -91,6 +91,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; } + ContainerType getContainer() const { return _container; } + CodecType getCodec() const { return _codec; } + 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; } @@ -104,7 +113,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; @@ -114,6 +122,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(ContainerType container) { _container = container; } + void setCodec(CodecType codec) { _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; } @@ -136,6 +153,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"); @@ -149,7 +174,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); @@ -163,6 +187,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 }; + ContainerType _container{ ContainerType::Unknown }; + CodecType _codec{ CodecType::Unknown }; + int _bitrate{}; // in bps + int _channelCount{}; + int _sampleRate{}; + std::optional _bitsPerSample; + std::string _url; std::string _title; std::string _link; @@ -179,7 +212,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/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/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..25b2ad7f 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/AudioTypes.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,93 @@ namespace lms::podcast { namespace { - void updateEpisode(db::Session& session, db::PodcastEpisodeId episodeId, const std::filesystem::path& relativeFilePath) + db::ContainerType audioContainerToDbContainer(audio::ContainerType type) + { + switch (type) + { + case audio::ContainerType::AIFF: + return db::ContainerType::AIFF; + case audio::ContainerType::APE: + return db::ContainerType::APE; + case audio::ContainerType::ASF: + return db::ContainerType::ASF; + case audio::ContainerType::DSF: + return db::ContainerType::DSF; + case audio::ContainerType::FLAC: + return db::ContainerType::FLAC; + case audio::ContainerType::MP4: + return db::ContainerType::MP4; + case audio::ContainerType::MPC: + return db::ContainerType::MPC; + case audio::ContainerType::MPEG: + return db::ContainerType::MPEG; + case audio::ContainerType::Shorten: + return db::ContainerType::Shorten; + case audio::ContainerType::Ogg: + return db::ContainerType::Ogg; + case audio::ContainerType::TrueAudio: + return db::ContainerType::TrueAudio; + case audio::ContainerType::WAV: + return db::ContainerType::WAV; + case audio::ContainerType::WavPack: + return db::ContainerType::WavPack; + } + + return db::ContainerType::Unknown; + } + + db::CodecType audioCodecToDbCodec(audio::CodecType type) + { + switch (type) + { + case audio::CodecType::AAC: + return db::CodecType::AAC; + case audio::CodecType::AC3: + return db::CodecType::AC3; + case audio::CodecType::ALAC: + return db::CodecType::ALAC; + case audio::CodecType::APE: + return db::CodecType::APE; + case audio::CodecType::DSD: + return db::CodecType::DSD; + case audio::CodecType::EAC3: + return db::CodecType::EAC3; + case audio::CodecType::FLAC: + return db::CodecType::FLAC; + case audio::CodecType::MP3: + return db::CodecType::MP3; + case audio::CodecType::MP4ALS: + return db::CodecType::MP4ALS; + case audio::CodecType::MPC7: + return db::CodecType::MPC7; + case audio::CodecType::MPC8: + return db::CodecType::MPC8; + case audio::CodecType::Opus: + return db::CodecType::Opus; + case audio::CodecType::PCM: + return db::CodecType::PCM; + case audio::CodecType::Shorten: + return db::CodecType::Shorten; + case audio::CodecType::TrueAudio: + return db::CodecType::TrueAudio; + case audio::CodecType::Vorbis: + return db::CodecType::Vorbis; + case audio::CodecType::WavPack: + return db::CodecType::WavPack; + case audio::CodecType::WMA1: + return db::CodecType::WMA1; + case audio::CodecType::WMA2: + return db::CodecType::WMA2; + case audio::CodecType::WMA9Pro: + return db::CodecType::WMA9Pro; + case audio::CodecType::WMA9Lossless: + return db::CodecType::WMA9Lossless; + } + + return db::CodecType::Unknown; + } + + void updateEpisode(db::Session& session, db::PodcastEpisodeId episodeId, const std::filesystem::path& relativeFilePath, const audio::AudioProperties& audioProperties) { auto transaction{ session.createWriteTransaction() }; @@ -47,6 +137,14 @@ namespace lms::podcast return; // may have been deleted by admin dbEpisode.modify()->setAudioRelativeFilePath(relativeFilePath); + + dbEpisode.modify()->setDuration(audioProperties.duration); + dbEpisode.modify()->setContainer(audioContainerToDbContainer(audioProperties.container)); + dbEpisode.modify()->setCodec(audioCodecToDbCodec(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 +152,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 +271,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();