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

0001 /**
0002  * \file discogsimporter.cpp
0003  * Discogs importer.
0004  *
0005  * \b Project: Kid3
0006  * \author Urs Fleisch
0007  * \date 13 Oct 2006
0008  *
0009  * Copyright (C) 2006-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 "discogsimporter.h"
0028 #include <QUrl>
0029 #include <QJsonDocument>
0030 #include <QJsonObject>
0031 #include <QJsonArray>
0032 #include <QRegularExpression>
0033 #include "serverimporterconfig.h"
0034 #include "trackdatamodel.h"
0035 #include "discogsconfig.h"
0036 #include "config.h"
0037 #include "genres.h"
0038 
0039 namespace {
0040 
0041 /**
0042  * Remove trailing stars and numbers like (2) from a string.
0043  *
0044  * @param str string
0045  *
0046  * @return fixed up string.
0047  */
0048 QString fixUpArtist(QString str)
0049 {
0050   str.replace(QRegularExpression(QLatin1String(",(\\S)")), QLatin1String(", \\1"));
0051   str.replace(QLatin1String("* / "), QLatin1String(" / "));
0052   str.replace(QLatin1String("* - "), QLatin1String(" - "));
0053   str.replace(QLatin1String("*,"), QLatin1String(","));
0054   str.remove(QRegularExpression(QLatin1String("\\*$")));
0055   str.remove(QRegularExpression(QLatin1String(R"([*\s]*\(\d+\)\(tracks:[^)]+\))")));
0056   str.replace(QRegularExpression(
0057     QLatin1String("[*\\s]*\\((?:\\d+|tracks:[^)]+)\\)(\\s*/\\s*,|\\s*&amp;|"
0058                   "\\s*And|\\s*and)")),
0059     QLatin1String("\\1"));
0060   str.remove(QRegularExpression(QLatin1String(R"([*\s]*\((?:\d+|tracks:[^)]+)\)$)")));
0061   return ServerImporter::removeHtml(str);
0062 }
0063 
0064 /**
0065  * Create a string with artists contained in an artist list.
0066  * @param artists list containing artist maps
0067  * @return string with artists joined appropriately.
0068  */
0069 QString getArtistString(const QJsonArray& artists)
0070 {
0071   QString artist;
0072   if (!artists.isEmpty()) {
0073     QString join;
0074     for (const auto& val : artists) {
0075       auto map = val.toObject();
0076       if (!artist.isEmpty()) {
0077         artist += join;
0078       }
0079       artist += fixUpArtist((map.contains(QLatin1String("name"))
0080                              ? map.value(QLatin1String("name"))
0081                              : map.contains(QLatin1String("displayName"))
0082                                ? map.value(QLatin1String("displayName"))
0083                                : map.value(QLatin1String("artist"))
0084                                  .toObject().value(QLatin1String("name")))
0085                             .toString());
0086       join = (map.contains(QLatin1String("join"))
0087               ? map.value(QLatin1String("join"))
0088               : map.value(QLatin1String("joiningText"))).toString();
0089       if (join.isEmpty() || join == QLatin1String(",")) {
0090         join = QLatin1String(", ");
0091       } else {
0092         join = QLatin1Char(' ') + join + QLatin1Char(' ');
0093       }
0094     }
0095   }
0096   return artist;
0097 }
0098 
0099 /**
0100  * Add involved people to a frame.
0101  * The format used is (should be converted according to tag specifications):
0102  * involvee 1 (involvement 1)\n
0103  * involvee 2 (involvement 2)\n
0104  * ...
0105  * involvee n (involvement n)
0106  *
0107  * @param frames      frame collection
0108  * @param type        type of frame
0109  * @param involvement involvement (e.g. instrument)
0110  * @param involvee    name of involvee (e.g. musician)
0111  */
0112 void addInvolvedPeople(
0113   FrameCollection& frames, Frame::Type type,
0114   const QString& involvement, const QString& involvee)
0115 {
0116   QString value = frames.getValue(type);
0117   if (!value.isEmpty()) value += Frame::stringListSeparator();
0118   value += Frame::joinStringList({involvement, involvee});
0119   frames.setValue(type, value);
0120 }
0121 
0122 /**
0123  * Get frame for a role.
0124  * @param role role or credit of involved people, can be adapted to canonical
0125  * value
0126  * @return suitable frame type, Frame::FT_UnknownFrame if not found.
0127  */
0128 Frame::Type frameTypeForRole(QString& role)
0129 {
0130   static const struct {
0131     const char* credit;
0132     Frame::Type type;
0133   } creditToType[] = {
0134     { "Composed By", Frame::FT_Composer },
0135     { "Conductor", Frame::FT_Conductor },
0136     { "Orchestra", Frame::FT_AlbumArtist },
0137     { "Lyrics By", Frame::FT_Lyricist },
0138     { "Written-By", Frame::FT_Author },
0139     { "Written By", Frame::FT_Author },
0140     { "Remix", Frame::FT_Remixer },
0141     { "Music By", Frame::FT_Composer },
0142     { "Songwriter", Frame::FT_Composer }
0143   };
0144   for (const auto& c2t : creditToType) {
0145     if (role.contains(QString::fromLatin1(c2t.credit))) {
0146       return c2t.type;
0147     }
0148   }
0149 
0150   static const struct {
0151     const char* credit;
0152     const char* arrangement;
0153   } creditToArrangement[] = {
0154     { "Arranged By", "Arranger" },
0155     { "Mixed By", "Mixer" },
0156     { "DJ Mix", "DJMixer" },
0157     { "Dj Mix", "DJMixer" },
0158     { "Engineer", "Engineer" },
0159     { "Mastered By", "Engineer" },
0160     { "Producer", "Producer" },
0161     { "Co-producer", "Producer" },
0162     { "Executive Producer", "Producer" }
0163   };
0164   for (const auto& [credit, arrangement] : creditToArrangement) {
0165     if (role.contains(QString::fromLatin1(credit))) {
0166       role = QString::fromLatin1(arrangement);
0167       return Frame::FT_Arranger;
0168     }
0169   }
0170 
0171   static const char* const instruments[] = {
0172     "Performer", "Vocals", "Voice", "Featuring", "Choir", "Chorus",
0173     "Baritone", "Tenor", "Rap", "Scratches", "Drums", "Percussion",
0174     "Keyboards", "Cello", "Piano", "Organ", "Synthesizer", "Keys",
0175     "Wurlitzer", "Rhodes", "Harmonica", "Xylophone", "Guitar", "Bass",
0176     "Strings", "Violin", "Viola", "Banjo", "Harp", "Mandolin",
0177     "Clarinet", "Horn", "Cornet", "Flute", "Oboe", "Saxophone",
0178     "Trumpet", "Tuba", "Trombone"
0179   };
0180   for (auto instrument : instruments) {
0181     if (role.contains(QString::fromLatin1(instrument))) {
0182       return Frame::FT_Performer;
0183     }
0184   }
0185 
0186   return Frame::FT_UnknownFrame;
0187 }
0188 
0189 /**
0190  * Set tags from a string with credits lines.
0191  * The string must have lines like "Composed By - Iommi", separated by \\n.
0192  *
0193  * @param str    credits string
0194  * @param frames tags will be added to these frames
0195  *
0196  * @return true if credits found.
0197  */
0198 bool parseCredits(const QString& str, FrameCollection& frames)
0199 {
0200   bool result = false;
0201   QStringList lines = str.split(QLatin1Char('\n'));
0202   for (auto it = lines.constBegin(); it != lines.constEnd(); ++it) {
0203     if (int nameStart = it->indexOf(QLatin1String(" - ")); nameStart != -1) {
0204       const QStringList names = it->mid(nameStart + 3).split(QLatin1String(", "));
0205       QString name;
0206       for (const QString& namesPart : names) {
0207         if (!name.isEmpty()) {
0208           name += QLatin1String(", ");
0209         }
0210         name += fixUpArtist(namesPart);
0211       }
0212       QStringList credits = it->left(nameStart).split(QLatin1String(", "));
0213       for (auto cit = credits.constBegin(); cit != credits.constEnd(); ++cit) {
0214         QString role = *cit;
0215         if (Frame::Type frameType = frameTypeForRole(role);
0216             frameType == Frame::FT_Arranger ||
0217             frameType == Frame::FT_Performer) {
0218           addInvolvedPeople(frames, frameType, role, name);
0219           result = true;
0220         } else if (frameType != Frame::FT_UnknownFrame) {
0221           frames.setValue(frameType, name);
0222           result = true;
0223         }
0224       }
0225     }
0226   }
0227   return result;
0228 }
0229 
0230 /**
0231  * Add name to frame with credits.
0232  * @param frames frame collection
0233  * @param type   type of frame
0234  * @param name   name of person to credit
0235  */
0236 void addCredit(FrameCollection& frames, Frame::Type type, const QString& name)
0237 {
0238   QString value = frames.getValue(type);
0239   if (!value.isEmpty()) value += QLatin1String(", ");
0240   value += name;
0241   frames.setValue(type, value);
0242 }
0243 
0244 /**
0245  * Extract the URL from "fullsize.__ref" of an image JSON object.
0246  * @param imageValue image JSON value
0247  * @return image URL if present, else null.
0248  */
0249 QString extractUrlFromImageValue(const QJsonValue& imageValue) {
0250   QRegularExpression sourceUrlRe(
0251         QLatin1String("\"sourceUrl\"\\s*:\\s*\"([^\"]+)\""));
0252   QString ref = imageValue.toObject()
0253       .value(QLatin1String("fullsize")).toObject()
0254       .value(QLatin1String("__ref")).toString();
0255   auto match = sourceUrlRe.match(ref);
0256   return match.hasMatch() ? match.captured(1) : QString();
0257 }
0258 
0259 /**
0260  * Stores information about extra artists.
0261  * The information can be used to add frames to the appropriate tracks.
0262  */
0263 class ExtraArtist {
0264 public:
0265   /**
0266    * Constructor.
0267    * @param obj JSON object containing extra artist information
0268    */
0269   explicit ExtraArtist(const QJsonObject& obj);
0270 
0271   /**
0272    * Add extra artist information to frames.
0273    * @param frames   frame collection
0274    * @param trackPos optional position, the extra artist information will
0275    *                 only be added if this track position is listed in the
0276    *                 track restrictions or is empty
0277    */
0278   void addToFrames(FrameCollection& frames,
0279                    const QString& trackPos = QString()) const;
0280 
0281   /**
0282    * Check if extra artist information is only valid for a subset of the tracks.
0283    * @return true if extra artist has track restriction.
0284    */
0285   bool hasTrackRestriction() const { return !m_tracks.isEmpty(); }
0286 
0287 private:
0288   QString m_name;
0289   QString m_role;
0290   QStringList m_tracks;
0291 };
0292 
0293 /**
0294  * Constructor.
0295  * @param obj JSON object containing extra artist information
0296  */
0297 ExtraArtist::ExtraArtist(const QJsonObject& obj) :
0298   m_name(fixUpArtist((obj.contains(QLatin1String("name"))
0299                       ? obj.value(QLatin1String("name"))
0300                       : obj.contains(QLatin1String("displayName"))
0301                         ? obj.value(QLatin1String("displayName"))
0302                         : obj.value(QLatin1String("artist"))
0303                           .toObject().value(QLatin1String("name"))).toString())),
0304   m_role((obj.contains(QLatin1String("role"))
0305           ? obj.value(QLatin1String("role"))
0306           : obj.value(QLatin1String("creditRole"))).toString().trimmed())
0307 {
0308   static const QRegularExpression tracksSepRe(QLatin1String(",\\s*"));
0309   if (QString tracks = (obj.contains(QLatin1String("tracks"))
0310                         ? obj.value(QLatin1String("tracks"))
0311                         : obj.value(QLatin1String("applicableTracks")))
0312                        .toString();
0313       !tracks.isEmpty()) {
0314     m_tracks = tracks.split(tracksSepRe);
0315   }
0316 }
0317 
0318 /**
0319  * Add extra artist information to frames.
0320  * @param frames   frame collection
0321  * @param trackPos optional position, the extra artist information will
0322  *                 only be added if this track position is listed in the
0323  *                 track restrictions or is empty
0324  */
0325 void ExtraArtist::addToFrames(FrameCollection& frames,
0326                               const QString& trackPos) const
0327 {
0328   if (!trackPos.isEmpty() && !m_tracks.contains(trackPos))
0329     return;
0330 
0331   QString role = m_role;
0332   if (Frame::Type frameType = frameTypeForRole(role);
0333       frameType == Frame::FT_Arranger ||
0334       frameType == Frame::FT_Performer) {
0335     addInvolvedPeople(frames, frameType, role, m_name);
0336   } else if (frameType != Frame::FT_UnknownFrame) {
0337     addCredit(frames, frameType, m_name);
0338   }
0339 }
0340 
0341 
0342 /**
0343  * Stores information about a track.
0344  */
0345 class TrackInfo {
0346 public:
0347   /**
0348    * Constructor.
0349    * @param track JSON object containing track information
0350    */
0351   explicit TrackInfo(const QJsonObject& track);
0352 
0353   void addToFrames(FrameCollection& frames,
0354                    const QList<ExtraArtist>& trackExtraArtists,
0355                    bool standardTags, bool additionalTags) const;
0356 
0357   QString title() const { return m_title; }
0358   QString disc() const { return m_disc; }
0359   QString position() const { return m_position; }
0360   int pos() const { return m_pos; }
0361   int duration() const { return m_duration; }
0362 
0363 private:
0364   QString m_title;
0365   QString m_disc;
0366   QString m_position;
0367   int m_pos;
0368   int m_duration;
0369 };
0370 
0371 /**
0372  * Constructor.
0373  * @param track JSON object containing track information
0374  */
0375 TrackInfo::TrackInfo(const QJsonObject& track)
0376   : m_pos(0), m_duration(0)
0377 {
0378   QRegularExpression discTrackPosRe(QLatin1String("^(\\d+)-(\\d+)$"));
0379   m_position = track.value(QLatin1String("position")).toString();
0380   bool ok;
0381   m_pos = m_position.toInt(&ok);
0382   if (!ok) {
0383     if (auto match = discTrackPosRe.match(m_position); match.hasMatch()) {
0384       m_disc = match.captured(1);
0385       m_pos = match.captured(2).toInt();
0386     }
0387   }
0388   m_title = track.value(QLatin1String("title")).toString().trimmed();
0389 
0390   m_duration = 0;
0391   if (track.contains(QLatin1String("duration"))) {
0392     const QStringList durationHms = track.value(QLatin1String("duration"))
0393         .toString().split(QLatin1Char(':'));
0394     for (const auto& val : durationHms) {
0395       m_duration *= 60;
0396       m_duration += val.toInt();
0397     }
0398   } else {
0399     m_duration = track.value(QLatin1String("durationInSeconds")).toInt();
0400   }
0401 }
0402 
0403 void TrackInfo::addToFrames(FrameCollection& frames,
0404                             const QList<ExtraArtist>& trackExtraArtists,
0405                             bool standardTags, bool additionalTags) const
0406 {
0407   if (standardTags) {
0408     frames.setTrack(m_pos);
0409     frames.setTitle(m_title);
0410   }
0411   if (additionalTags && !m_disc.isNull()) {
0412     frames.setValue(Frame::FT_Disc, m_disc);
0413   }
0414   if (additionalTags && m_pos == 0 && !m_position.isEmpty()) {
0415     // Support tracks which are not numeric, e.g. "A2"
0416     frames.setValue(Frame::FT_Track, m_position);
0417   }
0418   for (const auto& extraArtist : trackExtraArtists) {
0419     extraArtist.addToFrames(frames, m_position);
0420   }
0421 }
0422 
0423 
0424 /**
0425  * Parse album results from a JSON object.
0426  * @param map JSON object, returned object from API import, "Release..."
0427  * property when getting it from the HTML output
0428  * @param importer Discogs importer
0429  * @param trackDataModel track data model to update with imported data
0430  * @param data optional top level data
0431  * @return true if at least one title was found.
0432  */
0433 bool parseJsonAlbumResults(const QJsonObject& map,
0434     const DiscogsImporter* importer, TrackDataModel* trackDataModel,
0435     const QJsonObject& data = QJsonObject())
0436 {
0437   // releases have the format (JSON, simplified):
0438   // { "styles": ["Heavy Metal"],
0439   //   "labels": [{"name": "LMP"}],
0440   //   "year": 2003,
0441   //   "artists": [{"name": "Wizard (23)"}],
0442   //   "images": [
0443   //   { "uri": "http://api.discogs.com/image/R-2487778-1293847958.jpeg",
0444   //     "type": "primary" },
0445   //   { "uri": "http://api.discogs.com/image/R-2487778-1293847967.jpeg",
0446   //     "type": "secondary" }],
0447   //   "id": 2487778,
0448   //   "genres": ["Rock"],
0449   //   "thumb": "http://api.discogs.com/image/R-150-2487778-1293847958.jpeg",
0450   //   "extraartists": [],
0451   //   "title": "Odin",
0452   //   "tracklist": [
0453   //     {"duration": "5:19", "position": "1", "title": "The Prophecy"},
0454   //     {"duration": "", "position": "Video", "title": "Betrayer"}
0455   //   ],
0456   //   "released": "2003",
0457   //   "formats": [{"name": "CD"}]
0458   // }
0459   QRegularExpression discTrackPosRe(QLatin1String("^(\\d+)-(\\d+)$"));
0460   QRegularExpression yearRe(QLatin1String("^\\d{4}-\\d{2}"));
0461   QList<ExtraArtist> trackExtraArtists;
0462   ImportTrackDataVector trackDataVector(trackDataModel->getTrackData());
0463   FrameCollection framesHdr;
0464   const bool standardTags = importer->getStandardTags();
0465   if (standardTags) {
0466     framesHdr.setAlbum(map.value(QLatin1String("title")).toString().trimmed());
0467     framesHdr.setArtist(
0468           getArtistString((map.contains(QLatin1String("artists"))
0469                            ? map.value(QLatin1String("artists"))
0470                            : map.value(QLatin1String("primaryArtists")))
0471                           .toArray()));
0472 
0473     // The year can be found in "released".
0474     QString released(map.value(QLatin1String("released")).toString());
0475     if (auto match = yearRe.match(released); match.hasMatch()) {
0476       released.truncate(4);
0477     }
0478     framesHdr.setYear(released.toInt());
0479 
0480     // The genre can be found in "genre" or "style".
0481     // All genres found are checked for an ID3v1 number, starting with those
0482     // in the style field.
0483     // Converted to QVariantList because adding two QJsonArray will add the
0484     // second array as a single element to the first array.
0485     const auto genreList =
0486         map.value(QLatin1String("styles")).toArray().toVariantList() +
0487         map.value(QLatin1String("genres")).toArray().toVariantList();
0488     QStringList genres, customGenres;
0489     for (const auto& val : genreList) {
0490       if (QString genre = val.toString().trimmed(); !genre.isEmpty()) {
0491         if (int genreNum = Genres::getNumber(genre); genreNum != 255) {
0492           genres.append(QString::fromLatin1(Genres::getName(genreNum)));
0493         } else {
0494           customGenres.append(genre);
0495         }
0496       }
0497     }
0498     genres.append(customGenres);
0499     if (!genres.isEmpty()) {
0500       framesHdr.setGenre(Frame::joinStringList(genres));
0501     }
0502   }
0503 
0504   trackDataVector.setCoverArtUrl(QUrl());
0505   if (importer->getCoverArt()) {
0506     // Cover art can be found in "images"
0507     if (auto images = map.value(QLatin1String("images")).toArray();
0508         !images.isEmpty()) {
0509       trackDataVector.setCoverArtUrl(
0510             QUrl(images.first().toObject().value(QLatin1String("uri"))
0511                  .toString()));
0512     }
0513   }
0514 
0515   const bool additionalTags = importer->getAdditionalTags();
0516   if (additionalTags) {
0517     // Publisher can be found in "label"
0518     if (auto labels = map.value(QLatin1String("labels")).toArray();
0519         !labels.isEmpty()) {
0520       auto firstLabelMap = labels.first().toObject();
0521       if (QString catNo = (firstLabelMap.contains(QLatin1String("catno"))
0522                            ? firstLabelMap.value(QLatin1String("catno"))
0523                            : firstLabelMap.value(QLatin1String("catalogNumber")))
0524                           .toString().trimmed();
0525           !catNo.isEmpty() && catNo.toLower() != QLatin1String("none")) {
0526         framesHdr.setValue(Frame::FT_CatalogNumber, catNo);
0527       }
0528       if (!firstLabelMap.contains(QLatin1String("name")) &&
0529           firstLabelMap.contains(QLatin1String("label"))) {
0530         firstLabelMap = firstLabelMap.value(QLatin1String("label")).toObject();
0531       }
0532       framesHdr.setValue(Frame::FT_Publisher,
0533           fixUpArtist(firstLabelMap.value(QLatin1String("name")).toString()));
0534     }
0535     // Media can be found in "formats"
0536     if (auto formats = map.value(QLatin1String("formats")).toArray();
0537         !formats.isEmpty()) {
0538       framesHdr.setValue(Frame::FT_Media,
0539                          formats.first().toObject().value(QLatin1String("name"))
0540                          .toString().trimmed());
0541     }
0542     // Credits can be found in "extraartists"
0543     if (const auto extraartists =
0544           (map.contains(QLatin1String("extraartists"))
0545             ? map.value(QLatin1String("extraartists"))
0546             : map.value(QLatin1String("releaseCredits")))
0547           .toArray();
0548         !extraartists.isEmpty()) {
0549       for (const auto& val : extraartists) {
0550         if (ExtraArtist extraArtist(val.toObject());
0551             extraArtist.hasTrackRestriction()) {
0552           trackExtraArtists.append(extraArtist);
0553         } else {
0554           extraArtist.addToFrames(framesHdr);
0555         }
0556       }
0557     }
0558     // Release country can be found in "country"
0559     if (QString country(map.value(QLatin1String("country")).toString().trimmed());
0560         !country.isEmpty()) {
0561       framesHdr.setValue(Frame::FT_ReleaseCountry, country);
0562     }
0563   }
0564 
0565   FrameCollection frames(framesHdr);
0566   auto it = trackDataVector.begin();
0567   int trackNr = 1;
0568   bool atTrackDataListEnd = it == trackDataVector.end();
0569   bool titleFound = false;
0570 
0571   auto addFramesToTrackData =
0572       [&atTrackDataListEnd, &trackDataVector, &it, &trackNr, &titleFound](
0573       FrameCollection& frms, int duration) {
0574     if (!frms.getTitle().isEmpty()) {
0575       titleFound = true;
0576     }
0577     if (frms.getValue(Frame::FT_Track).isEmpty()) {
0578       // Track as string is used instead of "frames.getTrack() == 0" to support
0579       // tracks like "A2"
0580       frms.setTrack(trackNr);
0581     }
0582     if (atTrackDataListEnd) {
0583       ImportTrackData trackData;
0584       trackData.setFrameCollection(frms);
0585       trackData.setImportDuration(duration);
0586       trackDataVector.append(trackData);
0587     } else {
0588       while (!atTrackDataListEnd && !it->isEnabled()) {
0589         ++it;
0590         atTrackDataListEnd = it == trackDataVector.end();
0591       }
0592       if (!atTrackDataListEnd) {
0593         it->setFrameCollection(frms);
0594         it->setImportDuration(duration);
0595         ++it;
0596         atTrackDataListEnd = it == trackDataVector.end();
0597       }
0598     }
0599     ++trackNr;
0600   };
0601 
0602   const auto trackList = map.value(map.contains(QLatin1String("tracklist"))
0603                                    ? QLatin1String("tracklist")
0604                                    : QLatin1String("tracks")).toArray();
0605 
0606   // Check if all positions are empty.
0607   bool allPositionsEmpty = true;
0608   for (const auto& val : trackList) {
0609     if (!val.toObject().value(QLatin1String("position")).toString().isEmpty()) {
0610       allPositionsEmpty = false;
0611       break;
0612     }
0613   }
0614 
0615   for (const auto& val : trackList) {
0616     auto track = val.toObject();
0617     if (track.size() == 1 && track.contains(QLatin1String("__ref")) &&
0618         !data.isEmpty()) {
0619       if (const QJsonObject trackRef = data.value(
0620             track.value(QLatin1String("__ref")).toString()).toObject();
0621           trackRef.contains(QLatin1String("title"))) {
0622         track = trackRef;
0623       }
0624     }
0625 
0626     // Do not include heading tracks
0627     if (track.value(QLatin1String("trackType")).toString() ==
0628         QLatin1String("HEADING")) {
0629       continue;
0630     }
0631 
0632     TrackInfo trackInfo(track);
0633     if (const auto artists((track.contains(QLatin1String("artists"))
0634                               ? track.value(QLatin1String("artists"))
0635                               : track.value(QLatin1String("primaryArtists")))
0636                              .toArray());
0637         !artists.isEmpty()) {
0638       if (standardTags) {
0639         frames.setArtist(getArtistString(artists));
0640       }
0641       if (additionalTags) {
0642         frames.setValue(Frame::FT_AlbumArtist, framesHdr.getArtist());
0643       }
0644     }
0645     if (additionalTags) {
0646       if (const auto extraartists((track.contains(QLatin1String("extraartists"))
0647                                      ? track.value(QLatin1String("extraartists"))
0648                                      : track.value(QLatin1String("trackCredits")))
0649                                     .toArray());
0650           !extraartists.isEmpty()) {
0651         for (const auto& eaVal : extraartists) {
0652           ExtraArtist extraArtist(eaVal.toObject());
0653           extraArtist.addToFrames(frames);
0654         }
0655       }
0656     }
0657     if (!allPositionsEmpty && trackInfo.position().isEmpty()) {
0658       if (const auto subTracks((track.contains(QLatin1String("sub_tracks"))
0659                                   ? track.value(QLatin1String("sub_tracks"))
0660                                   : track.value(QLatin1String("subTracks")))
0661                                  .toArray());
0662           !subTracks.isEmpty()) {
0663         if (additionalTags) {
0664           frames.setValue(Frame::FT_Subtitle, trackInfo.title());
0665         }
0666         for (const auto& stVal : subTracks) {
0667           TrackInfo subTrackInfo(stVal.toObject());
0668           subTrackInfo.addToFrames(frames, trackExtraArtists,
0669                                    standardTags, additionalTags);
0670           addFramesToTrackData(frames, subTrackInfo.duration());
0671         }
0672       }
0673     } else if (!trackInfo.title().isEmpty() || trackInfo.duration() != 0) {
0674       trackInfo.addToFrames(frames, trackExtraArtists,
0675                             standardTags, additionalTags);
0676       addFramesToTrackData(frames, trackInfo.duration());
0677     }
0678     frames = framesHdr;
0679   }
0680   // handle redundant tracks
0681   frames.clear();
0682   while (!atTrackDataListEnd) {
0683     if (it->isEnabled()) {
0684       if (it->getFileDuration() == 0) {
0685         it = trackDataVector.erase(it);
0686       } else {
0687         it->setFrameCollection(frames);
0688         it->setImportDuration(0);
0689         ++it;
0690       }
0691     } else {
0692       ++it;
0693     }
0694     atTrackDataListEnd = it == trackDataVector.end();
0695   }
0696   trackDataModel->setTrackData(trackDataVector);
0697   return titleFound;
0698 }
0699 
0700 }
0701 
0702 
0703 /**
0704  * Abstract base class for Discogs importer implementations.
0705  */
0706 class DiscogsImporter::BaseImpl {
0707 public:
0708   BaseImpl(DiscogsImporter* importer, const char* url);
0709   virtual ~BaseImpl();
0710 
0711   virtual void parseFindResults(const QByteArray& searchStr) = 0;
0712   virtual void parseAlbumResults(const QByteArray& albumStr) = 0;
0713   virtual void sendFindQuery(
0714       const ServerImporterConfig* cfg,
0715       const QString& artist, const QString& album) = 0;
0716   virtual void sendTrackListQuery(
0717       const ServerImporterConfig* cfg,
0718       const QString& cat, const QString& id) = 0;
0719 
0720   AlbumListModel* albumListModel() { return m_importer->m_albumListModel; }
0721   TrackDataModel* trackDataModel() { return m_importer->m_trackDataModel; }
0722   QMap<QByteArray, QByteArray>& headers() { return m_discogsHeaders; }
0723 
0724 protected:
0725   QMap<QByteArray, QByteArray> m_discogsHeaders;
0726   DiscogsImporter* m_importer;
0727   const char* const m_discogsServer;
0728 };
0729 
0730 DiscogsImporter::BaseImpl::BaseImpl(DiscogsImporter* importer, const char* url)
0731   : m_importer(importer), m_discogsServer(url)
0732 {
0733 }
0734 
0735 DiscogsImporter::BaseImpl::~BaseImpl()
0736 {
0737 }
0738 
0739 
0740 /**
0741  * Importer implementation to import HTML data from the Discogs web site.
0742  */
0743 class DiscogsImporter::HtmlImpl : public DiscogsImporter::BaseImpl {
0744 public:
0745   explicit HtmlImpl(DiscogsImporter* importer);
0746   ~HtmlImpl() override;
0747 
0748   void parseFindResults(const QByteArray& searchStr) override;
0749   void parseAlbumResults(const QByteArray& albumStr) override;
0750   void sendFindQuery(
0751       const ServerImporterConfig* cfg,
0752       const QString& artist, const QString& album) override;
0753   void sendTrackListQuery(
0754       const ServerImporterConfig* cfg,
0755       const QString& cat, const QString& id) override;
0756 };
0757 
0758 DiscogsImporter::HtmlImpl::HtmlImpl(DiscogsImporter* importer)
0759   : BaseImpl(importer, "www.discogs.com")
0760 {
0761   m_discogsHeaders["User-Agent"] =
0762       "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_3_2 like Mac OS X; en-us) "
0763       "AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8H7 "
0764       "Safari/6533.18.5";
0765   m_discogsHeaders["Cookie"] = "language2=en";
0766 }
0767 
0768 DiscogsImporter::HtmlImpl::~HtmlImpl()
0769 {
0770 }
0771 
0772 void DiscogsImporter::HtmlImpl::parseFindResults(const QByteArray& searchStr)
0773 {
0774   // releases have the format:
0775   // <a href="/artist/256076-Amon-Amarth">Amon Amarth</a>         </span> -
0776   // <a class="search_result_title " href="/Amon-Amarth-The-Avenger/release/761529-Amon-Amarth-The-Avenger" data-followable="true">The Avenger</a>
0777   QString str = QString::fromUtf8(searchStr);
0778   QRegularExpression idTitleRe(QLatin1String(
0779       "href=\"/artist/[^>]+?>([^<]+?)</a>[^-]*?-"
0780       "\\s*?<a class=\"search_result_title[ \"]+?href=\"/([^/]*?/?release)/"
0781       "([0-9]+-[^\"]+?)\"[^>]*?>([^<]+?)</a>(.*?card_actions)"),
0782        QRegularExpression::DotMatchesEverythingOption);
0783 
0784   QRegularExpression yearRe(QLatin1String("<span class=\"card_release_year\">([^<]+)</span>"));
0785   QRegularExpression formatRe(QLatin1String("<span class=\"card_release_format\">([^<]+)</span>"));
0786 
0787   albumListModel()->clear();
0788   auto it = idTitleRe.globalMatch(str);
0789   while (it.hasNext()) {
0790     auto idTitleMatch = it.next();
0791     QString artist = fixUpArtist(idTitleMatch.captured(1).trimmed());
0792     if (QString title = removeHtml(idTitleMatch.captured(4).trimmed());
0793         !title.isEmpty()) {
0794       QString result(artist + QLatin1String(" - ") + title);
0795 
0796       QString metadata = idTitleMatch.captured(5);
0797       if (auto yearMatch = yearRe.match(metadata); yearMatch.hasMatch()) {
0798         result.append(QLatin1String(" (") + yearMatch.captured(1).trimmed() +
0799           QLatin1Char(')'));
0800       }
0801 
0802       if (auto formatMatch = formatRe.match(metadata); formatMatch.hasMatch()) {
0803           result.append(QLatin1String(" [") + formatMatch.captured(1).trimmed() +
0804             QLatin1Char(']'));
0805       }
0806 
0807       albumListModel()->appendItem(
0808         result,
0809         idTitleMatch.captured(2),
0810         idTitleMatch.captured(3));
0811     }
0812   }
0813 }
0814 
0815 void DiscogsImporter::HtmlImpl::parseAlbumResults(const QByteArray& albumStr)
0816 {
0817   if (int jsonStart = albumStr.indexOf("<script id=\"dsdata\" type=\"application/json\">");
0818       jsonStart >= 0) {
0819     jsonStart += 44;
0820     if (int jsonEnd = albumStr.indexOf("</script>", jsonStart);
0821         jsonEnd > jsonStart) {
0822       // We have JSON data inside the HTML output, if it is usable, we do not
0823       // have to parse the HTML output.
0824       if (auto doc = QJsonDocument::fromJson(albumStr.mid(jsonStart, jsonEnd - jsonStart));
0825           !doc.isNull() && doc.isObject()) {
0826         if (const auto dataValue = doc.object().value(QLatin1String("data"));
0827             dataValue.isObject()) {
0828           const auto data = dataValue.toObject();
0829           QJsonObject release;
0830           QString imgUrl;
0831 
0832           // There are multiple releases in the embedded JSON,
0833           // try to find the correct one.
0834           const auto rootQuery = data.value(QLatin1String("ROOT_QUERY"))
0835               .toObject();
0836           for (auto it = rootQuery.constBegin();
0837                it != rootQuery.constEnd();
0838                ++it) {
0839             if (QString releaseRef;
0840                 it.key().startsWith(QLatin1String("release")) &&
0841                 !(releaseRef = it.value().toObject()
0842                   .value(QLatin1String("__ref")).toString()).isEmpty()) {
0843               if (QJsonObject releaseObject = data.value(releaseRef).toObject();
0844                   releaseObject.contains(QLatin1String("tracks"))) {
0845                 release = releaseObject;
0846                 break;
0847               }
0848             }
0849           }
0850 
0851           // There are multiple images in the embedded JSON,
0852           // try to find the correct one.
0853           if (!release.isEmpty()) {
0854             for (auto it = release.constBegin(); it != release.constEnd(); ++it) {
0855               if (QString imageRef;
0856                   it.key().startsWith(QLatin1String("images")) &&
0857                   !(imageRef = it.value().toObject()
0858                     .value(QLatin1String("edges")).toArray().first().toObject()
0859                     .value(QLatin1String("node")).toObject()
0860                     .value(QLatin1String("__ref")).toString()).isEmpty()) {
0861                 imgUrl = extractUrlFromImageValue(data.value(imageRef));
0862               }
0863             }
0864           }
0865 
0866           for (auto it = data.constBegin(); it != data.constEnd(); ++it) {
0867             if (it.key().startsWith(QLatin1String("Release:"))) {
0868               if (QJsonValue releaseValue;
0869                   release.isEmpty() && (releaseValue = it.value()).isObject()) {
0870                 if (QJsonObject releaseObject = releaseValue.toObject();
0871                     releaseObject.contains(QLatin1String("tracks"))) {
0872                   release = releaseObject;
0873                 }
0874               }
0875             } else if (it.key().startsWith(QLatin1String("Image:"))) {
0876               if (imgUrl.isEmpty()) {
0877                 imgUrl = extractUrlFromImageValue(it.value());
0878               }
0879             }
0880           }
0881           if (!release.isEmpty()) {
0882             if (!imgUrl.isEmpty()) {
0883               release.insert(QLatin1String("images"),
0884                              QJsonArray({QJsonObject({{QLatin1String("uri"),
0885                                                        imgUrl}})}));
0886             }
0887             if (parseJsonAlbumResults(release, m_importer, trackDataModel(),
0888                                       data)) {
0889               return;
0890             }
0891           }
0892         }
0893       }
0894     }
0895   }
0896 
0897   QRegularExpression nlSpaceRe(QLatin1String("[\r\n]+\\s*"));
0898   QRegularExpression atDiscogsRe(QLatin1String("\\s*\\([^)]+\\) (?:at|-|\\|) Discogs\n?$"));
0899   QString str = QString::fromUtf8(albumStr);
0900   str.remove(QLatin1String(" data-rh=\"\"")).remove(QLatin1String("<!-- -->"))
0901      .replace(QLatin1Char(' ') + QChar(0x2013) + QLatin1Char(' '),
0902               QLatin1String(" - "));
0903 
0904   FrameCollection framesHdr;
0905   int start, end;
0906   const bool standardTags = m_importer->getStandardTags();
0907   if (standardTags) {
0908     /*
0909      * artist and album can be found in the title:
0910 <title>Amon Amarth - The Avenger (CD, Album, Dig) at Discogs</title>
0911      */
0912     start = str.indexOf(QLatin1String("<title>"));
0913     if (start >= 0) {
0914       start += 7; // skip <title>
0915       end = str.indexOf(QLatin1String("</title>"), start);
0916       if (end > start) {
0917         QString titleStr = str.mid(start, end - start);
0918         titleStr.replace(atDiscogsRe, QLatin1String(""));
0919         // reduce new lines and space after them
0920         titleStr.replace(nlSpaceRe, QLatin1String(" "));
0921         start = 0;
0922         end = titleStr.indexOf(QLatin1String(" - "), start);
0923         if (end > start) {
0924           framesHdr.setArtist(fixUpArtist(titleStr.mid(start, end - start)));
0925           start = end + 3; // skip " - "
0926         }
0927         framesHdr.setAlbum(removeHtml(titleStr.mid(start)));
0928       }
0929     }
0930     /*
0931      * the year can be found in "Released:"
0932 <div class="head">Released:</div><div class="content">02 Nov 1999</div>
0933      */
0934     start = str.indexOf(QLatin1String("Released:<"));
0935     if (start >= 0) {
0936       start += 9; // skip "Released:"
0937       end = str.indexOf(QLatin1String("</div>"), start + 1);
0938       if (end > start) {
0939         QString yearStr = str.mid(start, end - start);
0940         // strip new lines and space after them
0941         yearStr.replace(nlSpaceRe, QLatin1String(""));
0942         yearStr = removeHtml(yearStr); // strip HTML tags and entities
0943         // this should skip day and month numbers
0944         QRegularExpression yearRe(QLatin1String("(\\d{4})"));
0945         if (auto match = yearRe.match(yearStr); match.hasMatch()) {
0946           framesHdr.setYear(match.captured(1).toInt());
0947         }
0948       }
0949     }
0950     /*
0951      * the genre can be found in "Genre:" or "Style:" (lines with only whitespace
0952      *  in between):
0953 <div class="head">Genre:</div><div class="content">
0954       Rock
0955 </div>
0956 <div class="head">Style:</div><div class="content">
0957     Viking Metal,
0958     Death Metal
0959 </div>
0960      */
0961     // All genres found are checked for an ID3v1 number, starting with those
0962     // in the Style field.
0963     QStringList genreList;
0964     static const char* const fields[] = { "Style:", "Genre:" };
0965     for (auto field : fields) {
0966       start = str.indexOf(QString::fromLatin1(field) + QLatin1Char('<'));
0967       if (start >= 0) {
0968         start += qstrlen(field); // skip field
0969         end = str.indexOf(QLatin1String("</div>"), start + 1);
0970         if (end > start) {
0971           QString genreStr = str.mid(start, end - start);
0972           // strip new lines and space after them
0973           genreStr.replace(nlSpaceRe, QLatin1String(""));
0974           genreStr = removeHtml(genreStr); // strip HTML tags and entities
0975           genreStr.remove(QLatin1String("RockStyle:"));
0976           genreStr.remove(QLatin1String("PopStyle:"));
0977           if (genreStr.indexOf(QLatin1Char(',')) >= 0) {
0978             genreList += genreStr.split(QRegularExpression(QLatin1String(",\\s*")));
0979           } else {
0980             if (!genreStr.isEmpty()) {
0981               genreList += genreStr;
0982             }
0983           }
0984         }
0985       }
0986     }
0987     QStringList genres;
0988     for (auto it = genreList.begin(); it != genreList.end();) {
0989       if (int genreNum = Genres::getNumber(*it); genreNum != 255) {
0990         genres.append(QString::fromLatin1(Genres::getName(genreNum)));
0991         it = genreList.erase(it);
0992       } else {
0993         ++it;
0994       }
0995     }
0996     genres.append(genreList);
0997     genres.removeDuplicates();
0998     if (!genres.isEmpty()) {
0999       framesHdr.setGenre(Frame::joinStringList(genres));
1000     }
1001   }
1002 
1003   const bool additionalTags = m_importer->getAdditionalTags();
1004   if (additionalTags) {
1005     /*
1006      * publisher can be found in "Label:"
1007      */
1008     start = str.indexOf(QLatin1String("Label:<"));
1009     if (start >= 0) {
1010       start += 6; // skip "Label:"
1011       end = str.indexOf(QLatin1String("</div>"), start + 1);
1012       if (int anchorEnd = str.indexOf(QLatin1String("</a>"), start + 1);
1013           anchorEnd > start && anchorEnd < end) {
1014         end = anchorEnd;
1015       }
1016       if (end > start) {
1017         QString labelStr = str.mid(start, end - start);
1018         // strip new lines and space after them
1019         labelStr.replace(nlSpaceRe, QLatin1String(""));
1020         labelStr = fixUpArtist(labelStr);
1021         QRegularExpression catNoRe(QLatin1String(" \\s*(?:&lrm;)?- +(\\S[^,]*[^, ])"));
1022         if (auto match = catNoRe.match(labelStr); match.hasMatch()) {
1023           int catNoPos = match.capturedStart();
1024           QString catNo = match.captured(1);
1025           labelStr.truncate(catNoPos);
1026           if (!catNo.isEmpty()) {
1027             framesHdr.setValue(Frame::FT_CatalogNumber, catNo);
1028           }
1029         }
1030         if (labelStr != QLatin1String("Not On Label")) {
1031           framesHdr.setValue(Frame::FT_Publisher, fixUpArtist(labelStr));
1032         }
1033       }
1034     }
1035 
1036     /*
1037      * media can be found in "Format:"
1038      */
1039     start = str.indexOf(QLatin1String("Format:<"));
1040     if (start >= 0) {
1041       start += 7; // skip "Format:"
1042       end = str.indexOf(QLatin1String("</div>"), start + 1);
1043       if (end > start) {
1044         QString mediaStr = str.mid(start, end - start);
1045         // strip new lines and space after them
1046         mediaStr.replace(nlSpaceRe, QLatin1String(""));
1047         mediaStr = removeHtml(mediaStr); // strip HTML tags and entities
1048         framesHdr.setValue(Frame::FT_Media, mediaStr);
1049       }
1050     }
1051 
1052     /*
1053      * Release country can be found in "Country:"
1054      */
1055     start = str.indexOf(QLatin1String("Country:<"));
1056     if (start >= 0) {
1057       start += 8; // skip "Country:"
1058       end = str.indexOf(QLatin1String("</div>"), start + 1);
1059       if (int anchorEnd = str.indexOf(QLatin1String("</a>"), start + 1);
1060           anchorEnd > start && anchorEnd < end) {
1061         end = anchorEnd;
1062       }
1063       if (end > start) {
1064         QString countryStr = str.mid(start, end - start);
1065         // strip new lines and space after them
1066         countryStr.replace(nlSpaceRe, QLatin1String(""));
1067         countryStr = removeHtml(countryStr); // strip HTML tags and entities
1068         framesHdr.setValue(Frame::FT_ReleaseCountry, countryStr);
1069       }
1070     }
1071 
1072     /*
1073      * credits can be found in "Credits"
1074      */
1075     start = str.indexOf(QLatin1String(">Credits</h"));
1076     if (start >= 0) {
1077       start += 13; // skip "Credits" plus end of element (e.g. "3>")
1078       end = str.indexOf(QLatin1String("</div>"), start + 1);
1079       if (end > start) {
1080         QString creditsStr = str.mid(start, end - start);
1081         // strip new lines and space after them
1082         creditsStr.replace(nlSpaceRe, QLatin1String(""));
1083         creditsStr.replace(QLatin1String("<br />"), QLatin1String("\n"));
1084         creditsStr.replace(QLatin1String("</li>"), QLatin1String("\n"));
1085         creditsStr.replace(QLatin1String("&ndash;"), QLatin1String(" - "));
1086         creditsStr = removeHtml(creditsStr); // strip HTML tags and entities
1087         parseCredits(creditsStr, framesHdr);
1088       }
1089     }
1090   }
1091 
1092   ImportTrackDataVector trackDataVector(trackDataModel()->getTrackData());
1093   trackDataVector.setCoverArtUrl(QUrl());
1094   if (m_importer->getCoverArt()) {
1095     /*
1096      * cover art can be found in image source
1097      */
1098     // Using a raw string literal in the next line would disturb doxygen.
1099     start = str.indexOf(QLatin1String("<meta property=\"og:image\" content=\""));
1100     if (start >= 0) {
1101       start += 35;
1102       end = str.indexOf(QLatin1String("\""), start);
1103       if (end > start) {
1104         trackDataVector.setCoverArtUrl(QUrl(str.mid(start, end - start)));
1105       }
1106     }
1107   }
1108 
1109   /*
1110    * album tracks have the format (lines with only whitespace in between):
1111 <div id="tracklist" class="section tracklist" data-toggle="tracklist">
1112                     <td class="tracklist_track_pos">1</td>
1113 <span class="tracklist_track_title" itemprop="name">Bleed For Ancient Gods</span>
1114         <td width="25" class="tracklist_track_duration">
1115             <meta itemprop="duration" content="PT0H04M31S">
1116             <span>4:31</span>
1117         </td>
1118 
1119 <h1>Tracklist</h1>
1120 <div class="section_content">
1121 <table>
1122   <tr class="first">
1123     <td class="track_pos">1</td>
1124       <td>&nbsp;</td>
1125     <td class="track_title">Bleed For Ancient Gods</td>
1126     <td class="track_duration">4:31</td>
1127     <td class="track_itunes"></td>
1128   </tr>
1129   <tr>
1130     <td class="track_pos">2</td>
1131 (..)
1132 </table>
1133    *
1134    * Variations: strange track numbers, no durations, links instead of tracks,
1135    * only "track" instead of "track_title", align attribute in "track_duration"
1136    */
1137   start = str.indexOf(QLatin1String("id=\"release-tracklist\""));
1138   if (start >= 0) {
1139     end = str.indexOf(QLatin1String("</table>"), start);
1140     if (end > start) {
1141       str = str.mid(start, end - start);
1142       // strip whitespace
1143       str.replace(nlSpaceRe, QLatin1String(""));
1144 
1145       FrameCollection frames(framesHdr);
1146       QRegularExpression posRe(QLatin1String(
1147         R"(<td [^>]*class="trackPos[^"]*">(\d+)</td>)"));
1148       QRegularExpression artistsRe(QLatin1String(
1149         "class=\"trackArtist[^\"]*\">(?:<span[^>]*>)?"
1150         "<a href=\"/artist/[^>]+>([^<]+)</a>"));
1151       QRegularExpression moreArtistsRe(QLatin1String(
1152         "^([^<>]+)<a href=\"/artist/[^>]+>([^<]+)</a>"));
1153       QRegularExpression titleRe(QLatin1String(
1154         "<span class=\"trackTitle[^\"]*\"[^>]*>([^<]+)<"));
1155       QRegularExpression durationRe(QLatin1String(
1156         "<td [^>]*class=\"duration[^\"]*\"[^>]*>(?:<meta[^>]*>)?"
1157         "(?:<span>)?(\\d+):(\\d+)</"));
1158       QRegularExpression indexRe(QLatin1String("<td class=\"track_index\">([^<]+)$"));
1159       QRegularExpression rowEndRe(QLatin1String(R"(</td>[\s\r\n]*</tr>)"));
1160       auto it = trackDataVector.begin();
1161       bool atTrackDataListEnd = it == trackDataVector.end();
1162       int trackNr = 1;
1163       start = 0;
1164       auto rowEndIt = rowEndRe.globalMatch(str);
1165       while (rowEndIt.hasNext()) {
1166         auto rowEndMatch = rowEndIt.next();
1167         end = rowEndMatch.capturedStart();
1168         QString trackDataStr = str.mid(start, end - start);
1169         QString title;
1170         int duration = 0;
1171         int pos = trackNr;
1172         auto match = titleRe.match(trackDataStr);
1173         if (match.hasMatch()) {
1174           title = removeHtml(match.captured(1));
1175         }
1176         match = durationRe.match(trackDataStr);
1177         if (match.hasMatch()) {
1178           duration = match.captured(1).toInt() * 60 +
1179             match.captured(2).toInt();
1180         }
1181         match = posRe.match(trackDataStr);
1182         if (match.hasMatch()) {
1183           pos = match.captured(1).toInt();
1184         }
1185         if (additionalTags) {
1186           match = artistsRe.match(trackDataStr);
1187           if (match.hasMatch()) {
1188             // use the artist in the header as the album artist
1189             // and the artist in the track as the artist
1190             QString artist(fixUpArtist(match.captured(1)));
1191             // Look if there are more artists
1192             int artistEndPos = match.capturedEnd();
1193 #if QT_VERSION >= 0x060000
1194             auto moreArtistsIt = moreArtistsRe.globalMatch(
1195                   trackDataStr, artistEndPos, QRegularExpression::NormalMatch,
1196                   QRegularExpression::AnchorAtOffsetMatchOption);
1197 #else
1198             auto moreArtistsIt = moreArtistsRe.globalMatch(
1199                   trackDataStr, artistEndPos, QRegularExpression::NormalMatch,
1200                   QRegularExpression::AnchoredMatchOption);
1201 #endif
1202             while (moreArtistsIt.hasNext()) {
1203               match = moreArtistsIt.next();
1204               artist += match.captured(1);
1205               artist += fixUpArtist(match.captured(2));
1206             }
1207             if (standardTags) {
1208               frames.setArtist(artist);
1209             }
1210             frames.setValue(Frame::FT_AlbumArtist, framesHdr.getArtist());
1211           }
1212         }
1213         start = end + 10; // skip </td></tr>
1214         match = indexRe.match(trackDataStr);
1215         if (match.hasMatch()) {
1216           if (additionalTags) {
1217             QString subtitle(removeHtml(match.captured(1)));
1218             framesHdr.setValue(Frame::FT_Description, subtitle);
1219             frames.setValue(Frame::FT_Description, subtitle);
1220           }
1221           continue;
1222         }
1223         if (additionalTags) {
1224           if (int blockquoteStart =
1225                 trackDataStr.indexOf(QLatin1String("<blockquote>"));
1226               blockquoteStart >= 0) {
1227             blockquoteStart += 12;
1228             int blockquoteEnd =
1229                 trackDataStr.indexOf(QLatin1String("</blockquote>"),
1230                                      blockquoteStart);
1231             if (blockquoteEnd == -1) {
1232               // If the element is not correctly closed, search for </span>
1233               blockquoteEnd = trackDataStr.indexOf(QLatin1String("</span>"),
1234                                                    blockquoteStart);
1235             }
1236             if (blockquoteEnd > blockquoteStart) {
1237               QString blockquoteStr(trackDataStr.mid(blockquoteStart,
1238                 blockquoteEnd - blockquoteStart));
1239               // additional track info like "Music By, Lyrics By - "
1240               blockquoteStr.replace(QLatin1String("<br />"),
1241                                     QLatin1String("\n"));
1242               blockquoteStr.replace(QLatin1String("</li>"),
1243                                     QLatin1String("\n"));
1244               blockquoteStr.replace(QLatin1String("</span>"),
1245                                     QLatin1String("\n"));
1246               blockquoteStr.replace(QLatin1String(" &ndash; "),
1247                                     QLatin1String(" - "));
1248               blockquoteStr.replace(QLatin1String("&ndash;"),
1249                                     QLatin1String(" - "));
1250               blockquoteStr = removeHtml(blockquoteStr);
1251               parseCredits(blockquoteStr, frames);
1252             }
1253           }
1254         }
1255 
1256         if (!title.isEmpty() || duration != 0) {
1257           if (standardTags) {
1258             frames.setTrack(pos);
1259             frames.setTitle(title);
1260           }
1261           if (atTrackDataListEnd) {
1262             ImportTrackData trackData;
1263             trackData.setFrameCollection(frames);
1264             trackData.setImportDuration(duration);
1265             trackDataVector.push_back(trackData);
1266           } else {
1267             while (!atTrackDataListEnd && !it->isEnabled()) {
1268               ++it;
1269               atTrackDataListEnd = it == trackDataVector.end();
1270             }
1271             if (!atTrackDataListEnd) {
1272               it->setFrameCollection(frames);
1273               it->setImportDuration(duration);
1274               ++it;
1275               atTrackDataListEnd = it == trackDataVector.end();
1276             }
1277           }
1278           ++trackNr;
1279         }
1280         frames = framesHdr;
1281       }
1282 
1283       // handle redundant tracks
1284       frames.clear();
1285       while (!atTrackDataListEnd) {
1286         if (it->isEnabled()) {
1287           if (it->getFileDuration() == 0) {
1288             it = trackDataVector.erase(it);
1289           } else {
1290             it->setFrameCollection(frames);
1291             it->setImportDuration(0);
1292             ++it;
1293           }
1294         } else {
1295           ++it;
1296         }
1297         atTrackDataListEnd = it == trackDataVector.end();
1298       }
1299     }
1300   }
1301   trackDataModel()->setTrackData(trackDataVector);
1302 }
1303 
1304 void DiscogsImporter::HtmlImpl::sendFindQuery(
1305   const ServerImporterConfig*,
1306   const QString& artist, const QString& album)
1307 {
1308   /*
1309    * Query looks like this:
1310    * http://www.discogs.com/search/?q=amon+amarth+avenger&type=release&layout=sm
1311    */
1312   m_importer->sendRequest(QString::fromLatin1(m_discogsServer),
1313               QString(QLatin1String("/search/?q=")) +
1314               encodeUrlQuery(artist + QLatin1Char(' ') + album) +
1315               QLatin1String("&type=release&layout=sm"), QLatin1String("https"),
1316               m_discogsHeaders);
1317 }
1318 
1319 void DiscogsImporter::HtmlImpl::sendTrackListQuery(
1320   const ServerImporterConfig*, const QString& cat, const QString& id)
1321 {
1322   /*
1323    * Query looks like this:
1324    * http://www.discogs.com/release/761529
1325    */
1326   m_importer->sendRequest(QString::fromLatin1(m_discogsServer), QLatin1Char('/') +
1327               cat +
1328               QLatin1Char('/') + id, QLatin1String("https"), m_discogsHeaders);
1329 }
1330 
1331 
1332 /**
1333  * Importer implementation to import JSON data via the Discogs API.
1334  * A token is required to get data from the Discogs API.
1335  */
1336 class DiscogsImporter::JsonImpl : public DiscogsImporter::BaseImpl {
1337 public:
1338   explicit JsonImpl(DiscogsImporter* importer);
1339   ~JsonImpl() override;
1340 
1341   void parseFindResults(const QByteArray& searchStr) override;
1342   void parseAlbumResults(const QByteArray& albumStr) override;
1343   void sendFindQuery(
1344       const ServerImporterConfig* cfg,
1345       const QString& artist, const QString& album) override;
1346   void sendTrackListQuery(
1347       const ServerImporterConfig* cfg,
1348       const QString& cat, const QString& id) override;
1349 };
1350 
1351 DiscogsImporter::JsonImpl::JsonImpl(DiscogsImporter* importer)
1352   : BaseImpl(importer, "api.discogs.com")
1353 {
1354   m_discogsHeaders["User-Agent"] = "Kid3/" VERSION
1355       " +https://kid3.kde.org";
1356 }
1357 
1358 DiscogsImporter::JsonImpl::~JsonImpl()
1359 {
1360 }
1361 
1362 void DiscogsImporter::JsonImpl::parseFindResults(const QByteArray& searchStr)
1363 {
1364   // search results have the format (JSON, simplified):
1365   // {"results": [{"style": ["Heavy Metal"], "title": "Wizard (23) - Odin",
1366   //               "type": "release", "id": 2487778}]}
1367   albumListModel()->clear();
1368   if (auto doc = QJsonDocument::fromJson(searchStr); !doc.isNull()) {
1369     auto obj = doc.object();
1370     const auto results = obj.value(QLatin1String("results")).toArray();
1371     for (const auto& val : results) {
1372       auto result = val.toObject();
1373       if (QString title =
1374             fixUpArtist(result.value(QLatin1String("title")).toString());
1375           !title.isEmpty()) {
1376         if (QString year = result.value(QLatin1String("year")).toString().trimmed();
1377             !year.isEmpty()) {
1378           title += QLatin1String(" (") + year + QLatin1Char(')');
1379         }
1380         if (const auto fmts = result.value(QLatin1String("format")).toArray();
1381             !fmts.isEmpty()) {
1382           QStringList formats;
1383           for (const auto& fmt : fmts) {
1384             if (QString format = fmt.toString().trimmed(); !format.isEmpty()) {
1385               formats.append(format);
1386             }
1387           }
1388           if (!formats.isEmpty()) {
1389             title += QLatin1String(" [") +
1390                 formats.join(QLatin1String(", ")) +
1391                 QLatin1Char(']');
1392           }
1393         }
1394         albumListModel()->appendItem(
1395           title,
1396           QLatin1String("releases"),
1397           QString::number(result.value(QLatin1String("id")).toInt()));
1398       }
1399     }
1400   }
1401 }
1402 
1403 void DiscogsImporter::JsonImpl::parseAlbumResults(const QByteArray& albumStr)
1404 {
1405   auto doc = QJsonDocument::fromJson(albumStr);
1406   if (doc.isNull()) {
1407     return;
1408   }
1409   auto map = doc.object();
1410   if (map.isEmpty()) {
1411     return;
1412   }
1413 
1414   parseJsonAlbumResults(map, m_importer, trackDataModel());
1415 }
1416 
1417 void DiscogsImporter::JsonImpl::sendFindQuery(
1418   const ServerImporterConfig*,
1419   const QString& artist, const QString& album)
1420 {
1421   // Query looks like this:
1422   // http://api.discogs.com//database/search?type=release&title&q=amon+amarth+avenger
1423   m_importer->sendRequest(QString::fromLatin1(m_discogsServer),
1424               QLatin1String("/database/search?type=release&title&q=") +
1425               encodeUrlQuery(artist + QLatin1Char(' ') + album), QLatin1String("https"),
1426               m_discogsHeaders);
1427 }
1428 
1429 void DiscogsImporter::JsonImpl::sendTrackListQuery(
1430   const ServerImporterConfig*, const QString& cat, const QString& id)
1431 {
1432   // Query looks like this:
1433   // http://api.discogs.com/releases/761529
1434   m_importer->sendRequest(QString::fromLatin1(m_discogsServer), QLatin1Char('/') +
1435               cat +
1436               QLatin1Char('/') + id, QLatin1String("https"), m_discogsHeaders);
1437 }
1438 
1439 
1440 /**
1441  * Constructor.
1442  *
1443  * @param netMgr network access manager
1444  * @param trackDataModel track data to be filled with imported values
1445  */
1446 DiscogsImporter::DiscogsImporter(QNetworkAccessManager* netMgr,
1447                                  TrackDataModel* trackDataModel)
1448   : ServerImporter(netMgr, trackDataModel),
1449     m_htmlImpl(new HtmlImpl(this)), m_jsonImpl(new JsonImpl(this)),
1450     m_impl(m_htmlImpl)
1451 {
1452   setObjectName(QLatin1String("DiscogsImporter"));
1453 }
1454 
1455 /**
1456  * Destructor.
1457  */
1458 DiscogsImporter::~DiscogsImporter()
1459 {
1460   m_impl = nullptr;
1461   delete m_jsonImpl;
1462   delete m_htmlImpl;
1463 }
1464 
1465 /**
1466  * Name of import source.
1467  * @return name.
1468  */
1469 const char* DiscogsImporter::name() const {
1470   return QT_TRANSLATE_NOOP("@default", "Discogs");
1471 }
1472 
1473 /** anchor to online help, 0 to disable */
1474 const char* DiscogsImporter::helpAnchor() const { return "import-discogs"; }
1475 
1476 /** configuration, 0 if not used */
1477 ServerImporterConfig* DiscogsImporter::config() const {
1478   return &DiscogsConfig::instance();
1479 }
1480 
1481 /** additional tags option, false if not used */
1482 bool DiscogsImporter::additionalTags() const { return true; }
1483 
1484 /**
1485  * Process finished findCddbAlbum request.
1486  *
1487  * @param searchStr search data received
1488  */
1489 void DiscogsImporter::parseFindResults(const QByteArray& searchStr)
1490 {
1491   m_impl->parseFindResults(searchStr);
1492 }
1493 
1494 /**
1495  * Parse result of album request and populate m_trackDataModel with results.
1496  *
1497  * @param albumStr album data received
1498  */
1499 void DiscogsImporter::parseAlbumResults(const QByteArray& albumStr)
1500 {
1501   m_impl->parseAlbumResults(albumStr);
1502 }
1503 
1504 /**
1505  * Send a query command to search on the server.
1506  *
1507  * @param cfg      import source configuration
1508  * @param artist   artist to search
1509  * @param album    album to search
1510  */
1511 void DiscogsImporter::sendFindQuery(
1512   const ServerImporterConfig* cfg,
1513   const QString& artist, const QString& album)
1514 {
1515   // If an URL is entered in the first search field, its result will be directly
1516   // available in the album results list.
1517   if (artist.startsWith(QLatin1String("https://www.discogs.com/"))) {
1518     constexpr int catBegin = 24;
1519     if (int catEnd = artist.indexOf(QLatin1Char('/'), catBegin);
1520         catEnd > catBegin) {
1521       m_htmlImpl->albumListModel()->clear();
1522       m_htmlImpl->albumListModel()->appendItem(
1523             artist,
1524             artist.mid(catBegin, catEnd - catBegin),
1525             artist.mid(catEnd + 1));
1526       return;
1527     }
1528   }
1529   m_impl = selectImpl(cfg);
1530   m_impl->sendFindQuery(cfg, artist, album);
1531 }
1532 
1533 /**
1534  * Send a query command to fetch the track list
1535  * from the server.
1536  *
1537  * @param cfg      import source configuration
1538  * @param cat      category
1539  * @param id       ID
1540  */
1541 void DiscogsImporter::sendTrackListQuery(
1542   const ServerImporterConfig* cfg, const QString& cat, const QString& id)
1543 {
1544   m_impl = selectImpl(cfg);
1545   m_impl->sendTrackListQuery(cfg, cat, id);
1546 }
1547 
1548 /**
1549  * Set token to access Discogs API.
1550  * You have to create an account on Discogs and then generate a token
1551  * (Settings/Developers, Generate new token). The token can then be used for
1552  * the "Discogs Auth Flow" in the header "Authorization: Discogs token=value"
1553  * If a token is found in the configuration, the importer using the Discogs
1554  * API is used, else the HTML importer.
1555  * @param cfg configuration which can contain token
1556  */
1557 DiscogsImporter::BaseImpl* DiscogsImporter::selectImpl(
1558     const ServerImporterConfig* cfg) const
1559 {
1560   if (cfg) {
1561     if (QByteArray token = cfg->property("token").toByteArray();
1562         !token.isEmpty()) {
1563       m_jsonImpl->headers()["Authorization"] = "Discogs token=" + token;
1564       return m_jsonImpl;
1565     }
1566   }
1567   return m_htmlImpl;
1568 }