From 4d6de254e679a21271a4dd732364894163418282 Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 09:25:17 +0200 Subject: [PATCH 01/11] context: Add new container method This allows to fetch from providers like genius that spread lyrics across multiple containers. --- context/lyrics_providers.xml | 10 +++ context/ultimatelyrics.cpp | 13 ++- context/ultimatelyricsprovider.cpp | 140 ++++++++++++++++++++++++++--- context/ultimatelyricsprovider.h | 15 +++- 4 files changed, 165 insertions(+), 13 deletions(-) diff --git a/context/lyrics_providers.xml b/context/lyrics_providers.xml index a82171936..05d220d1d 100644 --- a/context/lyrics_providers.xml +++ b/context/lyrics_providers.xml @@ -128,10 +128,20 @@ + + + + + + diff --git a/context/ultimatelyrics.cpp b/context/ultimatelyrics.cpp index 8c98514de..02cf7e748 100644 --- a/context/ultimatelyrics.cpp +++ b/context/ultimatelyrics.cpp @@ -61,10 +61,19 @@ static UltimateLyricsProvider::Rule parseRule(QXmlStreamReader* reader) if (QLatin1String("item") == reader->name()) { QXmlStreamAttributes attr = reader->attributes(); if (attr.hasAttribute("tag")) { - ret << UltimateLyricsProvider::RuleItem(attr.value("tag").toString(), QString()); + ret << UltimateLyricsProvider::RuleItem(UltimateLyricsProvider::RuleItem::XmlTag, attr.value("tag").toString()); } else if (attr.hasAttribute("begin")) { - ret << UltimateLyricsProvider::RuleItem(attr.value("begin").toString(), attr.value("end").toString()); + // An item with no 'end' used to be indistinguishable from a 'tag' item, and was + // handled as one - keep that behaviour for hand-written provider files. + ret << (attr.hasAttribute("end") + ? UltimateLyricsProvider::RuleItem(UltimateLyricsProvider::RuleItem::Range, + attr.value("begin").toString(), attr.value("end").toString()) + : UltimateLyricsProvider::RuleItem(UltimateLyricsProvider::RuleItem::XmlTag, + attr.value("begin").toString())); + } + else if (attr.hasAttribute("container")) { + ret << UltimateLyricsProvider::RuleItem(UltimateLyricsProvider::RuleItem::Container, attr.value("container").toString()); } } reader->skipCurrentElement(); diff --git a/context/ultimatelyricsprovider.cpp b/context/ultimatelyricsprovider.cpp index 91b832ea9..6675d719b 100644 --- a/context/ultimatelyricsprovider.cpp +++ b/context/ultimatelyricsprovider.cpp @@ -138,6 +138,116 @@ static QString extractXmlTag(const QString& source, const QString& tag) return extract(source, tag, "", true); } +// Matches the opening tag of an element carrying the named attribute. The attribute must be a +// whitespace-separated name followed by '=', so that a name is not matched by a longer one that +// merely starts with it - a word boundary would not do, as '-' ends a word. +static QRegularExpression containerRegex(const QString& attribute) +{ + return QRegularExpression("<(\\w+)\\b[^>]*\\s" + QRegularExpression::escape(attribute) + "\\s*=[^>]*>", + QRegularExpression::CaseInsensitiveOption); +} + +// Given the opening tag matched by containerRegex(), walks the same tag in and out until the +// element's own closing tag is reached, so that nested markup does not terminate it early. +// Sets contentEnd to the start of that closing tag, and elementEnd to just past it. +static bool findContainerEnd(const QString& source, const QRegularExpressionMatch& open, int& contentEnd, int& elementEnd) +{ + QRegularExpression nestRegex("<(/?)" + open.captured(1) + "\\b[^>]*>", QRegularExpression::CaseInsensitiveOption); + int scan = open.capturedEnd(); + int depth = 1; + + while (depth > 0) { + auto nest = nestRegex.match(source, scan); + if (!nest.hasMatch()) { + DBUG << "Failed to find end of container"; + return false; + } + if (nest.captured(1).isEmpty()) { + ++depth; + } + else if (0 == --depth) { + contentEnd = nest.capturedStart(); + elementEnd = nest.capturedEnd(); + } + scan = nest.capturedEnd(); + } + return true; +} + +// Extract the contents of every element carrying the named attribute. Used for sites (e.g. +// Genius) that split the lyrics over several containers whose class names are build-specific, +// and whose contents hold nested markup - so neither a literal tag match nor a naive begin/end +// pair works. +static QString extractContainers(const QString& source, const QString& attribute) +{ + DBUG << "Looking for containers with" << attribute; + QRegularExpression openRegex = containerRegex(attribute); + QStringList parts; + int pos = 0; + + while (pos < source.length()) { + auto open = openRegex.match(source, pos); + if (!open.hasMatch()) { + break; + } + + int contentEnd = -1; + int elementEnd = -1; + if (!findContainerEnd(source, open, contentEnd, elementEnd)) { + break; + } + + // Empty containers are used as placeholders around ad slots - skip them, so that + // joining does not introduce stray breaks between the sections that do have lyrics. + QString content = source.mid(open.capturedEnd(), contentEnd - open.capturedEnd()); + if (!content.trimmed().isEmpty()) { + parts << content; + } + pos = elementEnd; + } + + if (parts.isEmpty()) { + DBUG << "Failed to find container"; + return QString(); + } + + DBUG << "Found" << parts.length() << "container(s)"; + return parts.join(QLatin1String("
")); +} + +// Remove every element carrying the named attribute, contents included. Genius marks the +// page furniture it embeds within the lyrics containers (contributor count, song title, +// summary) with such an attribute, and it has to go along with the markup it sits in. +static QString excludeContainers(const QString& source, const QString& attribute) +{ + DBUG << "Looking for containers with" << attribute; + QRegularExpression openRegex = containerRegex(attribute); + QString ret = source; + int removed = 0; + int pos = 0; + + while (pos < ret.length()) { + auto open = openRegex.match(ret, pos); + if (!open.hasMatch()) { + break; + } + + int contentEnd = -1; + int elementEnd = -1; + if (!findContainerEnd(ret, open, contentEnd, elementEnd)) { + break; + } + + // Any nested match is removed along with this one, so resume from where it started. + ret.remove(open.capturedStart(), elementEnd - open.capturedStart()); + pos = open.capturedStart(); + ++removed; + } + + DBUG << "Removed" << removed << "container(s)"; + return ret; +} + static QString exclude(const QString& source, const QString& begin, const QString& end) { int beginIdx = source.indexOf(begin, 0, Qt::CaseInsensitive); @@ -166,11 +276,16 @@ static QString excludeXmlTag(const QString& source, const QString& tag) static void applyExtractRule(const UltimateLyricsProvider::Rule& rule, QString& content, const Song& song) { for (const UltimateLyricsProvider::RuleItem& item : rule) { - if (item.second.isNull()) { - content = extractXmlTag(content, doTagReplace(item.first, song)); - } - else { - content = extract(content, doTagReplace(item.first, song), doTagReplace(item.second, song)); + switch (item.type) { + case UltimateLyricsProvider::RuleItem::XmlTag: + content = extractXmlTag(content, doTagReplace(item.begin, song)); + break; + case UltimateLyricsProvider::RuleItem::Range: + content = extract(content, doTagReplace(item.begin, song), doTagReplace(item.end, song)); + break; + case UltimateLyricsProvider::RuleItem::Container: + content = extractContainers(content, doTagReplace(item.begin, song)); + break; } } } @@ -178,11 +293,16 @@ static void applyExtractRule(const UltimateLyricsProvider::Rule& rule, QString& static void applyExcludeRule(const UltimateLyricsProvider::Rule& rule, QString& content, const Song& song) { for (const UltimateLyricsProvider::RuleItem& item : rule) { - if (item.second.isNull()) { - content = excludeXmlTag(content, doTagReplace(item.first, song)); - } - else { - content = exclude(content, doTagReplace(item.first, song), doTagReplace(item.second, song)); + switch (item.type) { + case UltimateLyricsProvider::RuleItem::XmlTag: + content = excludeXmlTag(content, doTagReplace(item.begin, song)); + break; + case UltimateLyricsProvider::RuleItem::Range: + content = exclude(content, doTagReplace(item.begin, song), doTagReplace(item.end, song)); + break; + case UltimateLyricsProvider::RuleItem::Container: + content = excludeContainers(content, doTagReplace(item.begin, song)); + break; } } } diff --git a/context/ultimatelyricsprovider.h b/context/ultimatelyricsprovider.h index 80378dbb9..1b99ff59a 100644 --- a/context/ultimatelyricsprovider.h +++ b/context/ultimatelyricsprovider.h @@ -42,7 +42,20 @@ class UltimateLyricsProvider : public QObject { UltimateLyricsProvider(); ~UltimateLyricsProvider() override; - typedef QPair RuleItem; + struct RuleItem { + enum Type { + XmlTag, // + Range, // + Container // + }; + + RuleItem(Type t, const QString& b, const QString& e = QString()) + : type(t), begin(b), end(e) {} + + Type type; + QString begin; + QString end; + }; typedef QList Rule; typedef QPair UrlFormat; From 9bbdb12786530d0c2c9c3a35189c6ab8efbe042b Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 09:25:17 +0200 Subject: [PATCH 02/11] context: clean up some providers that are offline --- context/lyrics_providers.xml | 133 ----------------------------------- 1 file changed, 133 deletions(-) diff --git a/context/lyrics_providers.xml b/context/lyrics_providers.xml index 05d220d1d..7f313379e 100644 --- a/context/lyrics_providers.xml +++ b/context/lyrics_providers.xml @@ -23,42 +23,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -77,27 +41,6 @@
- - - - - - - - - - - - @@ -154,16 +97,6 @@ - - - - - - - - - - @@ -193,14 +126,6 @@ --> - - - - - - - - @@ -222,19 +147,6 @@ - - - - - - - - - - - - - @@ -243,44 +155,6 @@ - - - - - - - - - - - - - - - - - - - - @@ -328,13 +202,6 @@ - - - - - - - From 96a8b36962a1dc4dd103ff9fde518c082a267366 Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 09:25:17 +0200 Subject: [PATCH 03/11] gui: drop defunct lyric providers from defaults --- gui/settings.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/gui/settings.cpp b/gui/settings.cpp index 76ba25eee..cb076acc6 100644 --- a/gui/settings.cpp +++ b/gui/settings.cpp @@ -340,9 +340,7 @@ MPDParseUtils::CueSupport Settings::cueSupport() QStringList Settings::lyricProviders() { - return cfg.get("lyricProviders", QStringList() << "azlyrics.com" - << "chartlyrics.com" - << "lyrics.wikia.com"); + return cfg.get("lyricProviders", QStringList() << "azlyrics.com"); } QStringList Settings::wikipediaLangs() From d7cbf98c51505a464e16ea86fa4086e4c18db72e Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 09:25:17 +0200 Subject: [PATCH 04/11] context: drop defunct lyrics.wikia.com --- context/lyrics_providers.xml | 13 ------------- context/ultimatelyricsprovider.cpp | 16 ---------------- 2 files changed, 29 deletions(-) diff --git a/context/lyrics_providers.xml b/context/lyrics_providers.xml index 7f313379e..450df427e 100644 --- a/context/lyrics_providers.xml +++ b/context/lyrics_providers.xml @@ -113,19 +113,6 @@ - - - diff --git a/context/ultimatelyricsprovider.cpp b/context/ultimatelyricsprovider.cpp index 6675d719b..e4e852366 100644 --- a/context/ultimatelyricsprovider.cpp +++ b/context/ultimatelyricsprovider.cpp @@ -355,22 +355,6 @@ void UltimateLyricsProvider::fetchInfo(int id, Song metadata, bool removeThe) artistFixed = artistFixed.mid(constThe.length()); } - if (QLatin1String("lyrics.wikia.com") == name) { - QUrl url(urlText); - QUrlQuery query; - - query.addQueryItem(QLatin1String("artist"), artistFixed); - query.addQueryItem(QLatin1String("song"), titleFixed); - query.addQueryItem(QLatin1String("func"), QLatin1String("getSong")); - query.addQueryItem(QLatin1String("fmt"), QLatin1String("xml")); - url.setQuery(query); - - NetworkJob* reply = NetworkAccessManager::self()->get(url); - requests[reply] = id; - connect(reply, SIGNAL(finished()), this, SLOT(wikiMediaSearchResponse())); - return; - } - metadata.priority = removeThe ? 1 : 0;// HACK Use this to indicate if searching without 'The ' songs.insert(id, metadata); From 76ec285d843a443bb897dde3ce799afcec9bbef1 Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 09:25:17 +0200 Subject: [PATCH 05/11] context: fix fetching from lyricsmania.com --- context/lyrics_providers.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/context/lyrics_providers.xml b/context/lyrics_providers.xml index 450df427e..f3add053a 100644 --- a/context/lyrics_providers.xml +++ b/context/lyrics_providers.xml @@ -113,8 +113,11 @@ - + + + + From 8081cdba08ff85d2255fb97a4b289d7545b4c6a3 Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 09:25:17 +0200 Subject: [PATCH 06/11] context: fix fetching from lyricsmode. --- context/lyrics_providers.xml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/context/lyrics_providers.xml b/context/lyrics_providers.xml index f3add053a..9adfe6b31 100644 --- a/context/lyrics_providers.xml +++ b/context/lyrics_providers.xml @@ -127,8 +127,16 @@ - + + + + + + + + From 4f641d96b9e58ec0066abf34da0709d39031d252 Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 09:25:17 +0200 Subject: [PATCH 07/11] context: add exclude rule for lyricsmode to drop extra text --- context/lyrics_providers.xml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/context/lyrics_providers.xml b/context/lyrics_providers.xml index 9adfe6b31..d230064e8 100644 --- a/context/lyrics_providers.xml +++ b/context/lyrics_providers.xml @@ -129,8 +129,9 @@ - + @@ -143,6 +144,11 @@ + + + + From 510757a9a309028c4cb031ac38056efc44b4ff6d Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 09:25:17 +0200 Subject: [PATCH 08/11] context: Add a mode to extract lyrics from json. Fixes musixmatch.com --- context/lyrics_providers.xml | 6 ++++ context/ultimatelyrics.cpp | 3 ++ context/ultimatelyricsprovider.cpp | 48 ++++++++++++++++++++++++++++++ context/ultimatelyricsprovider.h | 7 +++-- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/context/lyrics_providers.xml b/context/lyrics_providers.xml index d230064e8..b5c5f9a2e 100644 --- a/context/lyrics_providers.xml +++ b/context/lyrics_providers.xml @@ -162,6 +162,12 @@ + + + + + diff --git a/context/ultimatelyrics.cpp b/context/ultimatelyrics.cpp index 02cf7e748..6c35cd272 100644 --- a/context/ultimatelyrics.cpp +++ b/context/ultimatelyrics.cpp @@ -75,6 +75,9 @@ static UltimateLyricsProvider::Rule parseRule(QXmlStreamReader* reader) else if (attr.hasAttribute("container")) { ret << UltimateLyricsProvider::RuleItem(UltimateLyricsProvider::RuleItem::Container, attr.value("container").toString()); } + else if (attr.hasAttribute("unescape")) { + ret << UltimateLyricsProvider::RuleItem(UltimateLyricsProvider::RuleItem::Unescape, attr.value("unescape").toString()); + } } reader->skipCurrentElement(); } diff --git a/context/ultimatelyricsprovider.cpp b/context/ultimatelyricsprovider.cpp index e4e852366..7d853c0f9 100644 --- a/context/ultimatelyricsprovider.cpp +++ b/context/ultimatelyricsprovider.cpp @@ -138,6 +138,46 @@ static QString extractXmlTag(const QString& source, const QString& tag) return extract(source, tag, "", true); } +// Decode a JSON string literal. Sites that render client-side (e.g. Musixmatch) embed the +// lyrics in a JSON blob rather than in markup, so what the extract rules pull out still has +// its escapes intact. +static QString jsonUnescape(const QString& source) +{ + QString ret; + ret.reserve(source.length()); + + for (int i = 0; i < source.length(); ++i) { + if (QLatin1Char('\\') != source.at(i) || i + 1 >= source.length()) { + ret += source.at(i); + continue; + } + + QChar esc = source.at(++i); + switch (esc.toLatin1()) { + case 'n': ret += QLatin1Char('\n'); break; + case 't': ret += QLatin1Char('\t'); break; + case 'r': ret += QLatin1Char('\r'); break; + case 'b': ret += QLatin1Char('\b'); break; + case 'f': ret += QLatin1Char('\f'); break; + case 'u': { + bool ok = false; + ushort code = i + 4 < source.length() ? source.mid(i + 1, 4).toUShort(&ok, 16) : 0; + if (ok) { + ret += QChar(code); + i += 4; + } + else { + ret += esc; + } + break; + } + // Covers \" \\ \/ - and leaves anything unrecognised as-is. + default: ret += esc; break; + } + } + return ret; +} + // Matches the opening tag of an element carrying the named attribute. The attribute must be a // whitespace-separated name followed by '=', so that a name is not matched by a longer one that // merely starts with it - a word boundary would not do, as '-' ends a word. @@ -286,6 +326,11 @@ static void applyExtractRule(const UltimateLyricsProvider::Rule& rule, QString& case UltimateLyricsProvider::RuleItem::Container: content = extractContainers(content, doTagReplace(item.begin, song)); break; + case UltimateLyricsProvider::RuleItem::Unescape: + if (QLatin1String("json") == item.begin) { + content = jsonUnescape(content); + } + break; } } } @@ -303,6 +348,9 @@ static void applyExcludeRule(const UltimateLyricsProvider::Rule& rule, QString& case UltimateLyricsProvider::RuleItem::Container: content = excludeContainers(content, doTagReplace(item.begin, song)); break; + case UltimateLyricsProvider::RuleItem::Unescape: + // Not meaningful as an exclusion - ignored. + break; } } } diff --git a/context/ultimatelyricsprovider.h b/context/ultimatelyricsprovider.h index 1b99ff59a..05853e28f 100644 --- a/context/ultimatelyricsprovider.h +++ b/context/ultimatelyricsprovider.h @@ -44,9 +44,10 @@ class UltimateLyricsProvider : public QObject { struct RuleItem { enum Type { - XmlTag, // - Range, // - Container // + XmlTag, // + Range, // + Container, // + Unescape // }; RuleItem(Type t, const QString& b, const QString& e = QString()) From d1457014eebb0a3c5b9d663fe78effa02805ec41 Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 09:25:17 +0200 Subject: [PATCH 09/11] context: don't drop the last character of lyrics This was likely done to remove trailing whitespace, but in some cases it will actually drop the last character of the actual lyrics. The code runs trimmed(), which will remove trailing whitespace, so it shouldn't be required anyways. --- context/ultimatelyricsprovider.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/context/ultimatelyricsprovider.cpp b/context/ultimatelyricsprovider.cpp index 7d853c0f9..ce1f31e1e 100644 --- a/context/ultimatelyricsprovider.cpp +++ b/context/ultimatelyricsprovider.cpp @@ -121,7 +121,7 @@ static QString extract(const QString& source, const QString& begin, const QStrin } DBUG << "Found match"; - return source.mid(beginIdx, endIdx - beginIdx - 1); + return source.mid(beginIdx, endIdx - beginIdx); } static QRegularExpression xmlTagRegex = QRegularExpression("<(\\w+).*>"); From e02f6270b4910a8d6f04af1aa0892b062beddd53 Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 09:25:17 +0200 Subject: [PATCH 10/11] context: switch songlyrics.com to json as well --- context/lyrics_providers.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/context/lyrics_providers.xml b/context/lyrics_providers.xml index b5c5f9a2e..6d4eefc67 100644 --- a/context/lyrics_providers.xml +++ b/context/lyrics_providers.xml @@ -179,6 +179,13 @@ + + + + + From 7f335a1c8d5b25d57f8097855aa6588de4d5e030 Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 09:25:17 +0200 Subject: [PATCH 11/11] gui: set genius as default provider since azlyrics is also broken --- gui/settings.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/settings.cpp b/gui/settings.cpp index cb076acc6..ed59f6ce1 100644 --- a/gui/settings.cpp +++ b/gui/settings.cpp @@ -340,7 +340,7 @@ MPDParseUtils::CueSupport Settings::cueSupport() QStringList Settings::lyricProviders() { - return cfg.get("lyricProviders", QStringList() << "azlyrics.com"); + return cfg.get("lyricProviders", QStringList() << "genius.com"); } QStringList Settings::wikipediaLangs()