From 0062384badca9bb9a542c0def58a424dc076b993 Mon Sep 17 00:00:00 2001 From: Martchus Date: Sun, 9 Oct 2016 22:41:34 +0200 Subject: [PATCH] Allow retrieving lyrics from LyricsWikia --- application/settings.cpp | 3 +- dbquery/dbquery.cpp | 123 ++++++++------- dbquery/dbquery.h | 39 ++++- dbquery/lyricswikia.cpp | 195 +++++++++++++++++++++--- dbquery/lyricswikia.h | 11 +- dbquery/musicbrainz.cpp | 257 +++++++++++++++++--------------- dbquery/musicbrainz.h | 10 +- gui/dbquerywidget.cpp | 163 +++++++++++++++----- gui/dbquerywidget.h | 7 +- gui/dbquerywidget.ui | 10 +- gui/mainwindow.cpp | 5 +- gui/notificationlabel.cpp | 7 +- translations/tageditor_de_DE.ts | 163 ++++++++++++++------ translations/tageditor_en_US.ts | 163 ++++++++++++++------ 14 files changed, 800 insertions(+), 356 deletions(-) diff --git a/application/settings.cpp b/application/settings.cpp index ba3c156..a569274 100644 --- a/application/settings.cpp +++ b/application/settings.cpp @@ -265,7 +265,8 @@ KnownFieldModel &dbQueryFields() << KnownFieldModel::mkItem(KnownField::Album) << KnownFieldModel::mkItem(KnownField::Year) << KnownFieldModel::mkItem(KnownField::Genre) - << KnownFieldModel::mkItem(KnownField::Cover, Qt::Unchecked)); + << KnownFieldModel::mkItem(KnownField::Cover, Qt::Unchecked) + << KnownFieldModel::mkItem(KnownField::Lyrics, Qt::Unchecked)); return v; } diff --git a/dbquery/dbquery.cpp b/dbquery/dbquery.cpp index 5adc8ea..9e938be 100644 --- a/dbquery/dbquery.cpp +++ b/dbquery/dbquery.cpp @@ -78,6 +78,8 @@ TagValue QueryResultsModel::fieldValue(int row, KnownField knownField) const return tagValue; } break; + case KnownField::Lyrics: + returnValue(lyrics); default: ; } @@ -195,18 +197,51 @@ const QByteArray *QueryResultsModel::cover(const QModelIndex &index) const /*! * \brief Fetches the cover the specified \a index. - * \returns True if the cover is immidiately available; false is the cover is fetched asynchronously. + * \returns + * - true if the cover is immidiately available or an error occurs immidiately + * - and false if the cover will be fetched asynchronously. * * If the cover is fetched asynchronously the coverAvailable() signal is emitted, when the cover - * is available. + * becomes available. * * The resultsAvailable() signal is emitted if errors have been added to errorList(). */ -bool QueryResultsModel::fetchCover(const QModelIndex &) +bool QueryResultsModel::fetchCover(const QModelIndex &index) { - m_errorList << tr("Fetching the cover is not implemented for the selected provider."); + Q_UNUSED(index) + m_errorList << tr("Fetching cover is not implemented for this provider"); emit resultsAvailable(); - return false; + return true; +} + +const QString *QueryResultsModel::lyrics(const QModelIndex &index) const +{ + if(!index.parent().isValid() && index.row() < m_results.size()) { + const QString &lyrics = m_results.at(index.row()).lyrics; + if(!lyrics.isEmpty()) { + return &lyrics; + } + } + return nullptr; +} + +/*! + * \brief Fetches the lyrics the specified \a index. + * \returns + * - true if the lyrics are immidiately available or an error occurs immidiately + * - and false if the lyrics will be fetched asynchronously. + * + * If the lyrics are fetched asynchronously the lyricsAvailable() signal is emitted, when the lyrics + * become available. + * + * The resultsAvailable() signal is emitted if errors have been added to errorList(). + */ +bool QueryResultsModel::fetchLyrics(const QModelIndex &index) +{ + Q_UNUSED(index) + m_errorList << tr("Fetching lyrics is not implemented for this provider"); + emit resultsAvailable(); + return true; } /*! @@ -216,7 +251,7 @@ bool QueryResultsModel::fetchCover(const QModelIndex &) HttpResultsModel::HttpResultsModel(SongDescription &&initialSongDescription, QNetworkReply *reply) : m_initialDescription(initialSongDescription) { - addReply(reply); + addReply(reply, this, &HttpResultsModel::handleInitialReplyFinished); } /*! @@ -227,77 +262,61 @@ HttpResultsModel::~HttpResultsModel() qDeleteAll(m_replies); } -/*! - * \brief Helper to copy a property from one QObject to another. - * \remarks Used to transfer reply properties to new reply in case a second reply is required. - */ -void copyProperty(const char *property, const QObject *from, QObject *to) -{ - to->setProperty(property, from->property(property)); -} - /*! * \brief Evaluates request results. * \remarks Calls parseResults() if the requested data is available. Handles errors/redirections otherwise. */ -void HttpResultsModel::handleReplyFinished() +void HttpResultsModel::handleInitialReplyFinished() { auto *reply = static_cast(sender()); + QByteArray data; + if(auto *newReply = evaluateReplyResults(reply, data, false)) { + addReply(newReply, this, &HttpResultsModel::handleInitialReplyFinished); + } else { + if(!data.isEmpty()) { + parseInitialResults(data); + } + setResultsAvailable(true); // update status, emit resultsAvailable() + } +} + +QNetworkReply *HttpResultsModel::evaluateReplyResults(QNetworkReply *reply, QByteArray &data, bool alwaysFollowRedirection) +{ + // delete reply (later) + reply->deleteLater(); + m_replies.removeAll(reply); + if(reply->error() == QNetworkReply::NoError) { QVariant redirectionTarget = reply->attribute(QNetworkRequest::RedirectionTargetAttribute); if(!redirectionTarget.isNull()) { // there's a redirection available // -> resolve new URL const QUrl newUrl = reply->url().resolved(redirectionTarget.toUrl()); - // -> always follow when retrieving cover art, otherwise ask user - bool follow = reply->property("coverArt").toBool(); - if(!follow) { + // -> ask user whether to follow redirection unless alwaysFollowRedirection is true + if(!alwaysFollowRedirection) { const QString message = tr("

Do you want to redirect form %1 to %2?

").arg( reply->url().toString(), newUrl.toString()); - follow = QMessageBox::question(nullptr, tr("Search"), message, QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes; + alwaysFollowRedirection = QMessageBox::question(nullptr, tr("Search"), message, QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes; } - if(follow) { - QNetworkReply *newReply = networkAccessManager().get(QNetworkRequest(newUrl)); - // retain possible assigned dynamic properties (TODO: implement it in a better way) - copyProperty("coverArt", reply, newReply); - copyProperty("albumId", reply, newReply); - copyProperty("row", reply, newReply); - addReply(newReply); - return; + if(alwaysFollowRedirection) { + return networkAccessManager().get(QNetworkRequest(newUrl)); } else { m_errorList << tr("Redirection to: ") + newUrl.toString(); + return nullptr; } } else { - QByteArray data = reply->readAll(); + if((data = reply->readAll()).isEmpty()) { + m_errorList << tr("Server replied no data."); + } #ifdef DEBUG_BUILD - std::cerr << "Results from HTTP query:" << std::endl; - std::cerr << data.data() << std::endl; + cerr << "Results from HTTP query:" << endl; + cerr << data.data() << endl; #endif - parseResults(reply, data); } } else { m_errorList << reply->errorString(); } - - // delete reply - reply->deleteLater(); - m_replies.removeAll(reply); - - // update status, emit resultsAvailable() - setResultsAvailable(true); -} - -/*! - * \brief Adds a reply. - * \remarks Called within c'tor and handleReplyFinished() in case of redirection. Might be called when subclassing to do further requests. - */ -void HttpResultsModel::addReply(QNetworkReply *reply) -{ - m_replies << reply; -#ifdef DEBUG_BUILD - std::cerr << "HTTP query: " << reply->url().toString().toLocal8Bit().data() << std::endl; -#endif - connect(reply, &QNetworkReply::finished, this, &HttpResultsModel::handleReplyFinished); + return nullptr; } /*! diff --git a/dbquery/dbquery.h b/dbquery/dbquery.h index 57ca5fa..d13d263 100644 --- a/dbquery/dbquery.h +++ b/dbquery/dbquery.h @@ -5,6 +5,11 @@ #include +#ifdef DEBUG_BUILD +# include +# include +#endif + QT_FORWARD_DECLARE_CLASS(QNetworkReply) namespace Media { @@ -28,6 +33,7 @@ struct SongDescription unsigned int totalTracks; unsigned int disk; QByteArray cover; + QString lyrics; }; class QueryResultsModel : public QAbstractTableModel @@ -58,11 +64,14 @@ public: int columnCount(const QModelIndex &parent) const; const QByteArray *cover(const QModelIndex &index) const; virtual bool fetchCover(const QModelIndex &index); + const QString *lyrics(const QModelIndex &index) const; + virtual bool fetchLyrics(const QModelIndex &index); virtual void abort(); signals: void resultsAvailable(); void coverAvailable(const QModelIndex &index); + void lyricsAvailable(const QModelIndex &index); protected: QueryResultsModel(QObject *parent = nullptr); @@ -104,17 +113,41 @@ public: protected: HttpResultsModel(SongDescription &&initialSongDescription, QNetworkReply *reply); - void addReply(QNetworkReply *reply); - virtual void parseResults(QNetworkReply *reply, const QByteArray &data) = 0; + template void addReply(QNetworkReply *reply, Object object, Function handler); + template void addReply(QNetworkReply *reply, Function handler); + virtual void parseInitialResults(const QByteArray &data) = 0; + QNetworkReply *evaluateReplyResults(QNetworkReply *reply, QByteArray &data, bool alwaysFollowRedirection = false); private slots: - void handleReplyFinished(); + void handleInitialReplyFinished(); protected: QList m_replies; const SongDescription m_initialDescription; }; +template +inline void HttpResultsModel::addReply(QNetworkReply *reply, Object object, Function handler) +{ + (m_replies << reply), connect(reply, &QNetworkReply::finished, object, handler); +#ifdef DEBUG_BUILD + std::cerr << "HTTP query: " << reply->url().toString().toLocal8Bit().data() << std::endl; +#endif +} + +/*! + * \brief Adds a reply. + * \remarks Called within c'tor and handleReplyFinished() in case of redirection. Might be called when subclassing to do further requests. + */ +template +inline void HttpResultsModel::addReply(QNetworkReply *reply, Function handler) +{ + (m_replies << reply), connect(reply, &QNetworkReply::finished, handler); +#ifdef DEBUG_BUILD + std::cerr << "HTTP query: " << reply->url().toString().toLocal8Bit().data() << std::endl; +#endif +} + QueryResultsModel *queryMusicBrainz(SongDescription &&songDescription); QueryResultsModel *queryLyricsWikia(SongDescription &&songDescription); QNetworkReply *queryCoverArtArchive(const QString &albumId); diff --git a/dbquery/lyricswikia.cpp b/dbquery/lyricswikia.cpp index b3c3ee1..9f80aca 100644 --- a/dbquery/lyricswikia.cpp +++ b/dbquery/lyricswikia.cpp @@ -6,27 +6,49 @@ #include #include #include +#include +#include + +#include using namespace std; +using namespace std::placeholders; using namespace Utility; namespace QtGui { +static const QString defaultLyricsWikiaUrl(QStringLiteral("https://lyrics.wikia.com")); + LyricsWikiaResultsModel::LyricsWikiaResultsModel(SongDescription &&initialSongDescription, QNetworkReply *reply) : HttpResultsModel(move(initialSongDescription), reply) {} -void LyricsWikiaResultsModel::parseResults(QNetworkReply *reply, const QByteArray &data) +bool LyricsWikiaResultsModel::fetchLyrics(const QModelIndex &index) { - Q_UNUSED(reply) + if(!index.parent().isValid() && index.row() < m_results.size()) { + SongDescription &desc = m_results[index.row()]; + if(!desc.lyrics.isEmpty()) { + // lyrics already available -> nothing to do + } else if(!desc.artist.isEmpty() && !desc.title.isEmpty()) { + auto *reply = requestSongDetails(desc); + addReply(reply, bind(&LyricsWikiaResultsModel::handleSongDetailsFinished, this, reply, index.row())); + return false; + } else { + m_errorList << tr("Unable to fetch lyrics: Artist or title is unknown."); + emit resultsAvailable(); + } + } + return true; +} +void LyricsWikiaResultsModel::parseInitialResults(const QByteArray &data) +{ // prepare parsing MusicBrainz meta data beginResetModel(); m_results.clear(); - m_reader.addData(data); + QXmlStreamReader xmlReader(data); // parse XML tree -#define xmlReader m_reader #include children { iftag("getArtistResponse") { @@ -53,18 +75,18 @@ void LyricsWikiaResultsModel::parseResults(QNetworkReply *reply, const QByteArra } else_skip } } else_skip - // need to filter results manually because the filtering provided by Lyrica Wiki API doesn't work - if((m_initialDescription.album.isEmpty() || m_initialDescription.album == album) - && (m_initialDescription.year.isEmpty() || m_initialDescription.year == year) - && (!m_initialDescription.totalTracks || m_initialDescription.totalTracks == static_cast(songs.size()))) { - for(SongDescription &song : songs) { - if((m_initialDescription.title.isEmpty() || m_initialDescription.title == song.title) - && (!m_initialDescription.track || m_initialDescription.track == static_cast(songs.size()))) { - song.album = album; - song.year = year; - song.totalTracks = static_cast(songs.size()); - m_results << move(song); - } + } + // need to filter results manually because the filtering provided by Lyrica Wiki API doesn't work + if((m_initialDescription.album.isEmpty() || m_initialDescription.album == album) + && (m_initialDescription.year.isEmpty() || m_initialDescription.year == year) + && (!m_initialDescription.totalTracks || m_initialDescription.totalTracks == static_cast(songs.size()))) { + for(SongDescription &song : songs) { + if((m_initialDescription.title.isEmpty() || m_initialDescription.title == song.title) + && (!m_initialDescription.track || m_initialDescription.track == song.track)) { + song.album = album; + song.year = year; + song.totalTracks = static_cast(songs.size()); + m_results << move(song); } } } @@ -80,24 +102,155 @@ void LyricsWikiaResultsModel::parseResults(QNetworkReply *reply, const QByteArra #include // check for parsing errors - switch(m_reader.error()) { + switch(xmlReader.error()) { case QXmlStreamReader::NoError: case QXmlStreamReader::PrematureEndOfDocumentError: break; default: - m_errorList << m_reader.errorString(); + m_errorList << xmlReader.errorString(); } // promote changes endResetModel(); } +QNetworkReply *LyricsWikiaResultsModel::requestSongDetails(const SongDescription &songDescription) +{ + // compose URL + QUrl url((Settings::lyricsWikiaUrl().isEmpty() ? defaultLyricsWikiaUrl : Settings::lyricsWikiaUrl()) + QStringLiteral("/api.php")); + QUrlQuery query; + query.addQueryItem(QStringLiteral("func"), QStringLiteral("getSong")); + query.addQueryItem(QStringLiteral("action"), QStringLiteral("lyrics")); + query.addQueryItem(QStringLiteral("fmt"), QStringLiteral("xml")); + query.addQueryItem(QStringLiteral("fixXML"), QString()); + query.addQueryItem(QStringLiteral("artist"), songDescription.artist); + query.addQueryItem(QStringLiteral("title"), songDescription.title); + if(!songDescription.album.isEmpty()) { + // specifying album seems to have no effect but also don't hurt + query.addQueryItem(QStringLiteral("album"), songDescription.album); + } + url.setQuery(query); + + return Utility::networkAccessManager().get(QNetworkRequest(url)); +} + +void LyricsWikiaResultsModel::handleSongDetailsFinished(QNetworkReply *reply, int row) +{ + QByteArray data; + if(auto *newReply = evaluateReplyResults(reply, data, true)) { + addReply(newReply, bind(&LyricsWikiaResultsModel::handleSongDetailsFinished, this, newReply, row)); + } else if(!data.isEmpty()) { + parseSongDetails(row, data); + } +} + +void LyricsWikiaResultsModel::parseSongDetails(int row, const QByteArray &data) +{ + // find associated result/desc + if(row >= m_results.size()) { + m_errorList << tr("Internal error: context for song details reply invalid"); + setResultsAvailable(true); + return; + } + const SongDescription &assocDesc = m_results.at(row); + + QUrl parsedUrl; + + // parse XML tree + QXmlStreamReader xmlReader(data); +#include + children { + iftag("LyricsResult") { + SongDescription parsedDesc; + children { + iftag("artist") { + parsedDesc.artist = text; + } eliftag("song") { + parsedDesc.title = text; + } eliftag("url") { + parsedUrl = text; + } else_skip + } + + // verify whether parsed results match what was requested + if(!parsedUrl.isEmpty() && assocDesc.title == parsedDesc.title && assocDesc.artist == parsedDesc.artist) { + break; + } else { + parsedUrl.clear(); + } + } else_skip + } +#include + + // check for parsing errors + switch(xmlReader.error()) { + case QXmlStreamReader::NoError: + case QXmlStreamReader::PrematureEndOfDocumentError: + break; + default: + m_errorList << tr("Unable to parse song details: ") + xmlReader.errorString(); + setResultsAvailable(true); + if(parsedUrl.isEmpty()) { + return; // don't complain about missing URL when the XML isn't even valid + } + } + + // requets lyrics (seem to be incomplete in XML response, so just get the regular Wiki page) + if(parsedUrl.isEmpty()) { + m_errorList << tr("Song details requested for %1/%2 do not contain URL for Wiki page").arg(assocDesc.artist, assocDesc.title); + setResultsAvailable(true); + return; + } + // do not use parsed URL directly to avoid unintended requests + QUrl requestUrl(Settings::lyricsWikiaUrl().isEmpty() ? defaultLyricsWikiaUrl : Settings::lyricsWikiaUrl()); + requestUrl.setPath(parsedUrl.path()); + auto *reply = Utility::networkAccessManager().get(QNetworkRequest(parsedUrl)); + addReply(reply, bind(&LyricsWikiaResultsModel::handleLyricsReplyFinished, this, reply, row)); +} + +void LyricsWikiaResultsModel::handleLyricsReplyFinished(QNetworkReply *reply, int row) +{ + QByteArray data; + if(auto *newReply = evaluateReplyResults(reply, data, true)) { + addReply(newReply, bind(&LyricsWikiaResultsModel::handleLyricsReplyFinished, this, newReply, row)); + } else { + if(!data.isEmpty()) { + parseLyricsResults(row, data); + } + setResultsAvailable(true); + } +} + +void LyricsWikiaResultsModel::parseLyricsResults(int row, const QByteArray &data) +{ + // find associated result/desc + if(row >= m_results.size()) { + m_errorList << tr("Internal error: context for LyricsWikia page reply invalid"); + setResultsAvailable(true); + return; + } + SongDescription &assocDesc = m_results[row]; + + // parse lyrics from HTML + QString html(data); + int lyricsStart = html.indexOf("
"); + if(!lyricsStart) { + m_errorList << tr("Song details requested for %1/%2 do not contain lyrics").arg(assocDesc.artist, assocDesc.title); + setResultsAvailable(true); + return; + } + int lyricsEnd = html.indexOf("
", lyricsStart); + QTextDocument textDoc; + textDoc.setHtml(html.mid(lyricsStart, (lyricsEnd > lyricsStart) ? (lyricsEnd - lyricsStart) : -1)); + + assocDesc.lyrics = textDoc.toPlainText(); + emit lyricsAvailable(index(row, 0)); +} + QueryResultsModel *queryLyricsWikia(SongDescription &&songDescription) { - static QString defaultUrl(QStringLiteral("https://lyrics.wikia.com/api.php")); - // compose URL - QUrl url(Settings::lyricsWikiaUrl().isEmpty() ? defaultUrl : Settings::lyricsWikiaUrl()); + QUrl url((Settings::lyricsWikiaUrl().isEmpty() ? defaultLyricsWikiaUrl : Settings::lyricsWikiaUrl()) + QStringLiteral("/api.php")); QUrlQuery query; query.addQueryItem(QStringLiteral("func"), QStringLiteral("getArtist")); query.addQueryItem(QStringLiteral("fmt"), QStringLiteral("xml")); diff --git a/dbquery/lyricswikia.h b/dbquery/lyricswikia.h index 443fe2e..bfb25ae 100644 --- a/dbquery/lyricswikia.h +++ b/dbquery/lyricswikia.h @@ -3,7 +3,7 @@ #include "./dbquery.h" -#include +#include namespace QtGui { @@ -13,12 +13,17 @@ class LyricsWikiaResultsModel : public HttpResultsModel public: LyricsWikiaResultsModel(SongDescription &&initialSongDescription, QNetworkReply *reply); + bool fetchLyrics(const QModelIndex &index); protected: - void parseResults(QNetworkReply *reply, const QByteArray &data); + void parseInitialResults(const QByteArray &data); private: - QXmlStreamReader m_reader; + QNetworkReply *requestSongDetails(const SongDescription &songDescription); + void handleSongDetailsFinished(QNetworkReply *reply, int row); + void parseSongDetails(int row, const QByteArray &data); + void handleLyricsReplyFinished(QNetworkReply *reply, int row); + void parseLyricsResults(int row, const QByteArray &data); }; diff --git a/dbquery/musicbrainz.cpp b/dbquery/musicbrainz.cpp index 9d55ff0..81090cb 100644 --- a/dbquery/musicbrainz.cpp +++ b/dbquery/musicbrainz.cpp @@ -8,8 +8,12 @@ #include #include #include +#include + +#include using namespace std; +using namespace std::placeholders; using namespace Utility; namespace QtGui { @@ -32,147 +36,158 @@ bool MusicBrainzResultsModel::fetchCover(const QModelIndex &index) desc.cover = m_coverData.at(desc.albumId); } catch(const out_of_range &) { // request the cover art - QNetworkReply *reply = queryCoverArtArchive(desc.albumId); - addReply(reply); - reply->setProperty("coverArt", true); - reply->setProperty("albumId", desc.albumId); - reply->setProperty("row", index.row()); + auto *reply = queryCoverArtArchive(desc.albumId); + addReply(reply, bind(&MusicBrainzResultsModel::handleCoverReplyFinished, this, reply, desc.albumId, index.row())); setFetchingCover(true); return false; } } else { - m_errorList << tr("Unable to fetch cover: Album ID is unknown."); + m_errorList << tr("Unable to fetch cover: Album ID unknown"); emit resultsAvailable(); } } return true; } -void MusicBrainzResultsModel::parseResults(QNetworkReply *reply, const QByteArray &data) +void MusicBrainzResultsModel::parseInitialResults(const QByteArray &data) { - if(reply->property("coverArt").toBool()) { - // add cover -> determine album ID and row - bool ok; - QString albumId = reply->property("albumId").toString(); - int row = reply->property("row").toInt(&ok); - if(!albumId.isEmpty() && ok && row < m_results.size()) { + // prepare parsing MusicBrainz meta data + beginResetModel(); + m_results.clear(); + QXmlStreamReader xmlReader(data); + + // parse XML tree +#include + children { + iftag("metadata") { + children { + iftag("recording-list") { + children { + iftag("recording") { + SongDescription currentDescription; + children { + iftag("title") { + currentDescription.title = text; + } eliftag("artist-credit") { + children { + iftag("name-credit") { + children { + iftag("artist") { + children { + iftag("name") { + currentDescription.artist = text; + } else_skip + } + } else_skip + } + } else_skip + } + } eliftag("release-list") { + children { + iftag("release") { + if(currentDescription.albumId.isEmpty()) { + currentDescription.albumId = attribute("id").toString(); + } + children { + iftag("title") { + currentDescription.album = text; + } eliftag("date") { + currentDescription.year = text; + } eliftag("medium-list") { + children { + iftag("medium") { + children { + iftag("position") { + currentDescription.disk = text.toUInt(); + } eliftag("track-list") { + currentDescription.totalTracks = attribute("count").toUInt(); + children { + iftag("track") { + children { + iftag("number") { + currentDescription.track = text.toUInt(); + } else_skip + } + } else_skip + } + } else_skip + } + } else_skip + } + } else_skip + } + } else_skip + } + } eliftag("tag-list") { + children { + iftag("tag") { + children { + iftag("name") { + if(!currentDescription.genre.isEmpty()) { + currentDescription.genre.append(QLatin1Char(' ')); + } + currentDescription.genre.append(text); + } else_skip + } + } else_skip + } + } else_skip + } + m_results << currentDescription; + } else_skip + } + } else_skip + } + } else_skip + } +#include + + // check for parsing errors + switch(xmlReader.error()) { + case QXmlStreamReader::NoError: + case QXmlStreamReader::PrematureEndOfDocumentError: + break; + default: + m_errorList << xmlReader.errorString(); + } + + // promote changes + endResetModel(); +} + +void MusicBrainzResultsModel::handleCoverReplyFinished(QNetworkReply *reply, const QString &albumId, int row) +{ + QByteArray data; + if(auto *newReply = evaluateReplyResults(reply, data, true)) { + addReply(newReply, bind(&MusicBrainzResultsModel::handleCoverReplyFinished, this, newReply, albumId, row)); + } else { + if(!data.isEmpty()) { + parseCoverResults(albumId, row, data); + } + setResultsAvailable(true); + } +} + +void MusicBrainzResultsModel::parseCoverResults(const QString &albumId, int row, const QByteArray &data) +{ + // add cover -> determine album ID and row + bool ok; + if(!albumId.isEmpty() && ok && row < m_results.size()) { + if(!data.isEmpty()) { m_coverData[albumId] = data; m_results[row].cover = data; emit coverAvailable(index(row, 0)); - } else { - m_errorList << tr("Cover reply is invalid (internal error)."); } - setFetchingCover(false); } else { - // prepare parsing MusicBrainz meta data - beginResetModel(); - m_results.clear(); - m_reader.addData(data); - - // parse XML tree -#define xmlReader m_reader -#include - children { - iftag("metadata") { - children { - iftag("recording-list") { - children { - iftag("recording") { - SongDescription currentDescription; - children { - iftag("title") { - currentDescription.title = text; - } eliftag("artist-credit") { - children { - iftag("name-credit") { - children { - iftag("artist") { - children { - iftag("name") { - currentDescription.artist = text; - } else_skip - } - } else_skip - } - } else_skip - } - } eliftag("release-list") { - children { - iftag("release") { - if(currentDescription.albumId.isEmpty()) { - currentDescription.albumId = attribute("id").toString(); - } - children { - iftag("title") { - currentDescription.album = text; - } eliftag("date") { - currentDescription.year = text; - } eliftag("medium-list") { - children { - iftag("medium") { - children { - iftag("position") { - currentDescription.disk = text.toUInt(); - } eliftag("track-list") { - currentDescription.totalTracks = attribute("count").toUInt(); - children { - iftag("track") { - children { - iftag("number") { - currentDescription.track = text.toUInt(); - } else_skip - } - } else_skip - } - } else_skip - } - } else_skip - } - } else_skip - } - } else_skip - } - } eliftag("tag-list") { - children { - iftag("tag") { - children { - iftag("name") { - if(!currentDescription.genre.isEmpty()) { - currentDescription.genre.append(QLatin1Char(' ')); - } - currentDescription.genre.append(text); - } else_skip - } - } else_skip - } - } else_skip - } - m_results << currentDescription; - } else_skip - } - } else_skip - } - } else_skip - } -#include - - // check for parsing errors - switch(m_reader.error()) { - case QXmlStreamReader::NoError: - case QXmlStreamReader::PrematureEndOfDocumentError: - break; - default: - m_errorList << m_reader.errorString(); - } - - // promote changes - endResetModel(); + m_errorList << tr("Internal error: context for cover reply invalid"); + setResultsAvailable(true); } + setFetchingCover(false); } QueryResultsModel *queryMusicBrainz(SongDescription &&songDescription) { - static QString defaultMusicBrainzUrl(QStringLiteral("https://musicbrainz.org/ws/2/recording/")); + static const QString defaultMusicBrainzUrl(QStringLiteral("https://musicbrainz.org/ws/2/recording/")); // compose parts QStringList parts; @@ -202,7 +217,7 @@ QueryResultsModel *queryMusicBrainz(SongDescription &&songDescription) QNetworkReply *queryCoverArtArchive(const QString &albumId) { - static QString defaultArchiveUrl(QStringLiteral("https://coverartarchive.org")); + static const QString defaultArchiveUrl(QStringLiteral("https://coverartarchive.org")); return networkAccessManager().get(QNetworkRequest(QUrl((Settings::coverArtArchiveUrl().isEmpty() ? defaultArchiveUrl : Settings::coverArtArchiveUrl()) % QStringLiteral("/release/") % albumId % QStringLiteral("/front")))); } diff --git a/dbquery/musicbrainz.h b/dbquery/musicbrainz.h index 43e5310..14d5a58 100644 --- a/dbquery/musicbrainz.h +++ b/dbquery/musicbrainz.h @@ -3,8 +3,6 @@ #include "./dbquery.h" -#include - #include QT_FORWARD_DECLARE_CLASS(QNetworkRequest) @@ -23,14 +21,16 @@ private: public: MusicBrainzResultsModel(SongDescription &&initialSongDescription, QNetworkReply *reply); bool fetchCover(const QModelIndex &index); - static QNetworkRequest coverRequest(const QString &albumId); protected: - void parseResults(QNetworkReply *reply, const QByteArray &data); + void parseInitialResults(const QByteArray &data); + +private: + void handleCoverReplyFinished(QNetworkReply *reply, const QString &albumId, int row); + void parseCoverResults(const QString &albumId, int row, const QByteArray &data); private: static std::map m_coverData; - QXmlStreamReader m_reader; What m_what; }; diff --git a/gui/dbquerywidget.cpp b/gui/dbquerywidget.cpp index b180799..47f9080 100644 --- a/gui/dbquerywidget.cpp +++ b/gui/dbquerywidget.cpp @@ -20,6 +20,7 @@ #include #include #include +#include using namespace ConversionUtilities; using namespace Dialogs; @@ -36,6 +37,7 @@ DbQueryWidget::DbQueryWidget(TagEditorWidget *tagEditorWidget, QWidget *parent) m_tagEditorWidget(tagEditorWidget), m_model(nullptr), m_coverIndex(-1), + m_lyricsIndex(-1), m_menu(new QMenu(parent)) { m_ui->setupUi(this); @@ -117,12 +119,22 @@ void DbQueryWidget::insertSearchTermsFromTagEdit(TagEdit *tagEdit) } } +SongDescription DbQueryWidget::currentSongDescription() const +{ + SongDescription desc; + desc.title = m_ui->titleLineEdit->text(); + desc.album = m_ui->albumLineEdit->text(); + desc.artist = m_ui->artistLineEdit->text(); + desc.track = static_cast(m_ui->trackSpinBox->value()); + return desc; +} + void DbQueryWidget::searchMusicBrainz() { // check whether enough search terms are supplied if(m_ui->titleLineEdit->text().isEmpty() && m_ui->albumLineEdit->text().isEmpty() && m_ui->artistLineEdit->text().isEmpty()) { m_ui->notificationLabel->setNotificationType(NotificationType::Critical); - m_ui->notificationLabel->setText(tr("Insufficient search criteria supplied")); + m_ui->notificationLabel->setText(tr("Insufficient search criteria supplied - at least title, album or artist must be specified")); return; } @@ -132,18 +144,11 @@ void DbQueryWidget::searchMusicBrainz() // show status m_ui->notificationLabel->setNotificationType(NotificationType::Progress); - m_ui->notificationLabel->setText(tr("Retrieving meta data ...")); + m_ui->notificationLabel->setText(tr("Retrieving meta data from MusicBrainz ...")); setStatus(false); - // get song description - SongDescription desc; - desc.title = m_ui->titleLineEdit->text(); - desc.album = m_ui->albumLineEdit->text(); - desc.artist = m_ui->artistLineEdit->text(); - desc.track = m_ui->trackSpinBox->value(); - // do actual query - m_ui->resultsTreeView->setModel(m_model = queryMusicBrainz(std::move(desc))); + m_ui->resultsTreeView->setModel(m_model = queryMusicBrainz(currentSongDescription())); connect(m_model, &QueryResultsModel::resultsAvailable, this, &DbQueryWidget::showResults); connect(m_model, &QueryResultsModel::coverAvailable, this, &DbQueryWidget::showCoverFromIndex); } @@ -153,7 +158,7 @@ void DbQueryWidget::searchLyricsWikia() // check whether enough search terms are supplied if(m_ui->artistLineEdit->text().isEmpty()) { m_ui->notificationLabel->setNotificationType(NotificationType::Critical); - m_ui->notificationLabel->setText(tr("Insufficient search criteria supplied")); + m_ui->notificationLabel->setText(tr("Insufficient search criteria supplied - artist is mandatory")); return; } @@ -163,20 +168,13 @@ void DbQueryWidget::searchLyricsWikia() // show status m_ui->notificationLabel->setNotificationType(NotificationType::Progress); - m_ui->notificationLabel->setText(tr("Retrieving meta data ...")); + m_ui->notificationLabel->setText(tr("Retrieving meta data from LyricsWikia ...")); setStatus(false); - // get song description - SongDescription desc; - desc.title = m_ui->titleLineEdit->text(); - desc.album = m_ui->albumLineEdit->text(); - desc.artist = m_ui->artistLineEdit->text(); - desc.track = static_cast(m_ui->trackSpinBox->value()); - // do actual query - m_ui->resultsTreeView->setModel(m_model = queryLyricsWikia(std::move(desc))); + m_ui->resultsTreeView->setModel(m_model = queryLyricsWikia(currentSongDescription())); connect(m_model, &QueryResultsModel::resultsAvailable, this, &DbQueryWidget::showResults); - connect(m_model, &QueryResultsModel::coverAvailable, this, &DbQueryWidget::showCoverFromIndex); + connect(m_model, &QueryResultsModel::lyricsAvailable, this, &DbQueryWidget::showLyricsFromIndex); } void DbQueryWidget::abortSearch() @@ -261,27 +259,56 @@ void DbQueryWidget::applyResults() int row = selection.front().row(); TagValue value = m_model->fieldValue(row, field); - if(value.isEmpty() && field == KnownField::Cover) { - // cover value is empty -> cover still needs to be fetched - if(m_model->fetchCover(selection.front())) { - // cover is available now - tagEdit->setValue(KnownField::Cover, m_model->fieldValue(row, KnownField::Cover), previousValueHandling); - } else { - // cover is fetched asynchronously - // -> show status - m_ui->notificationLabel->setNotificationType(NotificationType::Progress); - m_ui->notificationLabel->setText(tr("Retrieving cover art to be applied ...")); - setStatus(false); - // -> apply cover when available - connect(m_model, &QueryResultsModel::coverAvailable, [this, row, previousValueHandling](const QModelIndex &index) { - if(row == index.row()) { - if(TagEdit *tagEdit = m_tagEditorWidget->activeTagEdit()) { - tagEdit->setValue(KnownField::Cover, m_model->fieldValue(row, KnownField::Cover), previousValueHandling); + if(value.isEmpty()) { + // cover and lyrics might be fetched belated + switch(field) { + case KnownField::Cover: + if(m_model->fetchCover(selection.front())) { + // cover is available now + tagEdit->setValue(KnownField::Cover, m_model->fieldValue(row, KnownField::Cover), previousValueHandling); + } else { + // cover is fetched asynchronously + // -> show status + m_ui->notificationLabel->setNotificationType(NotificationType::Progress); + m_ui->notificationLabel->appendLine(tr("Retrieving cover art to be applied ...")); + setStatus(false); + // -> apply cover when available + connect(m_model, &QueryResultsModel::coverAvailable, [this, row, previousValueHandling](const QModelIndex &index) { + if(row == index.row()) { + if(TagEdit *tagEdit = m_tagEditorWidget->activeTagEdit()) { + tagEdit->setValue(KnownField::Cover, m_model->fieldValue(row, KnownField::Cover), previousValueHandling); + } } - } - }); + }); + } + break; + + case KnownField::Lyrics: + if(m_model->fetchLyrics(selection.front())) { + // lyrics are available now + tagEdit->setValue(KnownField::Lyrics, m_model->fieldValue(row, KnownField::Lyrics), previousValueHandling); + } else { + // lyrics are fetched asynchronously + // -> show status + m_ui->notificationLabel->setNotificationType(NotificationType::Progress); + m_ui->notificationLabel->appendLine(tr("Retrieving lyrics to be applied ...")); + setStatus(false); + // -> apply cover when available + connect(m_model, &QueryResultsModel::lyricsAvailable, [this, row, previousValueHandling](const QModelIndex &index) { + if(row == index.row()) { + if(TagEdit *tagEdit = m_tagEditorWidget->activeTagEdit()) { + tagEdit->setValue(KnownField::Lyrics, m_model->fieldValue(row, KnownField::Lyrics), previousValueHandling); + } + } + }); + } + break; + + default: + ; } } else { + // any other fields are just set tagEdit->setValue(field, value, previousValueHandling); } } @@ -308,6 +335,7 @@ void DbQueryWidget::showResultsContextMenu() } if(m_model && m_model->areResultsAvailable()) { contextMenu.addAction(QIcon::fromTheme(QStringLiteral("view-preview")), tr("Show cover"), this, SLOT(fetchAndShowCoverForSelection())); + contextMenu.addAction(QIcon::fromTheme(QStringLiteral("view-media-lyrics")), tr("Show lyrics"), this, SLOT(fetchAndShowLyricsForSelection())); } contextMenu.exec(QCursor::pos()); } @@ -328,7 +356,7 @@ void DbQueryWidget::fetchAndShowCoverForSelection() if(const QByteArray *cover = m_model->cover(selectedIndex)) { showCover(*cover); } else { - // cover couldn't be fetched + // cover couldn't be fetched, error tracks via resultsAvailable() signal so nothing to do } } else { // cover is fetched asynchronously @@ -345,9 +373,39 @@ void DbQueryWidget::fetchAndShowCoverForSelection() } } +void DbQueryWidget::fetchAndShowLyricsForSelection() +{ + if(m_model) { + if(const QItemSelectionModel *selectionModel = m_ui->resultsTreeView->selectionModel()) { + const QModelIndexList selection = selectionModel->selection().indexes(); + if(!selection.isEmpty()) { + const QModelIndex &selectedIndex = selection.at(0); + if(const QString *lyrics = m_model->lyrics(selectedIndex)) { + showLyrics(*lyrics); + } else { + if(m_model->fetchLyrics(selectedIndex)) { + if(const QByteArray *cover = m_model->cover(selectedIndex)) { + showLyrics(*cover); + } else { + // lyrics couldn't be fetched, error tracks via resultsAvailable() signal so nothing to do + } + } else { + // lyrics are fetched asynchronously + // -> memorize index to be shown + m_lyricsIndex = selectedIndex.row(); + // -> show status + m_ui->notificationLabel->setNotificationType(NotificationType::Progress); + m_ui->notificationLabel->setText(tr("Retrieving lyrics ...")); + setStatus(false); + } + } + } + } + } +} + void DbQueryWidget::showCover(const QByteArray &data) { - // show cover QDialog dlg; dlg.setWindowFlags(Qt::Tool); dlg.setWindowTitle(tr("Cover - %1").arg(QApplication::applicationName())); @@ -371,6 +429,29 @@ void DbQueryWidget::showCoverFromIndex(const QModelIndex &index) } } +void DbQueryWidget::showLyrics(const QString &data) +{ + QDialog dlg; + dlg.setWindowFlags(Qt::Tool); + dlg.setWindowTitle(tr("Lyrics - %1").arg(QApplication::applicationName())); + QBoxLayout layout(QBoxLayout::Up); + layout.setMargin(0); + QTextBrowser textBrowser; + layout.addWidget(&textBrowser); + textBrowser.setText(data); + dlg.setLayout(&layout); + dlg.resize(400, 400); + dlg.exec(); +} + +void DbQueryWidget::showLyricsFromIndex(const QModelIndex &index) +{ + if(m_lyricsIndex == index.row()) { + m_lyricsIndex = -1; + showLyrics(*m_model->lyrics(index)); + } +} + void DbQueryWidget::clearSearchCriteria() { m_ui->titleLineEdit->clear(); diff --git a/gui/dbquerywidget.h b/gui/dbquerywidget.h index bdf0b66..fdfd22c 100644 --- a/gui/dbquerywidget.h +++ b/gui/dbquerywidget.h @@ -22,6 +22,7 @@ class DbQueryWidget; class QueryResultsModel; class TagEditorWidget; class TagEdit; +struct SongDescription; class DbQueryWidget : public QWidget { @@ -32,6 +33,7 @@ public: ~DbQueryWidget(); void insertSearchTermsFromTagEdit(TagEdit *tagEdit); + SongDescription currentSongDescription() const; public slots: void searchMusicBrainz(); @@ -47,8 +49,11 @@ private slots: void fileStatusChanged(bool opened, bool hasTags); void showResultsContextMenu(); void fetchAndShowCoverForSelection(); + void fetchAndShowLyricsForSelection(); void showCover(const QByteArray &data); void showCoverFromIndex(const QModelIndex &index); + void showLyrics(const QString &data); + void showLyricsFromIndex(const QModelIndex &index); protected: bool eventFilter(QObject *obj, QEvent *event); @@ -57,7 +62,7 @@ private: std::unique_ptr m_ui; TagEditorWidget *m_tagEditorWidget; QueryResultsModel *m_model; - int m_coverIndex; + int m_coverIndex, m_lyricsIndex; QMenu *m_menu; QAction *m_insertPresentDataAction; }; diff --git a/gui/dbquerywidget.ui b/gui/dbquerywidget.ui index 74fd1dc..89aa403 100644 --- a/gui/dbquerywidget.ui +++ b/gui/dbquerywidget.ui @@ -2,14 +2,6 @@ QtGui::DbQueryWidget - - - 0 - 0 - 731 - 619 - - MusicBrainz/LyricsWikia search @@ -218,7 +210,7 @@ - override existing values + Override existing values true diff --git a/gui/mainwindow.cpp b/gui/mainwindow.cpp index d833afa..52427f6 100644 --- a/gui/mainwindow.cpp +++ b/gui/mainwindow.cpp @@ -110,10 +110,7 @@ MainWindow::MainWindow(QWidget *parent) : // dbquery dock widget if(Settings::dbQueryWidgetShown()) { - toggleDbQueryWidget(); - } else { - // ensure the dock widget is invisible - m_ui->dbQueryDockWidget->setVisible(false); + m_ui->dbQueryDockWidget->setWidget(m_dbQueryWidget = new DbQueryWidget(m_ui->tagEditorWidget, this)); } // restore locked diff --git a/gui/notificationlabel.cpp b/gui/notificationlabel.cpp index f60e654..1f26f5a 100644 --- a/gui/notificationlabel.cpp +++ b/gui/notificationlabel.cpp @@ -173,18 +173,19 @@ QSize NotificationLabel::minimumSizeHint() const void NotificationLabel::setText(const QString &text) { + const bool updateTooltip = toolTip().isEmpty() || toolTip() == m_text; m_text = text; updateGeometry(); update(textRect()); - if(toolTip().isEmpty()) { - setToolTip(text); + if(updateTooltip) { + setToolTip(m_text); } } void NotificationLabel::clearText() { if(toolTip() == m_text) { - toolTip().clear(); + setToolTip(QString()); } m_text.clear(); updateGeometry(); diff --git a/translations/tageditor_de_DE.ts b/translations/tageditor_de_DE.ts index b1acfd4..46387ac 100644 --- a/translations/tageditor_de_DE.ts +++ b/translations/tageditor_de_DE.ts @@ -719,44 +719,52 @@ row - + Search hasn't been started - + Insert present data - + Clear search criteria - - - Insufficient search criteria supplied + + Insufficient search criteria supplied - at least title, album or artist must be specified - - - Retrieving meta data ... + + Retrieving meta data from MusicBrainz ... - + + Insufficient search criteria supplied - artist is mandatory + + + + + Retrieving meta data from LyricsWikia ... + + + + Aborted - + No results available - + %1 result(s) available @@ -769,25 +777,45 @@ row - + + Retrieving lyrics to be applied ... + + + + Use selected row - + Show cover - + + Show lyrics + + + + Retrieving cover art ... - + + Retrieving lyrics ... + + + + Cover - %1 + + + Lyrics - %1 + + QtGui::EditorAutoCorrectionOptionPage @@ -1578,22 +1606,27 @@ another position would prevent rewriting the entire file QtGui::HttpResultsModel - + <p>Do you want to redirect form <i>%1</i> to <i>%2</i>?</p> - + Search - + Redirection to: - + + Server replied no data. + + + + Aborted by user. @@ -1723,6 +1756,39 @@ another position would prevent rewriting the entire file + + QtGui::LyricsWikiaResultsModel + + + Unable to fetch lyrics: Artist or title is unknown. + + + + + Internal error: context for song details reply invalid + + + + + Unable to parse song details: + + + + + Song details requested for %1/%2 do not contain URL for Wiki page + + + + + Internal error: context for LyricsWikia page reply invalid + + + + + Song details requested for %1/%2 do not contain lyrics + + + QtGui::MainWindow @@ -1846,7 +1912,7 @@ another position would prevent rewriting the entire file - + Lock layout @@ -1922,63 +1988,63 @@ another position would prevent rewriting the entire file - + No file opened. - + A tag editing utility supporting ID3, MP4 (iTunes style), Vorbis and Matroska tags. - + Unable to show the next file because it can't be found anymore. - + Open file - - + Save changes as - - + Save file information - - + No file is opened. - + Unable to save file information because the current process hasn't been finished yet. - + Unable to write to file. %1 - + Unlock layout - + Unable to open file. - + No file information available. @@ -1986,13 +2052,13 @@ another position would prevent rewriting the entire file QtGui::MusicBrainzResultsModel - - Unable to fetch cover: Album ID is unknown. + + Unable to fetch cover: Album ID unknown - - Cover reply is invalid (internal error). + + Internal error: context for cover reply invalid @@ -2315,43 +2381,48 @@ another position would prevent rewriting the entire file QtGui::QueryResultsModel - + Song title - + Album - + Artist - + Year - + Track - + Total tracks - + Genre - - Fetching the cover is not implemented for the selected provider. + + Fetching cover is not implemented for this provider + + + + + Fetching lyrics is not implemented for this provider diff --git a/translations/tageditor_en_US.ts b/translations/tageditor_en_US.ts index d9dbe24..d1f29db 100644 --- a/translations/tageditor_en_US.ts +++ b/translations/tageditor_en_US.ts @@ -648,44 +648,52 @@ QtGui::DbQueryWidget - + Search hasn't been started - + Insert present data - + Clear search criteria - - - Insufficient search criteria supplied + + Insufficient search criteria supplied - at least title, album or artist must be specified - - - Retrieving meta data ... + + Retrieving meta data from MusicBrainz ... - + + Insufficient search criteria supplied - artist is mandatory + + + + + Retrieving meta data from LyricsWikia ... + + + + Aborted - + No results available - + %1 result(s) available %1 result available @@ -698,25 +706,45 @@ - + + Retrieving lyrics to be applied ... + + + + Use selected row - + Show cover - + + Show lyrics + + + + Retrieving cover art ... - + + Retrieving lyrics ... + + + + Cover - %1 + + + Lyrics - %1 + + MusicBrainz/LyricsWikia search @@ -1578,22 +1606,27 @@ another position would prevent rewriting the entire file QtGui::HttpResultsModel - + <p>Do you want to redirect form <i>%1</i> to <i>%2</i>?</p> - + Search - + Redirection to: - + + Server replied no data. + + + + Aborted by user. @@ -1723,6 +1756,39 @@ another position would prevent rewriting the entire file + + QtGui::LyricsWikiaResultsModel + + + Unable to fetch lyrics: Artist or title is unknown. + + + + + Internal error: context for song details reply invalid + + + + + Unable to parse song details: + + + + + Song details requested for %1/%2 do not contain URL for Wiki page + + + + + Internal error: context for LyricsWikia page reply invalid + + + + + Song details requested for %1/%2 do not contain lyrics + + + QtGui::MainWindow @@ -1912,7 +1978,7 @@ another position would prevent rewriting the entire file - + Lock layout @@ -1922,63 +1988,63 @@ another position would prevent rewriting the entire file - + Unlock layout - + No file opened. - + A tag editing utility supporting ID3, MP4 (iTunes style), Vorbis and Matroska tags. - + Unable to show the next file because it can't be found anymore. - + Open file - - + Save changes as - - + Save file information - - + Unable to write to file. %1 - + Unable to open file. - + No file information available. - + No file is opened. - + Unable to save file information because the current process hasn't been finished yet. @@ -1986,13 +2052,13 @@ another position would prevent rewriting the entire file QtGui::MusicBrainzResultsModel - - Unable to fetch cover: Album ID is unknown. + + Unable to fetch cover: Album ID unknown - - Cover reply is invalid (internal error). + + Internal error: context for cover reply invalid @@ -2315,43 +2381,48 @@ another position would prevent rewriting the entire file QtGui::QueryResultsModel - + Song title - + Album - + Artist - + Year - + Track - + Total tracks - + Genre - - Fetching the cover is not implemented for the selected provider. + + Fetching cover is not implemented for this provider + + + + + Fetching lyrics is not implemented for this provider