Extend script API; allow copying tags from other files

This commit is contained in:
Martchus 2023-12-30 03:06:58 +01:00
parent e1e979f9f5
commit 88989ff986
6 changed files with 134 additions and 15 deletions

View File

@ -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 - 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. 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 - 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 - 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 `--script-settings dryRun=1` and check for that setting within the script as shown in the
mentioned example script. 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 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 is interpreted relative to current directory of the file (and *not* to the current working
directory of the tag editor). 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 ##### Further useful commands
* Let the tag editor return with a non-zero exit code even if only non-fatal problems have been encountered * Let the tag editor return with a non-zero exit code even if only non-fatal problems have been encountered

View File

@ -59,6 +59,8 @@ constexpr auto nativeUtf16Encoding = TagParser::TagTextEncoding::
#endif #endif
; ;
const std::string UtilityObject::s_defaultContext = std::string("executing JavaScript");
UtilityObject::UtilityObject(QJSEngine *engine) UtilityObject::UtilityObject(QJSEngine *engine)
: QObject(engine) : QObject(engine)
, m_engine(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("information"), TagParser::DiagLevel::Information },
{ QStringLiteral("debug"), TagParser::DiagLevel::Debug }, { 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(), 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() int UtilityObject::exec()
@ -127,6 +128,25 @@ QJSValue UtilityObject::readFile(const QString &path)
return QJSValue(); return QJSValue();
} }
QJSValue UtilityObject::openFile(const QString &path)
{
if (!m_diag) {
return QJSValue();
}
auto mediaFileInfo = std::make_unique<TagParser::MediaFileInfo>(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 QString UtilityObject::formatName(const QString &str) const
{ {
return Utility::formatName(str); return Utility::formatName(str);
@ -571,6 +591,13 @@ MediaFileInfoObject::MediaFileInfoObject(
{ {
} }
MediaFileInfoObject::MediaFileInfoObject(
std::unique_ptr<TagParser::MediaFileInfo> &&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() MediaFileInfoObject::~MediaFileInfoObject()
{ {
} }
@ -580,11 +607,21 @@ QString MediaFileInfoObject::path() const
return QString::fromStdString(m_f.path()); return QString::fromStdString(m_f.path());
} }
bool MediaFileInfoObject::isPathRelative() const
{
return QFileInfo(path()).isRelative();
}
QString MediaFileInfoObject::name() const QString MediaFileInfoObject::name() const
{ {
return QString::fromStdString(m_f.fileName()); return QString::fromStdString(m_f.fileName());
} }
QString MediaFileInfoObject::nameWithoutExtension() const
{
return QString::fromStdString(m_f.fileName(true));
}
QString MediaFileInfoObject::extension() const QString MediaFileInfoObject::extension() const
{ {
return QString::fromStdString(m_f.extension()); return QString::fromStdString(m_f.extension());

View File

@ -54,6 +54,7 @@ public Q_SLOTS:
QJSValue readEnvironmentVariable(const QString &variable, const QJSValue &defaultValue = QJSValue()) const; QJSValue readEnvironmentVariable(const QString &variable, const QJSValue &defaultValue = QJSValue()) const;
QJSValue readDirectory(const QString &path); QJSValue readDirectory(const QString &path);
QJSValue readFile(const QString &path); QJSValue readFile(const QString &path);
QJSValue openFile(const QString &path);
QString formatName(const QString &str) const; QString formatName(const QString &str) const;
QString fixUmlauts(const QString &str) const; QString fixUmlauts(const QString &str) const;
@ -63,13 +64,14 @@ public Q_SLOTS:
QJSValue queryMakeItPersonal(const QJSValue &songDescription); QJSValue queryMakeItPersonal(const QJSValue &songDescription);
QJSValue queryTekstowo(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: private:
static QtGui::SongDescription makeSongDescription(const QJSValue &obj); static QtGui::SongDescription makeSongDescription(const QJSValue &obj);
QJSEngine *m_engine; QJSEngine *m_engine;
const std::string *m_context; const std::string *m_context;
static const std::string s_defaultContext;
TagParser::Diagnostics *m_diag; TagParser::Diagnostics *m_diag;
}; };
@ -186,7 +188,9 @@ inline TagParser::Tag &TagObject::tag()
class MediaFileInfoObject : public QObject { class MediaFileInfoObject : public QObject {
Q_OBJECT Q_OBJECT
Q_PROPERTY(QString path READ path) Q_PROPERTY(QString path READ path)
Q_PROPERTY(bool pathRelative READ isPathRelative)
Q_PROPERTY(QString name READ name) Q_PROPERTY(QString name READ name)
Q_PROPERTY(QString nameWithoutExtension READ nameWithoutExtension)
Q_PROPERTY(QString extension READ extension) Q_PROPERTY(QString extension READ extension)
Q_PROPERTY(QString containingDirectory READ containingDirectory) Q_PROPERTY(QString containingDirectory READ containingDirectory)
Q_PROPERTY(QString savePath READ savePath WRITE setSavePath) Q_PROPERTY(QString savePath READ savePath WRITE setSavePath)
@ -195,11 +199,15 @@ class MediaFileInfoObject : public QObject {
public: public:
explicit MediaFileInfoObject( explicit MediaFileInfoObject(
TagParser::MediaFileInfo &mediaFileInfo, TagParser::Diagnostics &diag, QJSEngine *engine, bool quiet, QObject *parent = nullptr); TagParser::MediaFileInfo &mediaFileInfo, TagParser::Diagnostics &diag, QJSEngine *engine, bool quiet, QObject *parent = nullptr);
explicit MediaFileInfoObject(std::unique_ptr<TagParser::MediaFileInfo> &&mediaFileInfo, TagParser::Diagnostics &diag, QJSEngine *engine,
bool quiet, QObject *parent = nullptr);
~MediaFileInfoObject() override; ~MediaFileInfoObject() override;
TagParser::MediaFileInfo &fileInfo(); TagParser::MediaFileInfo &fileInfo();
QString path() const; QString path() const;
bool isPathRelative() const;
QString name() const; QString name() const;
QString nameWithoutExtension() const;
QString extension() const; QString extension() const;
QString containingDirectory() const; QString containingDirectory() const;
QString savePath() const; QString savePath() const;
@ -212,6 +220,7 @@ public Q_SLOTS:
private: private:
TagParser::MediaFileInfo &m_f; TagParser::MediaFileInfo &m_f;
std::unique_ptr<TagParser::MediaFileInfo> m_f_owned;
TagParser::Diagnostics &m_diag; TagParser::Diagnostics &m_diag;
QJSEngine *m_engine; QJSEngine *m_engine;
QList<TagObject *> m_tags; QList<TagObject *> m_tags;

View File

@ -1,3 +1,5 @@
const fileCache = {};
export function isString(value) { export function isString(value) {
return typeof(value) === "string" || value instanceof String; 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"); utility.diag("debug", Object.keys(fields).sort().join(", "), "supported fields");
// log fields for debugging purposes // log fields for debugging purposes
for (const [key, value] of Object.entries(fields)) { for (const [key, values] of Object.entries(fields)) {
const content = value.content; for (const value of values) {
if (content !== undefined && content != null && !(content instanceof ArrayBuffer)) { const content = value.content;
utility.diag("debug", content, key + " (" + value.type + ")"); 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));
}
}

View File

@ -1,26 +1,23 @@
import * as helpers from "helpers.js"
const lyricsCache = {}; const lyricsCache = {};
const coverCache = {}; const coverCache = {};
const albumColumn = 1; const albumColumn = 1;
export function queryLyrics(searchCriteria) { 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 + "' ..."); utility.log(" - Querying lyrics for '" + searchCriteria.title + "' from '" + searchCriteria.artist + "' ...");
return queryLyricsFromProviders(["Tekstowo", "MakeItPersonal"], searchCriteria) return queryLyricsFromProviders(["Tekstowo", "MakeItPersonal"], searchCriteria)
}); });
} }
export function queryCover(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 + "' ..."); utility.log(" - Querying cover art for '" + searchCriteria.album + "' from '" + searchCriteria.artist + "' ...");
return queryCoverFromProvider("MusicBrainz", searchCriteria); return queryCoverFromProvider("MusicBrainz", searchCriteria);
}); });
} }
function cacheValue(cache, key, generator) {
const cachedValue = cache[key];
return cachedValue ? cachedValue : (cache[key] = generator());
}
function waitFor(signal) { function waitFor(signal) {
signal.connect(() => { utility.exit(); }); signal.connect(() => { utility.exit(); });
utility.exec(); utility.exec();

View File

@ -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) { function addLyrics(file, tag) {
const fields = tag.fields; const fields = tag.fields;
if (fields.lyrics.length) { if (fields.lyrics.length) {
return; // skip if already assigned 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 firstTitle = fields.title?.[0]?.content;
const firstArtist = fields.artist?.[0]?.content; const firstArtist = fields.artist?.[0]?.content;
if (firstTitle && firstArtist) { if (firstTitle && firstArtist) {
@ -74,6 +92,17 @@ function addCover(file, tag) {
if (fields.cover.length) { if (fields.cover.length) {
return; // skip if already assigned 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 firstAlbum = fields.album?.[0]?.content?.replace(/ \(.*\)/, '');
const firstArtist = fields.artist?.[0]?.content; const firstArtist = fields.artist?.[0]?.content;
if (firstAlbum && firstArtist) { if (firstAlbum && firstArtist) {