Store audio properties in database also for podcasts

This commit is contained in:
emeric
2025-11-29 15:37:04 +01:00
parent c70935a5a2
commit c3cd30f248
10 changed files with 202 additions and 24 deletions
+14 -1
View File
@@ -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{};
@@ -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<std::size_t> 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<int, std::milli> getDuration() const { return _duration; }
ObjectPtr<Podcast> getPodcast() const { return _podcast; }
PodcastId getPodcastId() const { return _podcast.id(); }
ObjectPtr<Artwork> 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<std::size_t> 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<int, std::milli> _duration{ 0 };
ContainerType _container{ ContainerType::Unknown };
CodecType _codec{ CodecType::Unknown };
int _bitrate{}; // in bps
int _channelCount{};
int _sampleRate{};
std::optional<int> _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<int, std::milli> _duration{ 0 };
Wt::Dbo::ptr<Artwork> _artwork;
Wt::Dbo::ptr<Podcast> _podcast;
+1
View File
@@ -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"
+1
View File
@@ -24,6 +24,7 @@ target_include_directories(lmspodcast PRIVATE
target_link_libraries(lmspodcast PRIVATE
lmscore
lmsaudio
lmsimage
pugixml::pugixml
)
@@ -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"
@@ -51,13 +51,7 @@ namespace lms::podcast
assert(std::holds_alternative<db::ImageId>(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());
}
}
});
}
@@ -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();
@@ -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<core::IConfig>::get()->getBool("podcast-auto-download-episodes", true) }
, _autoDownloadEpisodesMaxAge{ core::Service<core::IConfig>::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();
});
};
@@ -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<audio::IAudioFileInfoParser> _audioFileInfoParser;
std::deque<db::PodcastEpisodeId> _episodesToDownload;
};
@@ -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();