From 4d6de254e679a21271a4dd732364894163418282 Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 09:25:17 +0200 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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() From 53712e75ecf24d7cf1c650d444b76b8f94cba4ab Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 09:26:49 +0200 Subject: [PATCH 12/16] context: show lyrics source link This is done by saving metadata to a json file in cache. If this file exists, we check if it was created at the same time as the lyrics file, i.e. the simplest way to check if it is stale. If the file exists and is valid, attempt to load the provider and url from it and show that in the lyrics page. --- context/songview.cpp | 139 ++++++++++++++++++++++++++--- context/songview.h | 33 ++++++- context/ultimatelyrics.cpp | 3 +- context/ultimatelyrics.h | 3 +- context/ultimatelyricsprovider.cpp | 90 +++++++++++-------- context/ultimatelyricsprovider.h | 9 +- gui/cachesettings.cpp | 2 +- 7 files changed, 227 insertions(+), 52 deletions(-) diff --git a/context/songview.cpp b/context/songview.cpp index 32dca9ec5..e16ced751 100644 --- a/context/songview.cpp +++ b/context/songview.cpp @@ -46,6 +46,8 @@ #include #include #include +#include +#include #include #include #include @@ -60,6 +62,7 @@ const QLatin1String SongView::constLyricsDir("lyrics/"); const QLatin1String SongView::constExtension(".lyrics"); +const QLatin1String SongView::constSourceExt(".lyrics.json"); const QLatin1String SongView::constCacheDir("tracks/"); const QLatin1String SongView::constInfoExt(".html.gz"); @@ -81,6 +84,17 @@ static QString lyricsCacheFileName(const Song& song, bool createDir = false) return dir + Covers::encodeName(song.basicTitle()) + SongView::constExtension; } +// Sits next to the cached lyrics, and records where they came from. Deliberately never written to +// the MPD music dir - it is shared with other clients, which have no use for a Cantata specific file. +static QString sourceInfoFileName(const Song& song, bool createDir = false) +{ + QString dir = Utils::cacheDir(SongView::constLyricsDir + Covers::encodeName(song.basicArtist()) + Utils::constDirSep, createDir); + if (dir.isEmpty()) { + return QString(); + } + return dir + Covers::encodeName(song.basicTitle()) + SongView::constSourceExt; +} + #if !defined Q_OS_WIN && !defined Q_OS_MAC static QString lyricsOtherFileName(const Song& song) { @@ -112,7 +126,7 @@ SongView::SongView(QWidget* p) connect(refreshAction, SIGNAL(triggered()), SLOT(update())); connect(editAction, SIGNAL(triggered()), SLOT(edit())); connect(delAction, SIGNAL(triggered()), SLOT(del())); - connect(UltimateLyrics::self(), SIGNAL(lyricsReady(int, QString)), SLOT(lyricsReady(int, QString))); + connect(UltimateLyrics::self(), SIGNAL(lyricsReady(int, QString, QUrl)), SLOT(lyricsReady(int, QString, QUrl))); engine = ContextEngine::create(this); refreshInfoAction = ActionCollection::get()->createAction("refreshtrack", tr("Refresh Track Information"), Icons::self()->refreshIcon); @@ -161,6 +175,7 @@ void SongView::update() if (cacheExists) { QFile::remove(cacheName); } + QFile::remove(sourceInfoFileName(currentSong)); break; default: return; @@ -193,6 +208,7 @@ void SongView::search() if (!cacheName.isEmpty() && QFile::exists(cacheName)) { QFile::remove(cacheName); } + QFile::remove(sourceInfoFileName(currentSong)); update(dlg.song(), true); } } @@ -226,6 +242,7 @@ void SongView::del() if (!mpdName.isEmpty() && QFile::exists(mpdName)) { QFile::remove(mpdName); } + QFile::remove(sourceInfoFileName(currentSong)); } void SongView::showContextMenu(const QPoint& pos) @@ -378,14 +395,17 @@ void SongView::loadLyricsFromFile() QString tagLyrics = Tags::readLyrics(songFile); if (!tagLyrics.isEmpty()) { - text->setText(fixNewLines(tagLyrics)); + lyricsPlain = tagLyrics; + lyricsSource = QUrl(); + lyricsSourceName = QString(); + renderLyrics(); setMode(Mode_Display); // controls->setVisible(false); return; } #endif // Stop here if we found lyrics in the cache dir. - if (setLyricsFromFile(mpdLyrics)) { + if (setLyricsFromFile(mpdLyrics, true)) { lyricsFile = mpdLyrics; setMode(Mode_Display); return; @@ -402,7 +422,7 @@ void SongView::loadLyricsFromFile() // Check for cached file... QString file = lyricsCacheFileName(currentSong); - if (setLyricsFromFile(file)) { + if (setLyricsFromFile(file, true)) { // We just wanted a normal update without explicit re-fetching. We can return // here because we got cached lyrics and we don't want an explicit re-fetch. lyricsFile = file; @@ -721,6 +741,9 @@ void SongView::abort() job = nullptr; } currentProvider = -1; + lyricsPlain = QString(); + lyricsSource = QUrl(); + lyricsSourceName = QString(); if (currentProv) { currentProv->abort(); currentProv = nullptr; @@ -805,7 +828,10 @@ void SongView::downloadFinished() QTextStream str(reply->actualJob()); QString lyrics = str.readAll(); if (!lyrics.isEmpty()) { - text->setText(fixNewLines(lyrics)); + lyricsPlain = lyrics; + lyricsSource = QUrl(); + lyricsSourceName = QString(); + renderLyrics(); cancelJobAction->setEnabled(false); hideSpinner(); return; @@ -817,7 +843,7 @@ void SongView::downloadFinished() loadLyricsFromFile(); } -void SongView::lyricsReady(int id, QString lyrics) +void SongView::lyricsReady(int id, QString lyrics, QUrl source) { if (id != currentRequest) { return; @@ -840,16 +866,99 @@ void SongView::lyricsReady(int id, QString lyrics) getLyrics(); } else { + // Take the lyrics back out of the browser, rather than using 'plain' directly, so that + // what we save stays exactly what this round-trip has always produced. text->setText(fixNewLines(plain)); + lyricsPlain = text->toPlainText(); lyricsFile = QString(); if (!(Settings::self()->storeLyricsInMpdDir() && !currentSong.isNonMPD() && saveFile(mpdLyricsFilePath(currentSong)))) { saveFile(lyricsCacheFileName(currentSong, true)); } + lyricsSource = source; + lyricsSourceName = currentProv ? currentProv->displayName() : QString(); + // After saveFile(), so that the source info is never older than the lyrics it describes. + saveSourceInfo(); + renderLyrics(); setMode(Mode_Display); } } } +void SongView::renderLyrics() +{ + QString html = fixNewLines(lyricsPlain); + + if (lyricsSource.isValid() && !lyricsSourceName.isEmpty()) { + QString link = QLatin1String("") + lyricsSourceName.toHtmlEscaped() + QLatin1String(""); + html += QLatin1String("

") + tr("Lyrics from %1").arg(link) + QLatin1String("

"); + } + + setHtml(html, Page_Lyrics); +} + +void SongView::saveSourceInfo() +{ + QString fileName = sourceInfoFileName(currentSong, true); + + if (fileName.isEmpty()) { + return; + } + + if (!lyricsSource.isValid() || !currentProv) { + QFile::remove(fileName); + return; + } + + QJsonObject obj; + obj.insert(QLatin1String("provider"), currentProv->getName()); + obj.insert(QLatin1String("url"), lyricsSource.toString()); + + QFile f(fileName); + if (f.open(QIODevice::WriteOnly)) { + f.write(QJsonDocument(obj).toJson(QJsonDocument::Compact)); + } +} + +void SongView::loadSourceInfo(const QString& lyricsFilePath) +{ + QString fileName = sourceInfoFileName(currentSong); + + if (fileName.isEmpty()) { + return; + } + + // The lyrics may have been edited, or replaced by another client, since we recorded where they + // came from - in which case what we recorded no longer describes them. + QFileInfo sourceInfo(fileName); + if (!sourceInfo.exists() || sourceInfo.lastModified() < QFileInfo(lyricsFilePath).lastModified()) { + return; + } + + QFile f(fileName); + if (!f.open(QIODevice::ReadOnly)) { + return; + } + + QJsonObject obj = QJsonDocument::fromJson(f.readAll()).object(); + QUrl url(obj.value(QLatin1String("url")).toString()); + + if (!url.isValid()) { + return; + } + + lyricsSource = url; + lyricsSourceName = obj.value(QLatin1String("provider")).toString(); + + // Prefer how the provider presents itself, as the stored name carries markers - '(PORTUGUESE)' + // and the like - that are meant to be translated before being shown. + for (UltimateLyricsProvider* provider : UltimateLyrics::self()->getProviders()) { + if (provider->getName() == lyricsSourceName) { + lyricsSourceName = provider->displayName(); + break; + } + } +} + bool SongView::saveFile(const QString& fileName) { QFile f(fileName); @@ -860,7 +969,7 @@ bool SongView::saveFile(const QString& fileName) stream.setEncoding(QStringConverter::Utf8); stream.setGenerateByteOrderMark(true); #endif - stream << text->toPlainText(); + stream << lyricsPlain; f.close(); lyricsFile = fileName; return true; @@ -892,6 +1001,9 @@ void SongView::getLyrics() else { text->setText(QString()); currentProvider = -1; + lyricsPlain = QString(); + lyricsSource = QUrl(); + lyricsSourceName = QString(); // Set lyrics file anyway - so that editing is enabled! lyricsFile = Settings::self()->storeLyricsInMpdDir() && !currentSong.isNonMPD() ? mpdLyricsFilePath(currentSong) @@ -922,17 +1034,24 @@ void SongView::setMode(Mode m) } } -bool SongView::setLyricsFromFile(const QString& filePath) +bool SongView::setLyricsFromFile(const QString& filePath, bool useSourceInfo) { QFile f(filePath); if (f.exists() && f.open(QIODevice::ReadOnly)) { // Read the file using a QTextStream so we get automatic UTF8 detection. QTextStream inputStream(&f); - text->setText(fixNewLines(inputStream.readAll())); + lyricsPlain = inputStream.readAll(); + f.close(); + + lyricsSource = QUrl(); + lyricsSourceName = QString(); + if (useSourceInfo) { + loadSourceInfo(filePath); + } + renderLyrics(); cancelJobAction->setEnabled(false); hideSpinner(); - f.close(); return true; } diff --git a/context/songview.h b/context/songview.h index ac53ccb54..466231145 100644 --- a/context/songview.h +++ b/context/songview.h @@ -26,6 +26,7 @@ #include "config.h" #include "view.h" +#include #include class UltimateLyricsProvider; @@ -52,6 +53,7 @@ class SongView : public View { public: static const QLatin1String constLyricsDir; static const QLatin1String constExtension; + static const QLatin1String constSourceExt; static const QLatin1String constCacheDir; static const QLatin1String constInfoExt; @@ -66,7 +68,7 @@ class SongView : public View { public Q_SLOTS: void downloadFinished(); - void lyricsReady(int, QString lyrics); + void lyricsReady(int, QString lyrics, QUrl source); void update(); void search(); void edit(); @@ -98,15 +100,39 @@ private Q_SLOTS: void setMode(Mode m); bool saveFile(const QString& fileName); + /** + * Builds the lyrics page from lyricsPlain, appending a link to where the lyrics came from + * if we know it. All lyrics display goes through here, so that whatever we add to the page + * can never end up in the file we save. + */ + void renderLyrics(); + + /** + * Records, alongside the cached lyrics, which provider supplied them and the page they can be + * read on. The lyrics file itself is left alone - other MPD clients read it too. + */ + void saveSourceInfo(); + + /** + * Restores what saveSourceInfo() recorded, provided it is no older than the lyrics it + * describes. + * + * @param lyricsFilePath The lyrics file the source information has to match. + */ + void loadSourceInfo(const QString& lyricsFilePath); + /** * Reads the lyrics from the given filePath and updates * the UI with those lyrics. * * @param filePath The path to the lyrics file which will be read. * + * @param useSourceInfo Whether the file is one we wrote ourselves, and may therefore have + * recorded source information for. False for user supplied files. + * * @return Returns true if the file could be read; otherwise false. */ - bool setLyricsFromFile(const QString& filePath); + bool setLyricsFromFile(const QString& filePath, bool useSourceInfo = false); private: QTimer* scrollTimer; @@ -119,6 +145,9 @@ private Q_SLOTS: Action* delAction; Mode mode; QString lyricsFile; + QString lyricsPlain; + QUrl lyricsSource; + QString lyricsSourceName; QString preEdit; NetworkJob* job; UltimateLyricsProvider* currentProv; diff --git a/context/ultimatelyrics.cpp b/context/ultimatelyrics.cpp index 6c35cd272..7293e19c8 100644 --- a/context/ultimatelyrics.cpp +++ b/context/ultimatelyrics.cpp @@ -93,6 +93,7 @@ static UltimateLyricsProvider* parseProvider(QXmlStreamReader* reader) scraper->setName(attributes.value("name").toString()); scraper->setCharset(attributes.value("charset").toString()); scraper->setUrl(attributes.value("url").toString()); + scraper->setPageUrl(attributes.value("pageUrl").toString()); while (!reader->atEnd()) { reader->readNext(); @@ -196,7 +197,7 @@ void UltimateLyrics::load() UltimateLyricsProvider* provider = parseProvider(&reader); if (provider) { providers << provider; - connect(provider, SIGNAL(lyricsReady(int, QString)), this, SIGNAL(lyricsReady(int, QString))); + connect(provider, SIGNAL(lyricsReady(int, QString, QUrl)), this, SIGNAL(lyricsReady(int, QString, QUrl))); providerNames.insert(name); } } diff --git a/context/ultimatelyrics.h b/context/ultimatelyrics.h index 3c175105e..31607ada6 100644 --- a/context/ultimatelyrics.h +++ b/context/ultimatelyrics.h @@ -25,6 +25,7 @@ #define ULTIMATELYRICS_H #include +#include class UltimateLyricsProvider; @@ -41,7 +42,7 @@ class UltimateLyrics : public QObject { void setEnabled(const QStringList& enabled); Q_SIGNALS: - void lyricsReady(int id, const QString& data); + void lyricsReady(int id, const QString& data, const QUrl& source); private: UltimateLyricsProvider* providerByName(const QString& name) const; diff --git a/context/ultimatelyricsprovider.cpp b/context/ultimatelyricsprovider.cpp index ce1f31e1e..02cc678f8 100644 --- a/context/ultimatelyricsprovider.cpp +++ b/context/ultimatelyricsprovider.cpp @@ -386,56 +386,69 @@ QString UltimateLyricsProvider::displayName() const return n; } -void UltimateLyricsProvider::fetchInfo(int id, Song metadata, bool removeThe) +QUrl UltimateLyricsProvider::buildUrl(const QString& templateUrl, const QString& artist, const QString& title, const Song& metadata) const { - auto converter = QStringDecoder(charset.toLatin1().constData(), QStringConverter::Flag::Default); - - if (!converter.isValid()) { - emit lyricsReady(id, QString()); - return; - } - - QString artistFixed = metadata.basicArtist(); - QString titleFixed = metadata.basicTitle(); - QString urlText(url); - - if (removeThe && artistFixed.startsWith(constThe)) { - artistFixed = artistFixed.mid(constThe.length()); - } - - metadata.priority = removeThe ? 1 : 0;// HACK Use this to indicate if searching without 'The ' - songs.insert(id, metadata); + QString urlText(templateUrl); // Fill in fields in the URL bool urlContainsDetails = urlText.contains(QLatin1Char('{')); if (urlContainsDetails) { - doUrlReplace(constArtistArg, artistFixed, urlText); - doUrlReplace(constArtistLowerArg, artistFixed.toLower(), urlText); - doUrlReplace(constArtistLowerNoSpaceArg, noSpace(artistFixed.toLower()), urlText); - doUrlReplace(constArtistFirstCharArg, firstChar(artistFixed), urlText); + doUrlReplace(constArtistArg, artist, urlText); + doUrlReplace(constArtistLowerArg, artist.toLower(), urlText); + doUrlReplace(constArtistLowerNoSpaceArg, noSpace(artist.toLower()), urlText); + doUrlReplace(constArtistFirstCharArg, firstChar(artist), urlText); doUrlReplace(constAlbumArg, metadata.album, urlText); doUrlReplace(constAlbumLowerArg, metadata.album.toLower(), urlText); doUrlReplace(constAlbumLowerNoSpaceArg, noSpace(metadata.album.toLower()), urlText); - doUrlReplace(constTitleArg, titleFixed, urlText); - doUrlReplace(constTitleLowerArg, titleFixed.toLower(), urlText); - doUrlReplace(constTitleCaseArg, titleCase(titleFixed), urlText); + doUrlReplace(constTitleArg, title, urlText); + doUrlReplace(constTitleLowerArg, title.toLower(), urlText); + doUrlReplace(constTitleCaseArg, titleCase(title), urlText); doUrlReplace(constYearArg, QString::number(metadata.year), urlText); doUrlReplace(constTrackNoArg, QString::number(metadata.track), urlText); } // For some reason Qt messes up the ? -> %3F and & -> %26 conversions - by placing 25 after the % // So, try and revert this... - QUrl url(urlText); + QUrl built(urlText); if (urlContainsDetails) { - QByteArray data = url.toEncoded(); + QByteArray data = built.toEncoded(); data.replace("%253F", "%3F"); data.replace("%253f", "%3f"); data.replace("%2526", "%26"); - url = QUrl::fromEncoded(data, QUrl::StrictMode); + built = QUrl::fromEncoded(data, QUrl::StrictMode); + } + + return built; +} + +void UltimateLyricsProvider::fetchInfo(int id, Song metadata, bool removeThe) +{ + auto converter = QStringDecoder(charset.toLatin1().constData(), QStringConverter::Flag::Default); + + if (!converter.isValid()) { + emit lyricsReady(id, QString(), QUrl()); + return; } - QNetworkRequest req(url); + QString artistFixed = metadata.basicArtist(); + QString titleFixed = metadata.basicTitle(); + + if (removeThe && artistFixed.startsWith(constThe)) { + artistFixed = artistFixed.mid(constThe.length()); + } + + metadata.priority = removeThe ? 1 : 0;// HACK Use this to indicate if searching without 'The ' + songs.insert(id, metadata); + + QUrl fetchUrl = buildUrl(url, artistFixed, titleFixed, metadata); + + // Remember where these lyrics came from, so that we can offer a link to the source. For + // providers queried through an API the fetched url is of no use to the user, so those supply a + // separate, human readable, page url instead. + sourceUrls.insert(id, pageUrl.isEmpty() ? fetchUrl : buildUrl(pageUrl, artistFixed, titleFixed, metadata)); + + QNetworkRequest req(fetchUrl); req.setRawHeader("User-Agent", "Mozilla/5.0 (X11; Linux i686; rv:6.0) Gecko/20100101 Firefox/6.0"); NetworkJob* reply = NetworkAccessManager::self()->get(req); requests[reply] = id; @@ -452,6 +465,7 @@ void UltimateLyricsProvider::abort() } requests.clear(); songs.clear(); + sourceUrls.clear(); } void UltimateLyricsProvider::wikiMediaSearchResponse() @@ -466,11 +480,12 @@ void UltimateLyricsProvider::wikiMediaSearchResponse() if (!reply->ok()) { Song song = songs.take(id); + sourceUrls.remove(id); if (tryWithoutThe(song)) { fetchInfo(id, song, true); } else { - emit lyricsReady(id, QString()); + emit lyricsReady(id, QString(), QUrl()); } return; } @@ -497,7 +512,8 @@ void UltimateLyricsProvider::wikiMediaSearchResponse() connect(reply, SIGNAL(finished()), this, SLOT(wikiMediaLyricsFetched())); } else { - emit lyricsReady(id, QString()); + sourceUrls.remove(id); + emit lyricsReady(id, QString(), QUrl()); } } @@ -513,11 +529,12 @@ void UltimateLyricsProvider::wikiMediaLyricsFetched() if (!reply->ok()) { Song song = songs.take(id); + sourceUrls.remove(id); if (tryWithoutThe(song)) { fetchInfo(id, song, true); } else { - emit lyricsReady(id, QString()); + emit lyricsReady(id, QString(), QUrl()); } return; } @@ -526,7 +543,7 @@ void UltimateLyricsProvider::wikiMediaLyricsFetched() QString contents = fromCharset(reply->readAll()); contents = contents.replace("
", "
"); DBUG << name << "response" << contents; - emit lyricsReady(id, extract(contents, QLatin1String("<lyrics>"), QLatin1String("</lyrics>"))); + emit lyricsReady(id, extract(contents, QLatin1String("<lyrics>"), QLatin1String("</lyrics>")), sourceUrls.take(id)); } void UltimateLyricsProvider::lyricsFetched() @@ -539,6 +556,7 @@ void UltimateLyricsProvider::lyricsFetched() int id = requests.take(reply); reply->deleteLater(); Song song = songs.take(id); + QUrl source = sourceUrls.take(id); if (!reply->ok()) { //emit Finished(id); @@ -546,7 +564,7 @@ void UltimateLyricsProvider::lyricsFetched() fetchInfo(id, song, true); } else { - emit lyricsReady(id, QString()); + emit lyricsReady(id, QString(), QUrl()); } return; } @@ -565,7 +583,7 @@ void UltimateLyricsProvider::lyricsFetched() fetchInfo(id, song, true); } else { - emit lyricsReady(id, QString()); + emit lyricsReady(id, QString(), QUrl()); } return; } @@ -601,7 +619,7 @@ void UltimateLyricsProvider::lyricsFetched() fetchInfo(id, song, true); } else { - emit lyricsReady(id, lyrics); + emit lyricsReady(id, lyrics, lyrics.isEmpty() ? QUrl() : source); } } diff --git a/context/ultimatelyricsprovider.h b/context/ultimatelyricsprovider.h index 05853e28f..4a97caf63 100644 --- a/context/ultimatelyricsprovider.h +++ b/context/ultimatelyricsprovider.h @@ -30,6 +30,7 @@ #include #include #include +#include class NetworkJob; @@ -62,6 +63,9 @@ class UltimateLyricsProvider : public QObject { void setName(const QString& n) { name = n; } void setUrl(const QString& u) { url = u; } + // Providers queried through an API endpoint set this to the human readable page for the song, + // so that we can link to something useful. When unset, the fetched url is used. + void setPageUrl(const QString& u) { pageUrl = u; } void setCharset(const QString& c) { charset = c; } void setRelevance(int r) { relevance = r; } void addUrlFormat(const QString& replace, const QString& with) { urlFormats << UrlFormat(replace, with); } @@ -77,7 +81,7 @@ class UltimateLyricsProvider : public QObject { void abort(); Q_SIGNALS: - void lyricsReady(int id, const QString& data); + void lyricsReady(int id, const QString& data, const QUrl& source); private Q_SLOTS: void wikiMediaSearchResponse(); @@ -87,13 +91,16 @@ private Q_SLOTS: private: QString doTagReplace(QString str, const Song& song, bool doAll = true); void doUrlReplace(const QString& tag, const QString& value, QString& u) const; + QUrl buildUrl(const QString& templateUrl, const QString& artist, const QString& title, const Song& metadata) const; private: bool enabled; QHash requests; QMap songs; + QMap sourceUrls; QString name; QString url; + QString pageUrl; QString charset; int relevance; QList urlFormats; diff --git a/gui/cachesettings.cpp b/gui/cachesettings.cpp index 82238683c..a00b72fe4 100644 --- a/gui/cachesettings.cpp +++ b/gui/cachesettings.cpp @@ -267,7 +267,7 @@ CacheSettings::CacheSettings(QWidget* parent) new CacheItem(tr("Backdrops"), Utils::cacheDir(ContextWidget::constCacheDir, false), QStringList() << "*.jpg" << "*.png", tree); - new CacheItem(tr("Lyrics"), Utils::cacheDir(SongView::constLyricsDir, false), QStringList() << "*" + SongView::constExtension, tree); + new CacheItem(tr("Lyrics"), Utils::cacheDir(SongView::constLyricsDir, false), QStringList() << "*" + SongView::constExtension << "*" + SongView::constSourceExt, tree); new CacheItem(tr("Artist Information"), Utils::cacheDir(ArtistView::constCacheDir, false), QStringList() << "*" + ArtistView::constInfoExt << "*" + ArtistView::constSimilarInfoExt << "*.json.gz" << "*.jpg" << "*.png", tree); new CacheItem(tr("Album Information"), Utils::cacheDir(AlbumView::constCacheDir, false), QStringList() << "*" + AlbumView::constInfoExt << "*.jpg" << "*.png", From 72f959f888eb33da2cf7d0812f7aa1c44eb39272 Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 09:26:49 +0200 Subject: [PATCH 13/16] context: if the lyrics provides headers, render them as such Gated by a markup option, since I can't test every provider, so this seemed to be the safe route. --- context/lyrics_providers.xml | 2 +- context/songview.cpp | 35 +++++++++++++++++++++++++++--- context/songview.h | 1 + context/ultimatelyrics.cpp | 1 + context/ultimatelyricsprovider.cpp | 2 +- context/ultimatelyricsprovider.h | 6 +++++ 6 files changed, 42 insertions(+), 5 deletions(-) diff --git a/context/lyrics_providers.xml b/context/lyrics_providers.xml index 6d4eefc67..7bb31c6c7 100644 --- a/context/lyrics_providers.xml +++ b/context/lyrics_providers.xml @@ -52,7 +52,7 @@
- + - - From f6dbe22b319cd1493a1ebb725c67ed8ef07d3a38 Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 09:26:49 +0200 Subject: [PATCH 15/16] context: add lyrics annotation support for genius Guided by an option, this allows to indicate and show available annotations for the loaded lyrics. --- CMakeLists.txt | 1 + context/lyricsmarkup.cpp | 206 ++++++++++++++++++++++++++++++++++++++ context/lyricsmarkup.h | 102 +++++++++++++++++++ context/othersettings.cpp | 2 + context/othersettings.ui | 16 ++- context/songview.cpp | 150 +++++++++++++++++++++++---- context/songview.h | 24 ++++- gui/settings.cpp | 10 ++ gui/settings.h | 2 + 9 files changed, 487 insertions(+), 26 deletions(-) create mode 100644 context/lyricsmarkup.cpp create mode 100644 context/lyricsmarkup.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 7aeb96930..658e505b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -454,6 +454,7 @@ target_sources( widgets/genrecombo.cpp widgets/volumecontrol.cpp context/lyricsettings.cpp + context/lyricsmarkup.cpp context/ultimatelyricsprovider.cpp context/ultimatelyrics.cpp context/lyricsdialog.cpp diff --git a/context/lyricsmarkup.cpp b/context/lyricsmarkup.cpp new file mode 100644 index 000000000..2ce240a71 --- /dev/null +++ b/context/lyricsmarkup.cpp @@ -0,0 +1,206 @@ +/* + * Cantata + * + * Copyright (c) Bernd Steinhauser + * + * ---- + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. + */ + +#include "lyricsmarkup.h" +#include +#include +#include + +namespace LyricsMarkup { + +const QLatin1String constAnnotationUrl("cantata:///?annotation="); +const QLatin1String constAnnotationQuery("annotation"); + +// The name of the element a tag opens or closes, lower cased. +static QString tagName(const QString& tag) +{ + int pos = 1; + + if (pos < tag.length() && QLatin1Char('/') == tag.at(pos)) { + pos++; + } + + int start = pos; + while (pos < tag.length() && tag.at(pos).isLetterOrNumber()) { + pos++; + } + + return tag.mid(start, pos - start).toLower(); +} + +static bool isClosingTag(const QString& tag) +{ + return tag.length() > 1 && QLatin1Char('/') == tag.at(1); +} + +// Genius points an annotated phrase at '//', sometimes fully qualified. Anything else is +// an ordinary link, which we have no annotation for. +static QString referentId(const QString& tag) +{ + static const QRegularExpression constHref(QLatin1String("\\bhref\\s*=\\s*[\"']([^\"']*)[\"']"), QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression constReferent(QLatin1String("^(?:https?://(?:www\\.)?genius\\.com)?/(\\d+)/")); + + QRegularExpressionMatch href = constHref.match(tag); + + if (!href.hasMatch()) { + return QString(); + } + + QRegularExpressionMatch referent = constReferent.match(href.captured(1)); + return referent.hasMatch() ? referent.captured(1) : QString(); +} + +QString styleSectionHeaders(const QString& lyrics) +{ + static const QRegularExpression constHeader(QLatin1String("^\\s*\\[([^\\]]{1,80})\\]\\s*$")); + + QStringList lines = lyrics.split(QLatin1Char('\n')); + + for (QString& line : lines) { + QRegularExpressionMatch match = constHeader.match(line); + if (match.hasMatch()) { + line = QLatin1String("[") + match.captured(1).toHtmlEscaped() + QLatin1String("]"); + } + } + + return lines.join(QLatin1Char('\n')); +} + +Prepared prepare(const QString& providerText) +{ + Prepared prepared; + + // Normalised the same way the plain text path normalises it, so that both end up with the same + // lines. A newline in the markup is a line break here just as it is there. + QString source(providerText); + source.replace(QLatin1String("\\n"), QLatin1String("\n")); + source.replace(QLatin1String("\\t"), QLatin1String(" ")); + source.replace(QLatin1Char('\t'), QLatin1Char(' ')); + source.replace(QLatin1String("\n\n\n"), QLatin1String("\n\n")); + + QString line; + QString openId; + + for (int pos = 0; pos < source.length();) { + QChar ch = source.at(pos); + + if (QLatin1Char('\n') == ch) { + prepared.lines.append(styleSectionHeaders(line)); + line.clear(); + pos++; + continue; + } + + if (QLatin1Char('<') != ch) { + line += ch; + pos++; + continue; + } + + int close = source.indexOf(QLatin1Char('>'), pos); + + if (-1 == close) { + // Not a tag, just a '<' the lyric happens to contain. + line += QLatin1String("<"); + pos++; + continue; + } + + QString tag = source.mid(pos, close - pos + 1); + QString name = tagName(tag); + pos = close + 1; + + if (QLatin1String("br") == name) { + prepared.lines.append(styleSectionHeaders(line)); + line.clear(); + } + else if (QLatin1String("script") == name || QLatin1String("style") == name) { + // These are the only elements whose text we drop along with the tag. + if (!isClosingTag(tag)) { + int end = source.indexOf(QLatin1String("") : QLatin1String(""); + } + else if (QLatin1String("i") == name || QLatin1String("em") == name) { + line += isClosingTag(tag) ? QLatin1String("") : QLatin1String(""); + } + else if (QLatin1String("a") == name) { + if (isClosingTag(tag)) { + if (!openId.isEmpty()) { + line += QLatin1String(""); + // Recorded against the line the phrase ends on, not the one it starts on, so + // that a phrase running over a line break is annotated below all of it. + prepared.refs.append(qMakePair(openId, prepared.lines.count())); + openId.clear(); + } + } + else if (openId.isEmpty()) { + openId = referentId(tag); + if (!openId.isEmpty()) { + line += QLatin1String(""); + } + } + } + // Everything else is page furniture. Drop the tag, but keep whatever text it wraps. + } + + prepared.lines.append(styleSectionHeaders(line)); + return prepared; +} + +QString render(const Prepared& prepared, const QHash& bodies, const QSet& expanded) +{ + QMap opened; + + for (const QPair& ref : prepared.refs) { + if (expanded.contains(ref.first)) { + opened[ref.second].append(ref.first); + } + } + + QString html; + // An annotation is a block of its own, so the line after it needs no separator. + bool afterBlock = true; + + for (int i = 0; i < prepared.lines.count(); ++i) { + if (!afterBlock) { + html += QLatin1String("
"); + } + html += prepared.lines.at(i); + afterBlock = false; + + for (const QString& id : opened.value(i)) { + html += QLatin1String("
") + + (bodies.contains(id) ? bodies.value(id) : QObject::tr("Loading annotation...")) + + QLatin1String("
"); + afterBlock = true; + } + } + + return html; +} + +}// namespace LyricsMarkup diff --git a/context/lyricsmarkup.h b/context/lyricsmarkup.h new file mode 100644 index 000000000..421b34039 --- /dev/null +++ b/context/lyricsmarkup.h @@ -0,0 +1,102 @@ +/* + * Cantata + * + * Copyright (c) 2011-2022 Craig Drummond + * + * ---- + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. + */ + +#ifndef LYRICSMARKUP_H +#define LYRICSMARKUP_H + +#include +#include +#include +#include +#include +#include + +/** + * Turns what a lyrics provider returned into something we can display, for the providers whose + * output is structured enough to be worth more than plain text. + * + * Genius wraps the phrases people have annotated in links carrying the annotation's id. Those links + * survive the scraping rules intact, so the annotated ranges are already known here - they only + * need to be kept rather than flattened away with the rest of the page's markup. + */ +namespace LyricsMarkup { + +/** + * Lyrics broken into display ready lines, with the annotated phrases linked. + */ +struct Prepared { + /** One entry per lyric line, holding the small subset of HTML we allow through. */ + QStringList lines; + + /** Annotation id, paired with the index of the line the annotated phrase ends on. */ + QList> refs; + + bool hasAnnotations() const { return !refs.isEmpty(); } + + void clear() + { + lines.clear(); + refs.clear(); + } +}; + +/** The scheme and query a click on an annotated phrase arrives with. */ +extern const QLatin1String constAnnotationUrl; +extern const QLatin1String constAnnotationQuery; + +/** + * Reduces the provider's markup to line, emphasis and annotation links, dropping everything else + * while keeping its text. + * + * @param providerText What the extract and exclude rules produced. + * + * @return The lyrics, and where in them the annotations belong. + */ +Prepared prepare(const QString& providerText); + +/** + * Builds the page for the lyrics, showing the annotations the user has opened. + * + * @param prepared The lyrics, as returned by prepare(). + * @param bodies The annotation texts fetched so far, keyed by id. An id that is missing is still + * on its way, and is shown as such. + * @param expanded The ids the user has opened. + * + * @return The body of the lyrics page. + */ +QString render(const Prepared& prepared, const QHash& bodies, const QSet& expanded); + +/** + * Emphasises the section markers - '[Verse 1]', '[Chorus]' - that some providers put on a line of + * their own, so that they read as structure rather than as part of the lyric. Only a line that is + * nothing but a marker qualifies; bracketed asides within a line ('[sic]', '[?]') are left alone. + * + * @param lyrics One or more newline separated lines. + * + * @return The lines, with any section markers emphasised. + */ +QString styleSectionHeaders(const QString& lyrics); + +}// namespace LyricsMarkup + +#endif// LYRICSMARKUP_H diff --git a/context/othersettings.cpp b/context/othersettings.cpp index 3328ccd15..5ee15aaa5 100644 --- a/context/othersettings.cpp +++ b/context/othersettings.cpp @@ -55,6 +55,7 @@ void OtherSettings::load() contextDarkBackground->setChecked(Settings::self()->contextDarkBackground()); contextAlwaysCollapsed->setChecked(Settings::self()->contextAlwaysCollapsed()); storeLyricsInMpdDir->setChecked(Settings::self()->storeLyricsInMpdDir()); + lyricsAnnotations->setChecked(Settings::self()->lyricsAnnotations()); int bgnd = Settings::self()->contextBackdrop(); contextBackdrop_none->setChecked(bgnd == contextBackdrop_none->property(constValueProperty).toInt()); @@ -77,6 +78,7 @@ void OtherSettings::save() Settings::self()->saveContextDarkBackground(contextDarkBackground->isChecked()); Settings::self()->saveContextAlwaysCollapsed(contextAlwaysCollapsed->isChecked()); Settings::self()->saveStoreLyricsInMpdDir(storeLyricsInMpdDir->isChecked()); + Settings::self()->saveLyricsAnnotations(lyricsAnnotations->isChecked()); if (contextBackdrop_none->isChecked()) { Settings::self()->saveContextBackdrop(contextBackdrop_none->property(constValueProperty).toInt()); diff --git a/context/othersettings.ui b/context/othersettings.ui index 19cdc47c7..df7445325 100644 --- a/context/othersettings.ui +++ b/context/othersettings.ui @@ -205,6 +205,16 @@
+ + + Show lyric annotations, where the provider offers them + + + Mark up the phrases that have been annotated, so that the annotation can be read by clicking on them. Only Genius currently supplies these. + + + + Always collapse into a single pane @@ -214,21 +224,21 @@ - + Only show basic wikipedia text - + Cantata only shows a trimmed down version of wikipedia pages (no images, links, etc). This trimming is not always 100% accurate, which is why Cantata defaults to only showing the introduction. If you elect to show the full article, then there may be parsing errors. You will also need to remove any currently cached articles (using the 'Cache' page). - + Qt::Vertical diff --git a/context/songview.cpp b/context/songview.cpp index d37b2d819..be5464f5f 100644 --- a/context/songview.cpp +++ b/context/songview.cpp @@ -26,6 +26,7 @@ #include "gui/covers.h" #include "gui/settings.h" #include "lyricsdialog.h" +#include "lyricsmarkup.h" #include "support/messagebox.h" #include "support/squeezedtextlabel.h" #include "support/utils.h" @@ -49,7 +50,7 @@ #include #include #include -#include +#include #include #include #include @@ -108,25 +109,6 @@ static inline QString mpdLyricsFilePath(const Song& song) return Utils::changeExtension(song.filePath(MPDConnection::self()->getDetails().dir), SongView::constExtension); } -// Emphasises the section markers - '[Verse 1]', '[Chorus]' - that some providers put on a line of -// their own, so that they read as structure rather than as part of the lyric. Only a line that is -// nothing but a marker qualifies; bracketed asides within a line ('[sic]', '[?]') are left alone. -static QString styleSectionHeaders(const QString& lyrics) -{ - static const QRegularExpression constHeader(QLatin1String("^\\s*\\[([^\\]]{1,80})\\]\\s*$")); - - QStringList lines = lyrics.split(QLatin1Char('\n')); - - for (QString& line : lines) { - QRegularExpressionMatch match = constHeader.match(line); - if (match.hasMatch()) { - line = QLatin1String("[") + match.captured(1).toHtmlEscaped() + QLatin1String("]"); - } - } - - return lines.join(QLatin1Char('\n')); -} - static inline QString fixNewLines(const QString& o) { return QString(o).replace("\\n", "\n").replace("\\t", " ").replace("\t", " ").replace(QLatin1String("\n\n\n"), QLatin1String("\n\n")).replace("\n", "
"); @@ -334,6 +316,12 @@ void SongView::songPosition() void SongView::scroll() { + // An open annotation both moves the lyrics down and is something the reader is in the middle + // of, so leave the view where they put it until they close it again. + if (!expandedAnnotations.isEmpty()) { + return; + } + if (Mode_Display == mode && scrollAction->isChecked() && scrollTimer) { QScrollBar* bar = text->verticalScrollBar(); @@ -419,6 +407,7 @@ void SongView::loadLyricsFromFile() lyricsSource = QUrl(); lyricsSourceName = QString(); sourceProvidesMarkup = false; + clearAnnotations(); renderLyrics(); setMode(Mode_Display); // controls->setVisible(false); @@ -745,6 +734,13 @@ void SongView::abortInfoSearch() void SongView::showMoreInfo(const QUrl& url) { + QUrlQuery query(url); + + if (QLatin1String("cantata") == url.scheme() && query.hasQueryItem(LyricsMarkup::constAnnotationQuery)) { + toggleAnnotation(query.queryItemValue(LyricsMarkup::constAnnotationQuery)); + return; + } + QDesktopServices::openUrl(url); } @@ -766,6 +762,7 @@ void SongView::abort() lyricsSource = QUrl(); lyricsSourceName = QString(); sourceProvidesMarkup = false; + clearAnnotations(); if (currentProv) { currentProv->abort(); currentProv = nullptr; @@ -854,6 +851,7 @@ void SongView::downloadFinished() lyricsSource = QUrl(); lyricsSourceName = QString(); sourceProvidesMarkup = false; + clearAnnotations(); renderLyrics(); cancelJobAction->setEnabled(false); hideSpinner(); @@ -900,6 +898,13 @@ void SongView::lyricsReady(int id, QString lyrics, QUrl source) lyricsSource = source; lyricsSourceName = currentProv ? currentProv->displayName() : QString(); sourceProvidesMarkup = currentProv && currentProv->providesMarkup(); + clearAnnotations(); + if (sourceProvidesMarkup && Settings::self()->lyricsAnnotations()) { + lyricsMarkup = LyricsMarkup::prepare(lyrics); + // Keep what the provider sent, so that the annotations are still there next time + // these lyrics are read back from the cache. + lyricsProviderMarkup = lyricsMarkup.hasAnnotations() ? lyrics : QString(); + } // After saveFile(), so that the source info is never older than the lyrics it describes. saveSourceInfo(); renderLyrics(); @@ -908,16 +913,105 @@ void SongView::lyricsReady(int id, QString lyrics, QUrl source) } } -void SongView::renderLyrics() +void SongView::renderLyrics(bool keepPosition) { - QString html = fixNewLines(sourceProvidesMarkup ? styleSectionHeaders(lyricsPlain) : lyricsPlain); + QString html = lyricsMarkup.hasAnnotations() + ? LyricsMarkup::render(lyricsMarkup, annotationBodies, expandedAnnotations) + : fixNewLines(sourceProvidesMarkup ? LyricsMarkup::styleSectionHeaders(lyricsPlain) : lyricsPlain); if (lyricsSource.isValid() && !lyricsSourceName.isEmpty()) { QString link = QLatin1String("
") + lyricsSourceName.toHtmlEscaped() + QLatin1String(""); html += QLatin1String("

") + tr("Lyrics from %1").arg(link) + QLatin1String("

"); } + QScrollBar* bar = keepPosition ? text->verticalScrollBar() : nullptr; + int position = bar ? bar->value() : 0; + setHtml(html, Page_Lyrics); + + if (bar) { + bar->setValue(position); + } +} + +void SongView::toggleAnnotation(const QString& id) +{ + if (id.isEmpty()) { + return; + } + + if (expandedAnnotations.contains(id)) { + expandedAnnotations.remove(id); + } + else { + expandedAnnotations.insert(id); + + if (!annotationBodies.contains(id)) { + // Only ever fetched when a reader asks for one, so a song with fifty annotations costs + // nothing until they are opened. + QNetworkRequest req(QUrl(QLatin1String("https://genius.com/api/annotations/") + id + QLatin1String("?text_format=plain"))); + req.setRawHeader("User-Agent", "Mozilla/5.0 (X11; Linux i686; rv:6.0) Gecko/20100101 Firefox/6.0"); + NetworkJob* reply = NetworkAccessManager::self()->get(req, 5000); + annotationJobs.insert(reply, id); + connect(reply, SIGNAL(finished()), this, SLOT(annotationFetched())); + } + } + + renderLyrics(true); +} + +void SongView::annotationFetched() +{ + NetworkJob* reply = qobject_cast(sender()); + + if (!reply) { + return; + } + + reply->deleteLater(); + QString id = annotationJobs.take(reply); + + if (id.isEmpty()) { + return; + } + + QString body; + + if (reply->ok()) { + // Asking for the plain text form keeps Genius' own markup - images, embedded links - out of + // the document the lyrics live in. + body = QJsonDocument::fromJson(reply->readAll()) + .object() + .value(QLatin1String("response")) + .toObject() + .value(QLatin1String("annotation")) + .toObject() + .value(QLatin1String("body")) + .toObject() + .value(QLatin1String("plain")) + .toString() + .trimmed(); + } + + annotationBodies.insert(id, body.isEmpty() + ? tr("Annotation unavailable.") + : body.toHtmlEscaped().replace(QLatin1String("\n"), QLatin1String("
"))); + renderLyrics(true); +} + +void SongView::clearAnnotations() +{ + // Emptied before cancelling, as aborting a reply reports it as finished - which would otherwise + // come back through annotationFetched() while we are still tearing down. + QList jobs = annotationJobs.keys(); + annotationJobs.clear(); + for (NetworkJob* job : jobs) { + job->cancelAndDelete(); + } + lyricsProviderMarkup = QString(); + lyricsMarkup.clear(); + annotationBodies.clear(); + expandedAnnotations.clear(); } void SongView::saveSourceInfo() @@ -936,6 +1030,9 @@ void SongView::saveSourceInfo() QJsonObject obj; obj.insert(QLatin1String("provider"), currentProv->getName()); obj.insert(QLatin1String("url"), lyricsSource.toString()); + if (!lyricsProviderMarkup.isEmpty()) { + obj.insert(QLatin1String("markup"), lyricsProviderMarkup); + } QFile f(fileName); if (f.open(QIODevice::WriteOnly)) { @@ -984,6 +1081,13 @@ void SongView::loadSourceInfo(const QString& lyricsFilePath) break; } } + + if (sourceProvidesMarkup && Settings::self()->lyricsAnnotations()) { + lyricsProviderMarkup = obj.value(QLatin1String("markup")).toString(); + if (!lyricsProviderMarkup.isEmpty()) { + lyricsMarkup = LyricsMarkup::prepare(lyricsProviderMarkup); + } + } } bool SongView::saveFile(const QString& fileName) @@ -1032,6 +1136,7 @@ void SongView::getLyrics() lyricsSource = QUrl(); lyricsSourceName = QString(); sourceProvidesMarkup = false; + clearAnnotations(); // Set lyrics file anyway - so that editing is enabled! lyricsFile = Settings::self()->storeLyricsInMpdDir() && !currentSong.isNonMPD() ? mpdLyricsFilePath(currentSong) @@ -1075,6 +1180,7 @@ bool SongView::setLyricsFromFile(const QString& filePath, bool useSourceInfo) lyricsSource = QUrl(); lyricsSourceName = QString(); sourceProvidesMarkup = false; + clearAnnotations(); if (useSourceInfo) { loadSourceInfo(filePath); } diff --git a/context/songview.h b/context/songview.h index bc21e29af..a1d1cedb2 100644 --- a/context/songview.h +++ b/context/songview.h @@ -25,7 +25,10 @@ #define SONG_VIEW_H #include "config.h" +#include "lyricsmarkup.h" #include "view.h" +#include +#include #include #include @@ -85,6 +88,7 @@ private Q_SLOTS: void infoSearchResponse(const QString& resp, const QString& lang); void abortInfoSearch(); void showMoreInfo(const QUrl& url); + void annotationFetched(); private: void loadLyrics(); @@ -104,8 +108,11 @@ private Q_SLOTS: * Builds the lyrics page from lyricsPlain, appending a link to where the lyrics came from * if we know it. All lyrics display goes through here, so that whatever we add to the page * can never end up in the file we save. + * + * @param keepPosition Whether to leave the view where it is, for a re-render of lyrics the + * reader is already part way through. False when showing new lyrics. */ - void renderLyrics(); + void renderLyrics(bool keepPosition = false); /** * Records, alongside the cached lyrics, which provider supplied them and the page they can be @@ -121,6 +128,16 @@ private Q_SLOTS: */ void loadSourceInfo(const QString& lyricsFilePath); + /** + * Opens or closes an annotation, fetching its text the first time it is opened. + * + * @param id The annotation's id, as carried by the link that was clicked. + */ + void toggleAnnotation(const QString& id); + + /** Discards the annotations, along with anything still being fetched for them. */ + void clearAnnotations(); + /** * Reads the lyrics from the given filePath and updates * the UI with those lyrics. @@ -149,6 +166,11 @@ private Q_SLOTS: QUrl lyricsSource; QString lyricsSourceName; bool sourceProvidesMarkup; + QString lyricsProviderMarkup; + LyricsMarkup::Prepared lyricsMarkup; + QHash annotationBodies; + QSet expandedAnnotations; + QHash annotationJobs; QString preEdit; NetworkJob* job; UltimateLyricsProvider* currentProv; diff --git a/gui/settings.cpp b/gui/settings.cpp index ed59f6ce1..d357acbaa 100644 --- a/gui/settings.cpp +++ b/gui/settings.cpp @@ -291,6 +291,11 @@ bool Settings::storeLyricsInMpdDir() return cfg.get("storeLyricsInMpdDir", false); } +bool Settings::lyricsAnnotations() +{ + return cfg.get("lyricsAnnotations", true); +} + QString Settings::coverFilename() { QString name = cfg.get("coverFilename", QString()); @@ -844,6 +849,11 @@ void Settings::saveStoreLyricsInMpdDir(bool v) cfg.set("storeLyricsInMpdDir", v); } +void Settings::saveLyricsAnnotations(bool v) +{ + cfg.set("lyricsAnnotations", v); +} + void Settings::saveCoverFilename(const QString& v) { cfg.set("coverFilename", v); diff --git a/gui/settings.h b/gui/settings.h index 8df56fb48..35568501a 100644 --- a/gui/settings.h +++ b/gui/settings.h @@ -60,6 +60,7 @@ class Settings { bool stopOnExit(); bool storeCoversInMpdDir(); bool storeLyricsInMpdDir(); + bool lyricsAnnotations(); QString coverFilename(); int sidebar(); QSet composerGenres(); @@ -168,6 +169,7 @@ class Settings { void saveShowPopups(bool v); void saveStoreCoversInMpdDir(bool v); void saveStoreLyricsInMpdDir(bool v); + void saveLyricsAnnotations(bool v); void saveCoverFilename(const QString& v); void saveLibraryArtistImage(bool v); void saveSidebar(int v); From 012a3cdf220f427a58a8c16f981e54eea7f40218 Mon Sep 17 00:00:00 2001 From: Bernd Steinhauser Date: Sun, 26 Jul 2026 11:11:20 +0200 Subject: [PATCH 16/16] context: improve formatting of section markers --- context/lyricsmarkup.cpp | 44 ++++++++++++++++++++++++++++++++-------- context/lyricsmarkup.h | 5 ++++- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/context/lyricsmarkup.cpp b/context/lyricsmarkup.cpp index 2ce240a71..45e2ccf8b 100644 --- a/context/lyricsmarkup.cpp +++ b/context/lyricsmarkup.cpp @@ -70,16 +70,44 @@ static QString referentId(const QString& tag) return referent.hasMatch() ? referent.captured(1) : QString(); } -QString styleSectionHeaders(const QString& lyrics) +// What a line marks a section with, or a null string if the line is not a section marker. A marker +// is short; a bracketed line longer than that is a lyric rather than structure. +static QString sectionMarker(const QString& line) +{ + static const QRegularExpression constHeader(QLatin1String("^\\s*\\[([^\\]]*)\\]\\s*$")); + static const QRegularExpression constTag(QLatin1String("<[^>]*>")); + + QRegularExpressionMatch match = constHeader.match(line); + + if (!match.hasMatch()) { + return QString(); + } + + // Measured without the markup in it, so that an emphasised or annotated marker is judged by + // what the reader actually sees. + QString marker = match.captured(1); + int length = QString(marker).remove(constTag).length(); + + return length >= 1 && length <= 80 ? marker : QString(); +} + +// The marker on a line prepare() has already turned into markup. The only tags left in it are the +// ones we chose to keep, so it is emphasised as it stands rather than escaped back into view. +static QString styleSectionHeader(const QString& line) { - static const QRegularExpression constHeader(QLatin1String("^\\s*\\[([^\\]]{1,80})\\]\\s*$")); + QString marker = sectionMarker(line); + return marker.isNull() ? line : QLatin1String("[") + marker + QLatin1String("]"); +} + +QString styleSectionHeaders(const QString& lyrics) +{ QStringList lines = lyrics.split(QLatin1Char('\n')); for (QString& line : lines) { - QRegularExpressionMatch match = constHeader.match(line); - if (match.hasMatch()) { - line = QLatin1String("[") + match.captured(1).toHtmlEscaped() + QLatin1String("]"); + QString marker = sectionMarker(line); + if (!marker.isNull()) { + line = QLatin1String("[") + marker.toHtmlEscaped() + QLatin1String("]"); } } @@ -105,7 +133,7 @@ Prepared prepare(const QString& providerText) QChar ch = source.at(pos); if (QLatin1Char('\n') == ch) { - prepared.lines.append(styleSectionHeaders(line)); + prepared.lines.append(styleSectionHeader(line)); line.clear(); pos++; continue; @@ -131,7 +159,7 @@ Prepared prepare(const QString& providerText) pos = close + 1; if (QLatin1String("br") == name) { - prepared.lines.append(styleSectionHeaders(line)); + prepared.lines.append(styleSectionHeader(line)); line.clear(); } else if (QLatin1String("script") == name || QLatin1String("style") == name) { @@ -167,7 +195,7 @@ Prepared prepare(const QString& providerText) // Everything else is page furniture. Drop the tag, but keep whatever text it wraps. } - prepared.lines.append(styleSectionHeaders(line)); + prepared.lines.append(styleSectionHeader(line)); return prepared; } diff --git a/context/lyricsmarkup.h b/context/lyricsmarkup.h index 421b34039..fd52ac467 100644 --- a/context/lyricsmarkup.h +++ b/context/lyricsmarkup.h @@ -91,7 +91,10 @@ QString render(const Prepared& prepared, const QHash& bodies, * their own, so that they read as structure rather than as part of the lyric. Only a line that is * nothing but a marker qualifies; bracketed asides within a line ('[sic]', '[?]') are left alone. * - * @param lyrics One or more newline separated lines. + * For the plain text path - prepare() emphasises the markers it comes across itself, keeping the + * markup they carry. + * + * @param lyrics One or more newline separated lines of plain text. * * @return The lines, with any section markers emphasised. */