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/lyrics_providers.xml b/context/lyrics_providers.xml index a82171936..abd057db5 100644 --- a/context/lyrics_providers.xml +++ b/context/lyrics_providers.xml @@ -23,42 +23,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -77,27 +41,6 @@ - - - - - - - - - - - - @@ -109,7 +52,7 @@ - + + + + + + - - @@ -144,16 +95,6 @@ - - - - - - - - - - @@ -170,29 +111,11 @@ - - - - - - + + - + - - - - @@ -202,28 +125,29 @@ - + + - + - - + + - - - - - - - + - - + - + + + + + @@ -233,47 +157,15 @@ - - - - - - - - - - - - - - - - - - - - + + + + + @@ -285,6 +177,13 @@ + + + + + @@ -318,13 +217,6 @@ - - - - - - - diff --git a/context/lyricsmarkup.cpp b/context/lyricsmarkup.cpp new file mode 100644 index 000000000..45e2ccf8b --- /dev/null +++ b/context/lyricsmarkup.cpp @@ -0,0 +1,234 @@ +/* + * 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(); +} + +// 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) +{ + 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) { + QString marker = sectionMarker(line); + if (!marker.isNull()) { + line = QLatin1String("[") + marker.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(styleSectionHeader(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(styleSectionHeader(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(styleSectionHeader(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..fd52ac467 --- /dev/null +++ b/context/lyricsmarkup.h @@ -0,0 +1,105 @@ +/* + * 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. + * + * 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. + */ +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 32dca9ec5..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" @@ -46,7 +47,10 @@ #include #include #include +#include +#include #include +#include #include #include #include @@ -60,6 +64,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 +86,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) { @@ -99,7 +115,7 @@ static inline QString fixNewLines(const QString& o) } SongView::SongView(QWidget* p) - : View(p, QStringList() << tr("Lyrics") << tr("Information") << tr("Metadata")), scrollTimer(nullptr), songPos(0), currentProvider(-1), currentRequest(0), mode(Mode_Display), job(nullptr), currentProv(nullptr), lyricsNeedsUpdating(true), infoNeedsUpdating(true), metadataNeedsUpdating(true) + : View(p, QStringList() << tr("Lyrics") << tr("Information") << tr("Metadata")), scrollTimer(nullptr), songPos(0), currentProvider(-1), currentRequest(0), mode(Mode_Display), sourceProvidesMarkup(false), job(nullptr), currentProv(nullptr), lyricsNeedsUpdating(true), infoNeedsUpdating(true), metadataNeedsUpdating(true) { scrollAction = ActionCollection::get()->createAction("scrolllyrics", tr("Scroll Lyrics"), Icons::self()->downIcon); refreshAction = ActionCollection::get()->createAction("refreshlyrics", tr("Refresh Lyrics"), Icons::self()->refreshIcon); @@ -112,7 +128,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 +177,7 @@ void SongView::update() if (cacheExists) { QFile::remove(cacheName); } + QFile::remove(sourceInfoFileName(currentSong)); break; default: return; @@ -193,6 +210,7 @@ void SongView::search() if (!cacheName.isEmpty() && QFile::exists(cacheName)) { QFile::remove(cacheName); } + QFile::remove(sourceInfoFileName(currentSong)); update(dlg.song(), true); } } @@ -226,6 +244,7 @@ void SongView::del() if (!mpdName.isEmpty() && QFile::exists(mpdName)) { QFile::remove(mpdName); } + QFile::remove(sourceInfoFileName(currentSong)); } void SongView::showContextMenu(const QPoint& pos) @@ -297,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(); @@ -378,14 +403,19 @@ void SongView::loadLyricsFromFile() QString tagLyrics = Tags::readLyrics(songFile); if (!tagLyrics.isEmpty()) { - text->setText(fixNewLines(tagLyrics)); + lyricsPlain = tagLyrics; + lyricsSource = QUrl(); + lyricsSourceName = QString(); + sourceProvidesMarkup = false; + clearAnnotations(); + 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 +432,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; @@ -704,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); } @@ -721,6 +758,11 @@ void SongView::abort() job = nullptr; } currentProvider = -1; + lyricsPlain = QString(); + lyricsSource = QUrl(); + lyricsSourceName = QString(); + sourceProvidesMarkup = false; + clearAnnotations(); if (currentProv) { currentProv->abort(); currentProv = nullptr; @@ -805,7 +847,12 @@ void SongView::downloadFinished() QTextStream str(reply->actualJob()); QString lyrics = str.readAll(); if (!lyrics.isEmpty()) { - text->setText(fixNewLines(lyrics)); + lyricsPlain = lyrics; + lyricsSource = QUrl(); + lyricsSourceName = QString(); + sourceProvidesMarkup = false; + clearAnnotations(); + renderLyrics(); cancelJobAction->setEnabled(false); hideSpinner(); return; @@ -817,7 +864,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 +887,209 @@ 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(); + 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(); setMode(Mode_Display); } } } +void SongView::renderLyrics(bool keepPosition) +{ + 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() +{ + 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()); + if (!lyricsProviderMarkup.isEmpty()) { + obj.insert(QLatin1String("markup"), lyricsProviderMarkup); + } + + 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. Looking the provider up + // also tells us how these lyrics may be rendered, which we cannot ask currentProv about as + // nothing was fetched. + for (UltimateLyricsProvider* provider : UltimateLyrics::self()->getProviders()) { + if (provider->getName() == lyricsSourceName) { + lyricsSourceName = provider->displayName(); + sourceProvidesMarkup = provider->providesMarkup(); + 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) { QFile f(fileName); @@ -860,7 +1100,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 +1132,11 @@ void SongView::getLyrics() else { text->setText(QString()); currentProvider = -1; + lyricsPlain = QString(); + lyricsSource = QUrl(); + lyricsSourceName = QString(); + sourceProvidesMarkup = false; + clearAnnotations(); // Set lyrics file anyway - so that editing is enabled! lyricsFile = Settings::self()->storeLyricsInMpdDir() && !currentSong.isNonMPD() ? mpdLyricsFilePath(currentSong) @@ -922,17 +1167,26 @@ 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(); + sourceProvidesMarkup = false; + clearAnnotations(); + 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..a1d1cedb2 100644 --- a/context/songview.h +++ b/context/songview.h @@ -25,7 +25,11 @@ #define SONG_VIEW_H #include "config.h" +#include "lyricsmarkup.h" #include "view.h" +#include +#include +#include #include class UltimateLyricsProvider; @@ -52,6 +56,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 +71,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(); @@ -83,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(); @@ -98,15 +104,52 @@ 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. + * + * @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(bool keepPosition = false); + + /** + * 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); + + /** + * 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. * * @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 +162,15 @@ private Q_SLOTS: Action* delAction; Mode mode; QString lyricsFile; + QString lyricsPlain; + 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/context/ultimatelyrics.cpp b/context/ultimatelyrics.cpp index 8c98514de..423e7fc71 100644 --- a/context/ultimatelyrics.cpp +++ b/context/ultimatelyrics.cpp @@ -61,10 +61,22 @@ 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()); + } + else if (attr.hasAttribute("unescape")) { + ret << UltimateLyricsProvider::RuleItem(UltimateLyricsProvider::RuleItem::Unescape, attr.value("unescape").toString()); } } reader->skipCurrentElement(); @@ -81,6 +93,8 @@ 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()); + scraper->setProvidesMarkup(QLatin1String("true") == attributes.value("markup")); while (!reader->atEnd()) { reader->readNext(); @@ -184,7 +198,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 91b832ea9..e7ecc4d4a 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+).*>"); @@ -138,6 +138,156 @@ 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. +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 +316,21 @@ 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; + case UltimateLyricsProvider::RuleItem::Unescape: + if (QLatin1String("json") == item.begin) { + content = jsonUnescape(content); + } + break; } } } @@ -178,11 +338,19 @@ 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; + case UltimateLyricsProvider::RuleItem::Unescape: + // Not meaningful as an exclusion - ignored. + break; } } } @@ -201,7 +369,7 @@ static bool tryWithoutThe(const Song& s) } UltimateLyricsProvider::UltimateLyricsProvider() - : enabled(true), relevance(0) + : enabled(true), relevance(0), markup(false) { } @@ -218,72 +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()); - } - - 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); + 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); } - QNetworkRequest req(url); + 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; + } + + 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; @@ -300,6 +465,7 @@ void UltimateLyricsProvider::abort() } requests.clear(); songs.clear(); + sourceUrls.clear(); } void UltimateLyricsProvider::wikiMediaSearchResponse() @@ -314,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; } @@ -345,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()); } } @@ -361,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; } @@ -374,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() @@ -387,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); @@ -394,7 +564,7 @@ void UltimateLyricsProvider::lyricsFetched() fetchInfo(id, song, true); } else { - emit lyricsReady(id, QString()); + emit lyricsReady(id, QString(), QUrl()); } return; } @@ -413,7 +583,7 @@ void UltimateLyricsProvider::lyricsFetched() fetchInfo(id, song, true); } else { - emit lyricsReady(id, QString()); + emit lyricsReady(id, QString(), QUrl()); } return; } @@ -449,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 80378dbb9..f56410f7d 100644 --- a/context/ultimatelyricsprovider.h +++ b/context/ultimatelyricsprovider.h @@ -30,6 +30,7 @@ #include #include #include +#include class NetworkJob; @@ -42,12 +43,34 @@ class UltimateLyricsProvider : public QObject { UltimateLyricsProvider(); ~UltimateLyricsProvider() override; - typedef QPair RuleItem; + struct RuleItem { + enum Type { + XmlTag, // + Range, // + Container, // + Unescape // + }; + + 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; 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; } + // Set for providers whose output carries enough structure to be worth rendering as more than + // plain text. Anything that inspects what a provider returned is kept behind this, so that + // providers we cannot check stay untouched. + void setProvidesMarkup(bool m) { markup = m; } + bool providesMarkup() const { return markup; } 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); } @@ -63,7 +86,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(); @@ -73,15 +96,19 @@ 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; + bool markup; QList urlFormats; QList extractRules; QList excludeRules; 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", diff --git a/gui/settings.cpp b/gui/settings.cpp index 76ba25eee..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()); @@ -340,9 +345,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() << "genius.com"); } QStringList Settings::wikipediaLangs() @@ -846,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);