From c4f7d195a0db072a84d735d59f7fdc1b67477d15 Mon Sep 17 00:00:00 2001 From: Martchus Date: Mon, 7 Aug 2023 18:41:19 +0200 Subject: [PATCH] Allow adding cover via JavaScript --- cli/helper.h | 11 +++++ cli/mainfeatures.cpp | 11 ----- cli/mediafileinfoobject.cpp | 90 ++++++++++++++++++++++++++++++++++--- cli/mediafileinfoobject.h | 2 + testfiles/metadatasearch.js | 75 +++++++++++++++++++++++-------- testfiles/set-tags.js | 13 ++++++ 6 files changed, 168 insertions(+), 34 deletions(-) diff --git a/cli/helper.h b/cli/helper.h index f391b1d..d26b271 100644 --- a/cli/helper.h +++ b/cli/helper.h @@ -54,6 +54,17 @@ const std::vector &id3v2CoverTypeNames(); CoverType id3v2CoverType(std::string_view coverName); std::string_view id3v2CoverName(CoverType coverType); +template +bool fieldPredicate(CoverType coverType, std::optional description, + const std::pair &pair) +{ + const auto &[fieldId, field] = pair; + const auto typeMatches + = field.isTypeInfoAssigned() ? (field.typeInfo() == static_cast(coverType)) : (coverType == 0); + const auto descMatches = !description.has_value() || field.value().description() == description.value(); + return typeMatches && descMatches; +} + class FieldId { friend struct std::hash; diff --git a/cli/mainfeatures.cpp b/cli/mainfeatures.cpp index dec75f3..1c3ea31 100644 --- a/cli/mainfeatures.cpp +++ b/cli/mainfeatures.cpp @@ -448,17 +448,6 @@ struct Id3v2Cover { std::optional description; }; -template -bool fieldPredicate(CoverType coverType, std::optional description, - const std::pair &pair) -{ - const auto &[fieldId, field] = pair; - const auto typeMatches - = field.isTypeInfoAssigned() ? (field.typeInfo() == static_cast(coverType)) : (coverType == 0); - const auto descMatches = !description.has_value() || field.value().description() == description.value(); - return typeMatches && descMatches; -} - template static void setId3v2CoverValues(TagType *tag, std::vector &&values) { auto &fields = tag->fields(); diff --git a/cli/mediafileinfoobject.cpp b/cli/mediafileinfoobject.cpp index 5322f7a..8c9b437 100644 --- a/cli/mediafileinfoobject.cpp +++ b/cli/mediafileinfoobject.cpp @@ -1,5 +1,6 @@ #include "./mediafileinfoobject.h" #include "./fieldmapping.h" +#include "./helper.h" #include "../application/knownfieldmodel.h" #include "../dbquery/dbquery.h" @@ -7,10 +8,13 @@ #include #include +#include #include #include +#include #include #include +#include #include @@ -19,9 +23,12 @@ #include +#include +#include #include #include #include +#include #include #include #include @@ -147,6 +154,24 @@ QJSValue UtilityObject::queryTekstowo(const QJSValue &songDescription) return m_engine->newQObject(QtGui::queryTekstowo(makeSongDescription(songDescription))); } +QByteArray UtilityObject::convertImage(const QByteArray &imageData, const QSize &maxSize, const QString &format) +{ + auto image = QImage::fromData(imageData); + if (image.isNull()) { + return imageData; + } + if (!maxSize.isNull() && (image.width() > maxSize.width() || image.height() > maxSize.height())) { + image = image.scaled(maxSize.width(), maxSize.height(), Qt::KeepAspectRatio, Qt::SmoothTransformation); + if (image.isNull()) { + return imageData; + } + } + auto newData = QByteArray(); + auto buffer = QBuffer(&newData); + auto res = buffer.open(QIODevice::WriteOnly) && image.save(&buffer, format.isEmpty() ? "JPEG" : format.toUtf8().data()); + return res ? newData : imageData; +} + static QString propertyString(const QJSValue &obj, const QString &propertyName) { const auto val = obj.property(propertyName); @@ -293,12 +318,21 @@ bool TagValueObject::isInitial() const TagParser::TagValue TagValueObject::toTagValue(TagParser::TagTextEncoding encoding) const { + auto res = TagParser::TagValue(); if (m_content.isUndefined() || m_content.isNull()) { - return TagParser::TagValue(); + return res; } - const auto str = m_content.toString(); - return TagParser::TagValue(reinterpret_cast(str.utf16()), static_cast(str.size()) * (sizeof(ushort) / sizeof(char)), - nativeUtf16Encoding, encoding); + if (const auto variant = m_content.toVariant(); variant.userType() == QMetaType::QByteArray) { + const auto bytes = variant.toByteArray(); + const auto container = TagParser::parseSignature(bytes.data(), static_cast(bytes.size())); + res.assignData(bytes.data(), static_cast(bytes.size()), TagParser::TagDataType::Binary); + res.setMimeType(TagParser::containerMimeType(container)); + } else { + const auto str = m_content.toString(); + res.assignText(reinterpret_cast(str.utf16()), static_cast(str.size()) * (sizeof(ushort) / sizeof(char)), + nativeUtf16Encoding, encoding); + } + return res; } void TagValueObject::flagChange() @@ -415,6 +449,40 @@ QJSValue &TagObject::fields() return m_fields; } +/// \brief Sets the first of the specified \a values as front-cover with no description replacing any existing cover values. +template static void setId3v2CoverValues(TagType *tag, std::vector &&values) +{ + auto &fields = tag->fields(); + const auto id = tag->fieldId(TagParser::KnownField::Cover); + const auto range = fields.equal_range(id); + const auto first = range.first; + + constexpr auto coverType = CoverType(3); // assume front cover + constexpr auto description = std::optional(); // assume no description + + for (auto &tagValue : values) { + // check whether there is already a tag value with the current type and description + auto pair = std::find_if(first, range.second, std::bind(&fieldPredicate, coverType, description, std::placeholders::_1)); + if (pair != range.second) { + // there is already a tag value with the current type and description + // -> update this value + pair->second.setValue(tagValue); + // check whether there are more values with the current type and description + while ((pair = std::find_if(++pair, range.second, std::bind(&fieldPredicate, coverType, description, std::placeholders::_1))) + != range.second) { + // -> remove these values as we only support one value of a type/description in the same tag + pair->second.setValue(TagParser::TagValue()); + } + } else if (!tagValue.isEmpty()) { + using FieldType = typename TagType::FieldType; + auto newField = FieldType(id, tagValue); + newField.setTypeInfo(static_cast(coverType)); + fields.insert(std::pair(id, std::move(newField))); + } + break; // allow only setting one value for now + } +} + void TagObject::applyChanges() { auto context = !m_tag.target().isEmpty() || m_tag.type() == TagParser::TagType::MatroskaTag @@ -460,7 +528,7 @@ void TagObject::applyChanges() } continue; } - const auto &value = values.emplace_back(tagValueObj->toTagValue(encoding)); + auto &value = values.emplace_back(tagValueObj->toTagValue(encoding)); m_diag.emplace_back(TagParser::DiagLevel::Debug, value.isNull() ? CppUtilities::argsToString(" - delete ", propertyName.toStdString(), '[', i, ']') @@ -474,6 +542,18 @@ void TagObject::applyChanges() printJsValue(tagValueObj->initialContent()), "' to '", printJsValue(tagValueObj->content()), '\''))), std::string()); } + // assign cover values of ID3v2/VorbisComment tags as front-cover with no description + if (field == TagParser::KnownField::Cover && !values.empty()) { + switch (m_tag.type()) { + case TagParser::TagType::Id3v2Tag: + setId3v2CoverValues(static_cast(&m_tag), std::move(values)); + continue; + case TagParser::TagType::VorbisComment: + setId3v2CoverValues(static_cast(&m_tag), std::move(values)); + continue; + default:; + } + } m_tag.setValues(field, values); } } diff --git a/cli/mediafileinfoobject.h b/cli/mediafileinfoobject.h index 9d590ea..75ae2af 100644 --- a/cli/mediafileinfoobject.h +++ b/cli/mediafileinfoobject.h @@ -63,6 +63,8 @@ public Q_SLOTS: QJSValue queryMakeItPersonal(const QJSValue &songDescription); QJSValue queryTekstowo(const QJSValue &songDescription); + QByteArray convertImage(const QByteArray &imageData, const QSize &maxSize, const QString &format); + private: static QtGui::SongDescription makeSongDescription(const QJSValue &obj); diff --git a/testfiles/metadatasearch.js b/testfiles/metadatasearch.js index 1b922e2..8d3308e 100644 --- a/testfiles/metadatasearch.js +++ b/testfiles/metadatasearch.js @@ -1,40 +1,79 @@ -const cache = {}; +const lyricsCache = {}; +const coverCache = {}; +const albumColumn = 1; + +export function queryLyrics(searchCriteria) { + return cacheValue(lyricsCache, searchCriteria.title + "_" + searchCriteria.artist, () => { + return queryLyricsFromProviders(["Tekstowo", "MakeItPersonal"], searchCriteria) + }); +} + +export function queryCover(searchCriteria) { + return cacheValue(coverCache, searchCriteria.album + "_" + searchCriteria.artist, () => { + return queryCoverFromProvider("MusicBrainz", searchCriteria); + }); +} + +function cacheValue(cache, key, generator) { + const cachedValue = cache[key]; + return cachedValue ? cachedValue : cache[key] = generator(); +} function waitFor(signal) { signal.connect(() => { utility.exit(); }); utility.exec(); } -function queryProvider(provider, searchCriteria) { - const lyricsModel = utility["query" + provider](searchCriteria); - if (!lyricsModel.areResultsAvailable) { - waitFor(lyricsModel.resultsAvailable); +function queryLyricsFromProvider(provider, searchCriteria) { + const model = utility["query" + provider](searchCriteria); + if (!model.areResultsAvailable) { + waitFor(model.resultsAvailable); } - if (!lyricsModel.fetchLyrics(lyricsModel.index(0, 0))) { - waitFor(lyricsModel.lyricsAvailable); + if (!model.fetchLyrics(model.index(0, 0))) { + waitFor(model.lyricsAvailable); } - const lyrics = lyricsModel.lyricsValue(lyricsModel.index(0, 0)); + const lyrics = model.lyricsValue(model.index(0, 0)); if (lyrics && lyrics.startsWith("Bots have beat this API")) { return undefined; } return lyrics; } -function queryProviders(providers, searchCriteria) { +function queryLyricsFromProviders(providers, searchCriteria) { for (const provider of providers) { - const res = queryProvider(provider, searchCriteria); + const res = queryLyricsFromProvider(provider, searchCriteria); if (res) { return res; } } } -export function queryLyrics(searchCriteria) { - const cacheKey = searchCriteria.title + "_" + searchCriteria.artist; - const cachedValue = cache[cacheKey]; - return cachedValue - ? cachedValue - : cache[cacheKey] = queryProviders(["Tekstowo", "MakeItPersonal"], searchCriteria); +function queryCoverFromProvider(provider, searchCriteria) { + const context = searchCriteria.album + " from " + searchCriteria.artist; + const model = utility["query" + provider](searchCriteria); + if (!model.areResultsAvailable) { + waitFor(model.resultsAvailable); + } + const albumUpper = searchCriteria.album.toUpperCase(); + utility.diag("debug", model.rowCount(), "rows"); + let row = 0, rowCount = model.rowCount(); + for (; row != rowCount; ++row) { + const album = model.data(model.index(row, albumColumn)); + if (album && album.toUpperCase() === albumUpper) { + break; + } + } + if (row === rowCount) { + utility.diag("debug", "unable to find meta-data on " + provider, context); + return undefined; + } + if (!model.fetchCover(model.index(row, 0))) { + waitFor(model.coverAvailable); + } + let cover = model.coverValue(model.index(row, 0)); + if (cover instanceof ArrayBuffer) { + utility.diag("debug", "found cover", context); + cover = utility.convertImage(cover, Qt.size(512, 512), "JPEG"); + } + return cover; } - - diff --git a/testfiles/set-tags.js b/testfiles/set-tags.js index ebc0eb1..bdc62da 100644 --- a/testfiles/set-tags.js +++ b/testfiles/set-tags.js @@ -87,6 +87,18 @@ function addLyrics(file, tag) { } } +function addCover(file, tag) { + const fields = tag.fields; + if (fields.cover.length) { + return; // skip if already assigned + } + const firstAlbum = fields.album?.[0]?.content?.replace(/ \(.*\)/, ''); + const firstArtist = fields.artist?.[0]?.content; + if (firstAlbum && firstArtist) { + fields.cover = metadatasearch.queryCover({album: firstAlbum, artist: firstArtist}); + } +} + function addMiscFields(file, tag) { // assume the number of disks is always one for now const fields = tag.fields; @@ -102,4 +114,5 @@ function changeTagFields(file, tag) { addTotalNumberOfTracks(file, tag); addMiscFields(file, tag); addLyrics(file, tag); + addCover(file, tag); }