File indexing completed on 2024-05-19 04:56:25

0001 /**
0002  * \file musicbrainzclient.cpp
0003  * MusicBrainz client.
0004  *
0005  * \b Project: Kid3
0006  * \author Urs Fleisch
0007  * \date 15 Sep 2005
0008  *
0009  * Copyright (C) 2005-2024  Urs Fleisch
0010  *
0011  * This file is part of Kid3.
0012  *
0013  * Kid3 is free software; you can redistribute it and/or modify
0014  * it under the terms of the GNU General Public License as published by
0015  * the Free Software Foundation; either version 2 of the License, or
0016  * (at your option) any later version.
0017  *
0018  * Kid3 is distributed in the hope that it will be useful,
0019  * but WITHOUT ANY WARRANTY; without even the implied warranty of
0020  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
0021  * GNU General Public License for more details.
0022  *
0023  * You should have received a copy of the GNU General Public License
0024  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
0025  */
0026 
0027 #include "musicbrainzclient.h"
0028 #include <QByteArray>
0029 #include <QDomDocument>
0030 #include <QRegularExpression>
0031 #include "httpclient.h"
0032 #include "trackdatamodel.h"
0033 #include "fingerprintcalculator.h"
0034 
0035 namespace {
0036 
0037 /**
0038  * Parse response from acoustid.org.
0039  * @param bytes response in JSON format
0040  * @return list of MusicBrainz IDs
0041  */
0042 QStringList parseAcoustidIds(const QByteArray& bytes)
0043 {
0044   /*
0045    * The response from acoustid.org is in JSON format and looks like this:
0046    * {
0047    *   "status": "ok",
0048    *   "results": [{
0049    *     "recordings": [{"id": "14fef9a4-9b50-4e9f-9e22-490fd86d1861"}],
0050    *     "score": 0.938621, "id": "29bf7ce3-0182-40da-b840-5420203369c4"
0051    *   }]
0052    * }
0053    */
0054   QStringList ids;
0055   if (bytes.indexOf(R"("status": "ok")") >= 0) {
0056     if (int startPos = bytes.indexOf("\"recordings\": ["); startPos >= 0) {
0057       startPos += 15;
0058       if (int endPos = bytes.indexOf(']', startPos); endPos > startPos) {
0059         QRegularExpression idRe(QLatin1String("\"id\":\\s*\"([^\"]+)\""));
0060         QString recordings(QString::fromLatin1(bytes.mid(startPos,
0061                                                          endPos - startPos)));
0062         auto it = idRe.globalMatch(recordings);
0063         while (it.hasNext()) {
0064           auto match = it.next();
0065           ids.append(match.captured(1));
0066         }
0067       }
0068     }
0069   }
0070   return ids;
0071 }
0072 
0073 /**
0074  * Parse response from MusicBrainz server.
0075  *
0076  * @param bytes XML response from MusicBrainz
0077  * @param trackDataVector the resulting track data will be appended to this
0078  *                        vector
0079  */
0080 void parseMusicBrainzMetadata(const QByteArray& bytes,
0081                               ImportTrackDataVector& trackDataVector)
0082 {
0083   /*
0084    * The XML response from MusicBrainz looks like this (simplified):
0085    * <?xml version="1.0" encoding="UTF-8"?>
0086    * <metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
0087    *   <recording id="14fef9a4-9b50-4e9f-9e22-490fd86d1861">
0088    *     <title>Trip the Darkness</title>
0089    *     <length>192000</length>
0090    *     <artist-credit>
0091    *       <name-credit>
0092    *         <artist id="6fea1339-260c-40fe-bb7a-ace5c8438955">
0093    *           <name>Lacuna Coil</name>
0094    *         </artist>
0095    *       </name-credit>
0096    *     </artist-credit>
0097    *     <release-list count="2">
0098    *       <release id="aa7b7302-6ab0-409b-ab0f-b1e14732e11a">
0099    *         <title>Dark Adrenaline</title>
0100    *         <date>2012-01-24</date>
0101    *         <medium-list count="1">
0102    *           <medium>
0103    *             <track-list count="12" offset="0">
0104    *               <track>
0105    *                 <position>1</position>
0106    *               </track>
0107    *             </track-list>
0108    *           </medium>
0109    *         </medium-list>
0110    *       </release>
0111    *     </release-list>
0112    *   </recording>
0113    * </metadata>
0114    */
0115   int start = bytes.indexOf("<?xml");
0116   int end = bytes.indexOf("</metadata>");
0117   QByteArray xmlStr = start >= 0 && end > start ?
0118     bytes.mid(start, end + 11 - start) : bytes;
0119   if (QDomDocument doc; doc.setContent(xmlStr, false)) {
0120     if (QDomElement recording =
0121           doc.namedItem(QLatin1String("metadata"))
0122              .namedItem(QLatin1String("recording")).toElement();
0123         !recording.isNull()) {
0124       bool ok;
0125       ImportTrackData frames;
0126       frames.setTitle(recording.namedItem(QLatin1String("title")).toElement()
0127                       .text());
0128       int length = recording.namedItem(QLatin1String("length")).toElement()
0129           .text().toInt(&ok);
0130       if (ok) {
0131         frames.setImportDuration(length / 1000);
0132       }
0133       if (QDomNode artistNode = recording.namedItem(QLatin1String("artist-credit"));
0134           !artistNode.isNull()) {
0135         QString artist(artistNode.namedItem(QLatin1String("name-credit"))
0136                        .namedItem(QLatin1String("artist"))
0137                        .namedItem(QLatin1String("name")).toElement().text());
0138         frames.setArtist(artist);
0139       }
0140       if (QDomNode releaseNode = recording.namedItem(QLatin1String("release-list"))
0141                                           .namedItem(QLatin1String("release"));
0142           !releaseNode.isNull()) {
0143         frames.setAlbum(releaseNode.namedItem(QLatin1String("title"))
0144                         .toElement().text());
0145         if (QString date(releaseNode.namedItem(QLatin1String("date")).toElement()
0146                                     .text());
0147             !date.isEmpty()) {
0148           QRegularExpression dateRe(QLatin1String(R"(^(\d{4})(?:-\d{2})?(?:-\d{2})?$)"));
0149           auto match = dateRe.match(date);
0150           int year = 0;
0151           if (match.hasMatch()) {
0152             year = match.captured(1).toInt();
0153           } else {
0154             year = date.toInt();
0155           }
0156           if (year != 0) {
0157             frames.setYear(year);
0158           }
0159         }
0160         if (QDomNode trackNode = releaseNode
0161                                  .namedItem(QLatin1String("medium-list"))
0162                                  .namedItem(QLatin1String("medium"))
0163                                  .namedItem(QLatin1String("track-list"))
0164                                  .namedItem(QLatin1String("track"));
0165             !trackNode.isNull()) {
0166           int trackNr = trackNode.namedItem(QLatin1String("position"))
0167               .toElement().text().toInt(&ok);
0168           if (ok) {
0169             frames.setTrack(trackNr);
0170           }
0171         }
0172       }
0173       trackDataVector.append(frames);
0174     }
0175   }
0176 }
0177 
0178 }
0179 
0180 
0181 /**
0182  * Constructor.
0183  *
0184  * @param netMgr network access manager
0185  * @param trackDataModel track data to be filled with imported values,
0186  *                      is passed with filenames set
0187  */
0188 MusicBrainzClient::MusicBrainzClient(QNetworkAccessManager* netMgr,
0189                                      TrackDataModel *trackDataModel)
0190   : ServerTrackImporter(netMgr, trackDataModel),
0191     m_fingerprintCalculator(new FingerprintCalculator(this)),
0192     m_state(Idle), m_currentIndex(-1)
0193 {
0194   m_headers["User-Agent"] = "curl/7.52.1";
0195   connect(httpClient(), &HttpClient::bytesReceived,
0196           this, &MusicBrainzClient::receiveBytes);
0197   connect(m_fingerprintCalculator, &FingerprintCalculator::finished,
0198           this, &MusicBrainzClient::receiveFingerprint);
0199 }
0200 
0201 /**
0202  * Name of import source.
0203  * @return name.
0204  */
0205 const char* MusicBrainzClient::name() const {
0206   return QT_TRANSLATE_NOOP("@default", "MusicBrainz Fingerprint");
0207 }
0208 
0209 /** NULL-terminated array of server strings, 0 if not used */
0210 const char** MusicBrainzClient::serverList() const
0211 {
0212   return nullptr;
0213 }
0214 
0215 /** default server, 0 to disable */
0216 const char* MusicBrainzClient::defaultServer() const {
0217   return nullptr;
0218 }
0219 
0220 /** anchor to online help, 0 to disable */
0221 const char* MusicBrainzClient::helpAnchor() const {
0222   return "import-musicbrainz";
0223 }
0224 
0225 /** configuration, 0 if not used */
0226 ServerImporterConfig* MusicBrainzClient::config() const {
0227   return nullptr;
0228 }
0229 
0230 /**
0231  * Verify if m_currentIndex is in range of m_idsOfTrack.
0232  * @return true if index OK, false if index was invalid and state is reset.
0233  */
0234 bool MusicBrainzClient::verifyIdIndex()
0235 {
0236   if (m_currentIndex < 0 || m_currentIndex >= m_idsOfTrack.size()) {
0237     qWarning("Invalid index %d for IDs (size %d)",
0238              m_currentIndex, static_cast<int>(m_idsOfTrack.size()));
0239     stop();
0240     return false;
0241   }
0242   return true;
0243 }
0244 
0245 /**
0246  * Verify if m_currentIndex is in range of m_filenameOfTrack.
0247  * @return true if index OK, false if index was invalid and state is reset.
0248  */
0249 bool MusicBrainzClient::verifyTrackIndex()
0250 {
0251   if (m_currentIndex < 0 || m_currentIndex >= m_filenameOfTrack.size()) {
0252     qWarning("Invalid index %d for track (size %d)",
0253              m_currentIndex, static_cast<int>(m_filenameOfTrack.size()));
0254     stop();
0255     return false;
0256   }
0257   return true;
0258 }
0259 
0260 /**
0261  * Reset the state to Idle and no track.
0262  */
0263 void MusicBrainzClient::stop()
0264 {
0265   m_fingerprintCalculator->stop();
0266   m_currentIndex = -1;
0267   m_state = Idle;
0268 }
0269 
0270 /**
0271  * Receive response from web service.
0272  * @param bytes bytes received
0273  */
0274 void MusicBrainzClient::receiveBytes(const QByteArray& bytes)
0275 {
0276   switch (m_state) {
0277   case GettingIds:
0278     if (!verifyIdIndex())
0279       return;
0280     m_idsOfTrack[m_currentIndex] = parseAcoustidIds(bytes);
0281     if (m_idsOfTrack.at(m_currentIndex).isEmpty()) {
0282       emit statusChanged(m_currentIndex, tr("Unrecognized"));
0283     }
0284     m_state = GettingMetadata;
0285     processNextStep();
0286     break;
0287   case GettingMetadata:
0288     parseMusicBrainzMetadata(bytes, m_currentTrackData);
0289     if (!verifyIdIndex())
0290       return;
0291     if (m_idsOfTrack.at(m_currentIndex).isEmpty()) {
0292       emit statusChanged(m_currentIndex, m_currentTrackData.size() == 1
0293                          ? tr("Recognized") : tr("User Selection"));
0294       emit resultsReceived(m_currentIndex, m_currentTrackData);
0295     }
0296     processNextStep();
0297     break;
0298   default:
0299     ;
0300   }
0301 }
0302 
0303 /**
0304  * Receive fingerprint from decoder.
0305  *
0306  * @param fingerprint Chromaprint fingerprint
0307  * @param duration duration in seconds
0308  * @param error error code
0309  */
0310 void MusicBrainzClient::receiveFingerprint(const QString& fingerprint,
0311                                            int duration, int error)
0312 {
0313   if (error == FingerprintCalculator::Ok) {
0314     m_state = GettingIds;
0315     emit statusChanged(m_currentIndex, tr("ID Lookup"));
0316     QString path(
0317       QLatin1String("/v2/lookup?client=LxDbFAXo&meta=recordingids&duration=") +
0318       QString::number(duration) +
0319       QLatin1String("&fingerprint=") + fingerprint);
0320     httpClient()->sendRequest(QLatin1String("api.acoustid.org"), path,
0321                               QLatin1String("https"));
0322   } else {
0323     emit statusChanged(m_currentIndex, tr("Error"));
0324     if (m_state != Idle) {
0325       processNextTrack();
0326     }
0327   }
0328 }
0329 
0330 /**
0331  * Process next step in importing from fingerprints.
0332  */
0333 void MusicBrainzClient::processNextStep()
0334 {
0335   switch (m_state) {
0336   case Idle:
0337     break;
0338   case CalculatingFingerprint:
0339   {
0340     if (!verifyTrackIndex())
0341       return;
0342     emit statusChanged(m_currentIndex, tr("Fingerprint"));
0343     m_fingerprintCalculator->start(m_filenameOfTrack.at(m_currentIndex));
0344     break;
0345   }
0346   case GettingMetadata:
0347   {
0348     if (!verifyIdIndex())
0349       return;
0350     if (QStringList& ids = m_idsOfTrack[m_currentIndex]; !ids.isEmpty()) {
0351       emit statusChanged(m_currentIndex, tr("Metadata Lookup"));
0352       QString path(QLatin1String("/ws/2/recording/") + ids.takeFirst() +
0353                    QLatin1String("?inc=artists+releases+media"));
0354       httpClient()->sendRequest(QLatin1String("musicbrainz.org"), path,
0355                                 QLatin1String("https"), m_headers);
0356     } else {
0357       processNextTrack();
0358     }
0359     break;
0360   }
0361   case GettingIds:
0362     qWarning("processNextStep() called in state GettingIds");
0363     stop();
0364   }
0365 }
0366 
0367 /**
0368  * Process next track.
0369  * If all tracks have been processed, the state is reset to Idle.
0370  */
0371 void MusicBrainzClient::processNextTrack()
0372 {
0373   if (m_currentIndex < m_filenameOfTrack.size() - 1) {
0374     ++m_currentIndex;
0375     m_state = CalculatingFingerprint;
0376   } else {
0377     stop();
0378   }
0379   m_currentTrackData.clear();
0380   processNextStep();
0381 }
0382 
0383 /**
0384  * Set configuration.
0385  *
0386  * @param cfg import server configuration, 0 if not used
0387  */
0388 void MusicBrainzClient::setConfig(const ServerImporterConfig* cfg)
0389 {
0390   Q_UNUSED(cfg)
0391 }
0392 
0393 /**
0394  * Add the files in the file list.
0395  */
0396 void MusicBrainzClient::start()
0397 {
0398   m_filenameOfTrack.clear();
0399   m_idsOfTrack.clear();
0400   const ImportTrackDataVector& trackDataVector(trackDataModel()->trackData());
0401   for (auto it = trackDataVector.constBegin();
0402        it != trackDataVector.constEnd();
0403        ++it) {
0404     if (it->isEnabled()) {
0405       m_filenameOfTrack.append(it->getAbsFilename());
0406       m_idsOfTrack.append(QStringList());
0407     }
0408   }
0409   stop();
0410   processNextTrack();
0411 }