Allow adding cover via JavaScript

This commit is contained in:
Martchus 2023-08-07 18:41:19 +02:00
parent 7063f1bf03
commit c4f7d195a0
6 changed files with 168 additions and 34 deletions

View File

@ -54,6 +54,17 @@ const std::vector<std::string_view> &id3v2CoverTypeNames();
CoverType id3v2CoverType(std::string_view coverName);
std::string_view id3v2CoverName(CoverType coverType);
template <class TagType>
bool fieldPredicate(CoverType coverType, std::optional<std::string_view> description,
const std::pair<typename TagType::IdentifierType, typename TagType::FieldType> &pair)
{
const auto &[fieldId, field] = pair;
const auto typeMatches
= field.isTypeInfoAssigned() ? (field.typeInfo() == static_cast<typename TagType::FieldType::TypeInfoType>(coverType)) : (coverType == 0);
const auto descMatches = !description.has_value() || field.value().description() == description.value();
return typeMatches && descMatches;
}
class FieldId {
friend struct std::hash<FieldId>;

View File

@ -448,17 +448,6 @@ struct Id3v2Cover {
std::optional<std::string_view> description;
};
template <class TagType>
bool fieldPredicate(CoverType coverType, std::optional<std::string_view> description,
const std::pair<typename TagType::IdentifierType, typename TagType::FieldType> &pair)
{
const auto &[fieldId, field] = pair;
const auto typeMatches
= field.isTypeInfoAssigned() ? (field.typeInfo() == static_cast<typename TagType::FieldType::TypeInfoType>(coverType)) : (coverType == 0);
const auto descMatches = !description.has_value() || field.value().description() == description.value();
return typeMatches && descMatches;
}
template <class TagType> static void setId3v2CoverValues(TagType *tag, std::vector<Id3v2Cover> &&values)
{
auto &fields = tag->fields();

View File

@ -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 <tagparser/abstracttrack.h>
#include <tagparser/exceptions.h>
#include <tagparser/id3/id3v2tag.h>
#include <tagparser/mediafileinfo.h>
#include <tagparser/progressfeedback.h>
#include <tagparser/signature.h>
#include <tagparser/tag.h>
#include <tagparser/tagvalue.h>
#include <tagparser/vorbis/vorbiscomment.h>
#include <qtutilities/misc/conversion.h>
@ -19,9 +23,12 @@
#include <qtutilities/misc/compat.h>
#include <QBuffer>
#include <QByteArray>
#include <QCoreApplication>
#include <QDir>
#include <QHash>
#include <QImage>
#include <QJSEngine>
#include <QJSValueIterator>
#include <QRegularExpression>
@ -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<const char *>(str.utf16()), static_cast<std::size_t>(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<std::size_t>(bytes.size()));
res.assignData(bytes.data(), static_cast<std::size_t>(bytes.size()), TagParser::TagDataType::Binary);
res.setMimeType(TagParser::containerMimeType(container));
} else {
const auto str = m_content.toString();
res.assignText(reinterpret_cast<const char *>(str.utf16()), static_cast<std::size_t>(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 <class TagType> static void setId3v2CoverValues(TagType *tag, std::vector<TagParser::TagValue> &&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<std::string_view>(); // 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<TagType>, 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<TagType>, 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<typename FieldType::TypeInfoType>(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<TagParser::Id3v2Tag *>(&m_tag), std::move(values));
continue;
case TagParser::TagType::VorbisComment:
setId3v2CoverValues(static_cast<TagParser::VorbisComment *>(&m_tag), std::move(values));
continue;
default:;
}
}
m_tag.setValues(field, values);
}
}

View File

@ -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);

View File

@ -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;
}

View File

@ -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);
}