Store audio properties in database also for podcasts
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user