From 88989ff986c21fff5a8f29a1d7aad110bcac4ab9 Mon Sep 17 00:00:00 2001 From: Martchus Date: Sat, 30 Dec 2023 03:06:58 +0100 Subject: [PATCH] Extend script API; allow copying tags from other files --- README.md | 8 +++++- cli/scriptapi.cpp | 41 +++++++++++++++++++++++++++++-- cli/scriptapi.h | 11 ++++++++- testfiles/helpers.js | 49 ++++++++++++++++++++++++++++++++++--- testfiles/metadatasearch.js | 11 +++------ testfiles/set-tags.js | 29 ++++++++++++++++++++++ 6 files changed, 134 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 7c68b65..9313df3 100644 --- a/README.md +++ b/README.md @@ -372,7 +372,8 @@ Here are some Bash examples which illustrate getting and setting tag information - The script needs to export a `main()` function. This function is invoked for every file and passed an object representing the current file as first argument. - Checkout the file `testfiles/set-tags.js` in this repository for an example that applies basic - fixes and tries to fetch lyrics and cover art. + fixes and tries to fetch lyrics and cover art when according settings are passed (e.g. + `--script-settings addCover=1 addLyrics=1`). - For debugging, the option `--pedantic debug` is very useful. You may also add `--script-settings dryRun=1` and check for that setting within the script as shown in the mentioned example script. @@ -400,6 +401,11 @@ Here are some Bash examples which illustrate getting and setting tag information file without modifying it by returning a falsy value). If the specified path is relative, it is interpreted relative to current directory of the file (and *not* to the current working directory of the tag editor). + - It is also possible to open another file via `utility.openFile(path)`. This makes it possible + to copy tags over from another file, e.g. to insert tags back from original files that have + been lost when converting to a different format. The mentioned example script `set-tags.js` + also demonstrates this for covers and lyrics when according script settings are passed (e.g. + `--script-settings addCover=1 originalDir=… originalExt=…`). ##### Further useful commands * Let the tag editor return with a non-zero exit code even if only non-fatal problems have been encountered diff --git a/cli/scriptapi.cpp b/cli/scriptapi.cpp index 9316143..1cdcc39 100644 --- a/cli/scriptapi.cpp +++ b/cli/scriptapi.cpp @@ -59,6 +59,8 @@ constexpr auto nativeUtf16Encoding = TagParser::TagTextEncoding:: #endif ; +const std::string UtilityObject::s_defaultContext = std::string("executing JavaScript"); + UtilityObject::UtilityObject(QJSEngine *engine) : QObject(engine) , m_engine(engine) @@ -86,9 +88,8 @@ void UtilityObject::diag(const QString &level, const QString &message, const QSt { QStringLiteral("information"), TagParser::DiagLevel::Information }, { QStringLiteral("debug"), TagParser::DiagLevel::Debug }, }); - static const auto defaultContext = std::string("executing JavaScript"); m_diag->emplace_back(mapping.value(level.toLower(), TagParser::DiagLevel::Debug), message.toStdString(), - context.isEmpty() ? (m_context ? *m_context : defaultContext) : context.toStdString()); + context.isEmpty() ? (m_context ? *m_context : s_defaultContext) : context.toStdString()); } int UtilityObject::exec() @@ -127,6 +128,25 @@ QJSValue UtilityObject::readFile(const QString &path) return QJSValue(); } +QJSValue UtilityObject::openFile(const QString &path) +{ + if (!m_diag) { + return QJSValue(); + } + auto mediaFileInfo = std::make_unique(path.toStdString()); + auto feedback = TagParser::AbortableProgressFeedback(); + try { + mediaFileInfo->open(true); + mediaFileInfo->parseEverything(*m_diag, feedback); + } catch (const std::exception &e) { + m_diag->emplace_back(TagParser::DiagLevel::Critical, CppUtilities::argsToString("Unable to open \"", mediaFileInfo->path(), "\": ", e.what()), + m_context ? *m_context : s_defaultContext); + return QJSValue(); + } + auto mediaFileInfoObj = new MediaFileInfoObject(std::move(mediaFileInfo), *m_diag, m_engine, false, m_engine); + return m_engine->newQObject(mediaFileInfoObj); +} + QString UtilityObject::formatName(const QString &str) const { return Utility::formatName(str); @@ -571,6 +591,13 @@ MediaFileInfoObject::MediaFileInfoObject( { } +MediaFileInfoObject::MediaFileInfoObject( + std::unique_ptr &&mediaFileInfo, TagParser::Diagnostics &diag, QJSEngine *engine, bool quiet, QObject *parent) + : MediaFileInfoObject(*mediaFileInfo.get(), diag, engine, quiet, parent) +{ + m_f_owned = std::move(mediaFileInfo); +} + MediaFileInfoObject::~MediaFileInfoObject() { } @@ -580,11 +607,21 @@ QString MediaFileInfoObject::path() const return QString::fromStdString(m_f.path()); } +bool MediaFileInfoObject::isPathRelative() const +{ + return QFileInfo(path()).isRelative(); +} + QString MediaFileInfoObject::name() const { return QString::fromStdString(m_f.fileName()); } +QString MediaFileInfoObject::nameWithoutExtension() const +{ + return QString::fromStdString(m_f.fileName(true)); +} + QString MediaFileInfoObject::extension() const { return QString::fromStdString(m_f.extension()); diff --git a/cli/scriptapi.h b/cli/scriptapi.h index 9492f75..eb9000d 100644 --- a/cli/scriptapi.h +++ b/cli/scriptapi.h @@ -54,6 +54,7 @@ public Q_SLOTS: QJSValue readEnvironmentVariable(const QString &variable, const QJSValue &defaultValue = QJSValue()) const; QJSValue readDirectory(const QString &path); QJSValue readFile(const QString &path); + QJSValue openFile(const QString &path); QString formatName(const QString &str) const; QString fixUmlauts(const QString &str) const; @@ -63,13 +64,14 @@ public Q_SLOTS: QJSValue queryMakeItPersonal(const QJSValue &songDescription); QJSValue queryTekstowo(const QJSValue &songDescription); - QByteArray convertImage(const QByteArray &imageData, const QSize &maxSize, const QString &format); + QByteArray convertImage(const QByteArray &imageData, const QSize &maxSize, const QString &format = QString()); private: static QtGui::SongDescription makeSongDescription(const QJSValue &obj); QJSEngine *m_engine; const std::string *m_context; + static const std::string s_defaultContext; TagParser::Diagnostics *m_diag; }; @@ -186,7 +188,9 @@ inline TagParser::Tag &TagObject::tag() class MediaFileInfoObject : public QObject { Q_OBJECT Q_PROPERTY(QString path READ path) + Q_PROPERTY(bool pathRelative READ isPathRelative) Q_PROPERTY(QString name READ name) + Q_PROPERTY(QString nameWithoutExtension READ nameWithoutExtension) Q_PROPERTY(QString extension READ extension) Q_PROPERTY(QString containingDirectory READ containingDirectory) Q_PROPERTY(QString savePath READ savePath WRITE setSavePath) @@ -195,11 +199,15 @@ class MediaFileInfoObject : public QObject { public: explicit MediaFileInfoObject( TagParser::MediaFileInfo &mediaFileInfo, TagParser::Diagnostics &diag, QJSEngine *engine, bool quiet, QObject *parent = nullptr); + explicit MediaFileInfoObject(std::unique_ptr &&mediaFileInfo, TagParser::Diagnostics &diag, QJSEngine *engine, + bool quiet, QObject *parent = nullptr); ~MediaFileInfoObject() override; TagParser::MediaFileInfo &fileInfo(); QString path() const; + bool isPathRelative() const; QString name() const; + QString nameWithoutExtension() const; QString extension() const; QString containingDirectory() const; QString savePath() const; @@ -212,6 +220,7 @@ public Q_SLOTS: private: TagParser::MediaFileInfo &m_f; + std::unique_ptr m_f_owned; TagParser::Diagnostics &m_diag; QJSEngine *m_engine; QList m_tags; diff --git a/testfiles/helpers.js b/testfiles/helpers.js index 999f659..3d87ece 100644 --- a/testfiles/helpers.js +++ b/testfiles/helpers.js @@ -1,3 +1,5 @@ +const fileCache = {}; + export function isString(value) { return typeof(value) === "string" || value instanceof String; } @@ -13,10 +15,49 @@ export function logTagInfo(file, tag) { utility.diag("debug", Object.keys(fields).sort().join(", "), "supported fields"); // log fields for debugging purposes - for (const [key, value] of Object.entries(fields)) { - const content = value.content; - if (content !== undefined && content != null && !(content instanceof ArrayBuffer)) { - utility.diag("debug", content, key + " (" + value.type + ")"); + for (const [key, values] of Object.entries(fields)) { + for (const value of values) { + const content = value.content; + if (content !== undefined && content !== null && !(content instanceof ArrayBuffer)) { + utility.diag("debug", content, key + " (" + value.type + ")"); + } } } } + +export function readFieldContents(file, fieldName) { + const values = []; + for (const tag of file.tags) { + if (tag.type === "ID3v1 tag") { + // due to its limitations ID3v1 tags may have truncated contents; so just ignore them + // for the sake of this script + continue; + } + for (const value of tag.fields[fieldName]) { + const content = value.content; + if (content !== undefined && content !== null) { + values.push(content); + } + } + if (values.length) { + // just return the contents from the first tag that has any for now + break; + } + } + return values; +} + +export function cacheValue(cache, key, generator) { + const cachedValue = cache[key]; + return cachedValue ? cachedValue : (cache[key] = generator()); +} + +export function openOriginalFile(file) { + const originalDir = settings.originalDir; + const originalExt = settings.originalExt || file.extension; + if (originalDir && file.pathRelative) { + const name = file.nameWithoutExtension; + const path = [originalDir, file.containingDirectory, name + originalExt].join("/"); + return cacheValue(fileCache, path, () => utility.openFile(path)); + } +} diff --git a/testfiles/metadatasearch.js b/testfiles/metadatasearch.js index 395fd25..214b25f 100644 --- a/testfiles/metadatasearch.js +++ b/testfiles/metadatasearch.js @@ -1,26 +1,23 @@ +import * as helpers from "helpers.js" + const lyricsCache = {}; const coverCache = {}; const albumColumn = 1; export function queryLyrics(searchCriteria) { - return cacheValue(lyricsCache, searchCriteria.title + "_" + searchCriteria.artist, () => { + return helpers.cacheValue(lyricsCache, searchCriteria.title + "_" + searchCriteria.artist, () => { utility.log(" - Querying lyrics for '" + searchCriteria.title + "' from '" + searchCriteria.artist + "' ..."); return queryLyricsFromProviders(["Tekstowo", "MakeItPersonal"], searchCriteria) }); } export function queryCover(searchCriteria) { - return cacheValue(coverCache, searchCriteria.album + "_" + searchCriteria.artist, () => { + return helpers.cacheValue(coverCache, searchCriteria.album + "_" + searchCriteria.artist, () => { utility.log(" - Querying cover art for '" + searchCriteria.album + "' from '" + 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(); diff --git a/testfiles/set-tags.js b/testfiles/set-tags.js index 25f87e0..d256da4 100644 --- a/testfiles/set-tags.js +++ b/testfiles/set-tags.js @@ -57,11 +57,29 @@ function addTotalNumberOfTracks(file, tag) { } } +function addFieldFromOriginalFile(file, tag, fieldName) { + const originalFile = helpers.openOriginalFile(file); + if (!originalFile) { + return false; + } + utility.diag("debug", "Trying to take over " + fieldName + " from \"" + originalFile.path + "\"."); + const contents = helpers.readFieldContents(originalFile, fieldName); + if (!contents.length) { + utility.diag("debug", "No " + fieldName + " found in original file."); + return false; + } + tag.fields[fieldName] = contents; + return true; +} + function addLyrics(file, tag) { const fields = tag.fields; if (fields.lyrics.length) { return; // skip if already assigned } + if (addFieldFromOriginalFile(file, tag, "lyrics")) { + return; // skip fetching via meta-data search if lyrics could be taken over from original file + } const firstTitle = fields.title?.[0]?.content; const firstArtist = fields.artist?.[0]?.content; if (firstTitle && firstArtist) { @@ -74,6 +92,17 @@ function addCover(file, tag) { if (fields.cover.length) { return; // skip if already assigned } + if (addFieldFromOriginalFile(file, tag, "cover")) { + // ensure the cover's resolution is below a certain size to avoid bloating the file + const convertedCovers = []; + const maxSizeInt = parseInt(settings.coverMaxSize || 512); + const maxSize = Qt.size(maxSizeInt, maxSizeInt); + for (const cover of fields.cover) { + convertedCovers.push(utility.convertImage(cover, maxSize)); + } + fields.cover = convertedCovers; + return; // skip fetching via meta-data search if cover could be taken over from original file + } const firstAlbum = fields.album?.[0]?.content?.replace(/ \(.*\)/, ''); const firstArtist = fields.artist?.[0]?.content; if (firstAlbum && firstArtist) {