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 }