File indexing completed on 2024-05-12 16:45:46

0001 /***************************************************************************
0002     Copyright (C) 2008-2020 Robby Stephenson <robby@periapsis.org>
0003  ***************************************************************************/
0004 
0005 /***************************************************************************
0006  *                                                                         *
0007  *   This program is free software; you can redistribute it and/or         *
0008  *   modify it under the terms of the GNU General Public License as        *
0009  *   published by the Free Software Foundation; either version 2 of        *
0010  *   the License or (at your option) version 3 or any later version        *
0011  *   accepted by the membership of KDE e.V. (or its successor approved     *
0012  *   by the membership of KDE e.V.), which shall act as a proxy            *
0013  *   defined in Section 14 of version 3 of the license.                    *
0014  *                                                                         *
0015  *   This program is distributed in the hope that it will be useful,       *
0016  *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
0017  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
0018  *   GNU General Public License for more details.                          *
0019  *                                                                         *
0020  *   You should have received a copy of the GNU General Public License     *
0021  *   along with this program.  If not, see <http://www.gnu.org/licenses/>. *
0022  *                                                                         *
0023  ***************************************************************************/
0024 
0025 #include <config.h> // for TELLICO_VERSION
0026 
0027 #include "discogsfetcher.h"
0028 #include "../collections/musiccollection.h"
0029 #include "../images/imagefactory.h"
0030 #include "../utils/guiproxy.h"
0031 #include "../utils/string_utils.h"
0032 #include "../core/filehandler.h"
0033 #include "../tellico_debug.h"
0034 
0035 #include <KLocalizedString>
0036 #include <KConfigGroup>
0037 #include <KIO/Job>
0038 #include <KJobUiDelegate>
0039 #include <KJobWidgets/KJobWidgets>
0040 
0041 #include <QLineEdit>
0042 #include <QLabel>
0043 #include <QFile>
0044 #include <QTextStream>
0045 #include <QBoxLayout>
0046 #include <QJsonDocument>
0047 #include <QJsonObject>
0048 #include <QUrlQuery>
0049 
0050 namespace {
0051   static const int DISCOGS_MAX_RETURNS_TOTAL = 20;
0052   static const char* DISCOGS_API_URL = "https://api.discogs.com";
0053 }
0054 
0055 using namespace Tellico;
0056 using Tellico::Fetch::DiscogsFetcher;
0057 
0058 DiscogsFetcher::DiscogsFetcher(QObject* parent_)
0059     : Fetcher(parent_)
0060     , m_limit(DISCOGS_MAX_RETURNS_TOTAL)
0061     , m_started(false)
0062     , m_page(1) {
0063 }
0064 
0065 DiscogsFetcher::~DiscogsFetcher() {
0066 }
0067 
0068 QString DiscogsFetcher::source() const {
0069   return m_name.isEmpty() ? defaultName() : m_name;
0070 }
0071 
0072 bool DiscogsFetcher::canSearch(Fetch::FetchKey k) const {
0073   return k == Title || k == Person || k == Keyword || k == UPC;
0074 }
0075 
0076 bool DiscogsFetcher::canFetch(int type) const {
0077   return type == Data::Collection::Album;
0078 }
0079 
0080 void DiscogsFetcher::readConfigHook(const KConfigGroup& config_) {
0081   QString k = config_.readEntry("API Key");
0082   if(!k.isEmpty()) {
0083     m_apiKey = k;
0084   }
0085 }
0086 
0087 void DiscogsFetcher::setLimit(int limit_) {
0088   m_limit = qBound(1, limit_, DISCOGS_MAX_RETURNS_TOTAL);
0089 }
0090 
0091 void DiscogsFetcher::search() {
0092   m_page = 1;
0093   continueSearch();
0094 }
0095 
0096 void DiscogsFetcher::continueSearch() {
0097   m_started = true;
0098 
0099   if(m_apiKey.isEmpty()) {
0100     myDebug() << "empty API key";
0101     message(i18n("An access key is required to use this data source.")
0102             + QLatin1Char(' ') +
0103             i18n("Those values must be entered in the data source settings."), MessageHandler::Error);
0104     stop();
0105     return;
0106   }
0107 
0108   QUrl u(QString::fromLatin1(DISCOGS_API_URL));
0109   u.setPath(QStringLiteral("/database/search"));
0110 
0111   QUrlQuery q;
0112   switch(request().key()) {
0113     case Title:
0114       q.addQueryItem(QStringLiteral("release_title"), request().value());
0115       q.addQueryItem(QStringLiteral("type"), QStringLiteral("release"));
0116       break;
0117 
0118     case Person:
0119       q.addQueryItem(QStringLiteral("artist"), request().value());
0120       q.addQueryItem(QStringLiteral("type"), QStringLiteral("release"));
0121       break;
0122 
0123     case Keyword:
0124       q.addQueryItem(QStringLiteral("q"), request().value());
0125       q.addQueryItem(QStringLiteral("type"), QStringLiteral("release"));
0126       break;
0127 
0128     case UPC:
0129       q.addQueryItem(QStringLiteral("barcode"), request().value());
0130       break;
0131 
0132     case Raw:
0133       q.setQuery(request().value());
0134       break;
0135 
0136     default:
0137       myWarning() << "key not recognized:" << request().key();
0138       stop();
0139       return;
0140   }
0141   q.addQueryItem(QStringLiteral("page"), QString::number(m_page));
0142   q.addQueryItem(QStringLiteral("per_page"), QString::number(m_limit));
0143   q.addQueryItem(QStringLiteral("token"), m_apiKey);
0144   u.setQuery(q);
0145 
0146 //  myDebug() << "url: " << u.url();
0147 
0148   m_job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
0149   m_job->addMetaData(QStringLiteral("UserAgent"), QStringLiteral("Tellico/%1")
0150                                                                 .arg(QStringLiteral(TELLICO_VERSION)));
0151   KJobWidgets::setWindow(m_job, GUI::Proxy::widget());
0152   connect(m_job.data(), &KJob::result, this, &DiscogsFetcher::slotComplete);
0153 }
0154 
0155 void DiscogsFetcher::stop() {
0156   if(!m_started) {
0157     return;
0158   }
0159   if(m_job) {
0160     m_job->kill();
0161     m_job = nullptr;
0162   }
0163   m_started = false;
0164   emit signalDone(this);
0165 }
0166 
0167 Tellico::Data::EntryPtr DiscogsFetcher::fetchEntryHook(uint uid_) {
0168   Data::EntryPtr entry = m_entries.value(uid_);
0169   if(!entry) {
0170     myWarning() << "no entry in dict";
0171     return Data::EntryPtr();
0172   }
0173 
0174   QString id = entry->field(QStringLiteral("discogs-id"));
0175   if(!id.isEmpty()) {
0176     // quiet
0177     QUrl u(QString::fromLatin1(DISCOGS_API_URL));
0178     u.setPath(QStringLiteral("/releases/%1").arg(id));
0179     QByteArray data = FileHandler::readDataFile(u, true);
0180 
0181 #if 0
0182     myWarning() << "Remove data debug from discogsfetcher.cpp";
0183     QFile f(QString::fromLatin1("/tmp/test-discogs-data.json"));
0184     if(f.open(QIODevice::WriteOnly)) {
0185       QTextStream t(&f);
0186       t.setCodec("UTF-8");
0187       t << data;
0188     }
0189     f.close();
0190 #endif
0191 
0192     QJsonParseError error;
0193     QJsonDocument doc = QJsonDocument::fromJson(data, &error);
0194     const QVariantMap resultMap = doc.object().toVariantMap();
0195     if(resultMap.contains(QStringLiteral("message")) && mapValue(resultMap, "id").isEmpty()) {
0196       message(mapValue(resultMap, "message"), MessageHandler::Error);
0197       myLog() << "DiscogsFetcher -" << mapValue(resultMap, "message");
0198     } else if(error.error == QJsonParseError::NoError) {
0199       populateEntry(entry, resultMap, true);
0200     } else {
0201       myDebug() << "Bad JSON results";
0202     }
0203   }
0204 
0205   const QString image_id = entry->field(QStringLiteral("cover"));
0206   // if it's still a url, we need to load it
0207   if(image_id.contains(QLatin1Char('/'))) {
0208     const QString id = ImageFactory::addImage(QUrl::fromUserInput(image_id), true /* quiet */);
0209     if(id.isEmpty()) {
0210       myDebug() << "empty id for" << image_id;
0211       message(i18n("The cover image could not be loaded."), MessageHandler::Warning);
0212     }
0213     // empty image ID is ok
0214     entry->setField(QStringLiteral("cover"), id);
0215   }
0216 
0217   // don't want to include ID field
0218   entry->setField(QStringLiteral("discogs-id"), QString());
0219 
0220   return entry;
0221 }
0222 
0223 Tellico::Fetch::FetchRequest DiscogsFetcher::updateRequest(Data::EntryPtr entry_) {
0224   const QString barcode = entry_->field(QStringLiteral("barcode"));
0225   if(!barcode.isEmpty()) {
0226     return FetchRequest(UPC, barcode);
0227   }
0228 
0229   const QString title = entry_->field(QStringLiteral("title"));
0230   const QString artist = entry_->field(QStringLiteral("artist"));
0231   const QString year = entry_->field(QStringLiteral("year"));
0232   // if any two of those are non-empty, combine them for a keyword search
0233   const int sum = (title.isEmpty() ? 0:1) + (artist.isEmpty() ? 0:1) + (year.isEmpty() ? 0:1);
0234   if(sum > 1) {
0235     QUrlQuery q;
0236     if(!title.isEmpty()) {
0237       q.addQueryItem(QStringLiteral("title"), title);
0238     }
0239     if(!artist.isEmpty()) {
0240       q.addQueryItem(QStringLiteral("artist"), artist);
0241     }
0242     if(!year.isEmpty()) {
0243       q.addQueryItem(QStringLiteral("year"), year);
0244     }
0245     return FetchRequest(Raw, q.toString());
0246   }
0247 
0248   if(!title.isEmpty()) {
0249     return FetchRequest(Title, title);
0250   }
0251 
0252   if(!artist.isEmpty()) {
0253     return FetchRequest(Person, artist);
0254   }
0255   return FetchRequest();
0256 }
0257 
0258 void DiscogsFetcher::slotComplete(KJob*) {
0259   if(m_job->error()) {
0260     m_job->uiDelegate()->showErrorMessage();
0261     stop();
0262     return;
0263   }
0264 
0265   QByteArray data = m_job->data();
0266   if(data.isEmpty()) {
0267     myDebug() << "no data";
0268     stop();
0269     return;
0270   }
0271 
0272 #if 0 // checking remaining discogs rate limit allocation
0273   const QStringList allHeaders = m_job->queryMetaData(QStringLiteral("HTTP-Headers")).split(QLatin1Char('\n'));
0274   foreach(const QString& header, allHeaders) {
0275     if(header.startsWith(QStringLiteral("x-discogs-ratelimit-remaining"))) {
0276       const int index = header.indexOf(QLatin1Char(':'));
0277       if(index > 0) {
0278         myDebug() << "DiscogsFetcher: rate limit remaining:" << header.mid(index + 1);
0279       }
0280       break;
0281     }
0282   }
0283 #endif
0284   // see bug 319662. If fetcher is cancelled, job is killed
0285   // if the pointer is retained, it gets double-deleted
0286   m_job = nullptr;
0287 
0288 #if 0
0289   myWarning() << "Remove debug from discogsfetcher.cpp";
0290   QFile f(QString::fromLatin1("/tmp/test-discogs.json"));
0291   if(f.open(QIODevice::WriteOnly)) {
0292     QTextStream t(&f);
0293     t.setCodec("UTF-8");
0294     t << data;
0295   }
0296   f.close();
0297 #endif
0298 
0299   Data::CollPtr coll(new Data::MusicCollection(true));
0300   // always add ID for fetchEntryHook
0301   Data::FieldPtr field(new Data::Field(QStringLiteral("discogs-id"), QStringLiteral("Discogs ID"), Data::Field::Line));
0302   field->setCategory(i18n("General"));
0303   coll->addField(field);
0304 
0305   if(optionalFields().contains(QStringLiteral("discogs"))) {
0306     Data::FieldPtr field(new Data::Field(QStringLiteral("discogs"), i18n("Discogs Link"), Data::Field::URL));
0307     field->setCategory(i18n("General"));
0308     coll->addField(field);
0309   }
0310   if(optionalFields().contains(QStringLiteral("nationality"))) {
0311     Data::FieldPtr field(new Data::Field(QStringLiteral("nationality"), i18n("Nationality")));
0312     field->setCategory(i18n("General"));
0313     field->setFlags(Data::Field::AllowCompletion | Data::Field::AllowMultiple | Data::Field::AllowGrouped);
0314     field->setFormatType(FieldFormat::FormatPlain);
0315     coll->addField(field);
0316   }
0317   if(optionalFields().contains(QStringLiteral("producer"))) {
0318     Data::FieldPtr field(new Data::Field(QStringLiteral("producer"), i18n("Producer")));
0319     field->setCategory(i18n("General"));
0320     field->setFlags(Data::Field::AllowCompletion | Data::Field::AllowMultiple | Data::Field::AllowGrouped);
0321     field->setFormatType(FieldFormat::FormatName);
0322     coll->addField(field);
0323   }
0324   if(optionalFields().contains(QStringLiteral("barcode"))) {
0325     Data::FieldPtr field(new Data::Field(QStringLiteral("barcode"), i18n("Barcode")));
0326     field->setCategory(i18n("General"));
0327     coll->addField(field);
0328   }
0329 
0330   QJsonDocument doc = QJsonDocument::fromJson(data);
0331 //  const QVariantMap resultMap = doc.object().toVariantMap().value(QStringLiteral("feed")).toMap();
0332   const QVariantMap resultMap = doc.object().toVariantMap();
0333 
0334   if(mapValue(resultMap, "message").startsWith(QLatin1String("Invalid consumer token"))) {
0335     message(i18n("The Discogs.com server reports a token error."),
0336             MessageHandler::Error);
0337     stop();
0338     return;
0339   }
0340 
0341   const int totalPages = mapValue(resultMap, "pagination", "pages").toInt();
0342   m_hasMoreResults = m_page < totalPages;
0343   ++m_page;
0344 
0345   int count = 0;
0346   foreach(const QVariant& result, resultMap.value(QLatin1String("results")).toList()) {
0347     if(count >= m_limit) {
0348       break;
0349     }
0350 
0351   //  myDebug() << "found result:" << result;
0352 
0353     Data::EntryPtr entry(new Data::Entry(coll));
0354     populateEntry(entry, result.toMap(), false);
0355 
0356     FetchResult* r = new FetchResult(this, entry);
0357     m_entries.insert(r->uid, entry);
0358     emit signalResultFound(r);
0359     ++count;
0360   }
0361 
0362   stop();
0363 }
0364 
0365 void DiscogsFetcher::populateEntry(Data::EntryPtr entry_, const QVariantMap& resultMap_, bool fullData_) {
0366   entry_->setField(QStringLiteral("discogs-id"), mapValue(resultMap_, "id"));
0367   entry_->setField(QStringLiteral("title"), mapValue(resultMap_, "title"));
0368   const QString year = mapValue(resultMap_, "year");
0369   if(year != QLatin1String("0")) {
0370     entry_->setField(QStringLiteral("year"), year);
0371   }
0372   entry_->setField(QStringLiteral("genre"),  mapValue(resultMap_, "genres"));
0373 
0374   QStringList artists;
0375   foreach(const QVariant& artist, resultMap_.value(QLatin1String("artists")).toList()) {
0376     artists << mapValue(artist.toMap(), "name");
0377   }
0378   artists.removeDuplicates(); // sometimes the same value is repeated
0379   entry_->setField(QStringLiteral("artist"), artists.join(FieldFormat::delimiterString()));
0380 
0381   QStringList labels;
0382   foreach(const QVariant& label, resultMap_.value(QLatin1String("labels")).toList()) {
0383     labels << mapValue(label.toMap(), "name");
0384   }
0385   entry_->setField(QStringLiteral("label"), labels.join(FieldFormat::delimiterString()));
0386 
0387   /* cover value is not always in the full data, so go ahead and set it now */
0388   QString coverUrl = mapValue(resultMap_, "cover_image");
0389   if(coverUrl.isEmpty()) {
0390     coverUrl = mapValue(resultMap_, "thumb");
0391   }
0392   if(!coverUrl.isEmpty()) {
0393     entry_->setField(QStringLiteral("cover"), coverUrl);
0394   }
0395 
0396   // if we only need cursory data, then we're done
0397   if(!fullData_) {
0398     return;
0399   }
0400 
0401   // check the formats, it could have multiple
0402   // if there is a CD, prefer that in the track list
0403   bool hasCD = false;
0404   foreach(const QVariant& format, resultMap_.value(QLatin1String("formats")).toList()) {
0405     const QString formatName = mapValue(format.toMap(), "name");
0406     if(formatName == QLatin1String("CD")) {
0407       entry_->setField(QStringLiteral("medium"), i18n("Compact Disc"));
0408       hasCD = true;
0409     } else if(formatName == QLatin1String("Vinyl")) {
0410       entry_->setField(QStringLiteral("medium"), i18n("Vinyl"));
0411     } else if(formatName == QLatin1String("Cassette")) {
0412       entry_->setField(QStringLiteral("medium"), i18n("Cassette"));
0413     } else if(!hasCD && formatName == QLatin1String("DVD")) {
0414       // sometimes a CD and DVD both are included. If we're using the CD, ignore the DVD
0415       entry_->setField(QStringLiteral("medium"), i18n("DVD"));
0416     }
0417   }
0418 
0419   QStringList tracks;
0420   foreach(const QVariant& track, resultMap_.value(QLatin1String("tracklist")).toList()) {
0421     const QVariantMap trackMap = track.toMap();
0422     if(mapValue(trackMap, "type_") != QLatin1String("track")) {
0423       continue;
0424     }
0425 
0426     // Releases might include a CD and a DVD, for example
0427     // prefer only the tracks on the CD. Allow positions of just numbers
0428     if(hasCD && !(mapValue(trackMap, "position").at(0).isNumber() ||
0429                   mapValue(trackMap, "position").startsWith(QLatin1String("CD")))) {
0430       continue;
0431     }
0432 
0433     QStringList trackInfo;
0434     trackInfo << mapValue(trackMap, "title");
0435     if(trackMap.contains(QStringLiteral("artists"))) {
0436       QStringList artists;
0437       foreach(const QVariant& artist, trackMap.value(QLatin1String("artists")).toList()) {
0438         artists << mapValue(artist.toMap(), "name");
0439       }
0440       trackInfo << artists.join(FieldFormat::delimiterString());
0441     } else {
0442       trackInfo << entry_->field(QStringLiteral("artist"));
0443     }
0444     trackInfo << mapValue(trackMap, "duration");
0445     tracks << trackInfo.join(FieldFormat::columnDelimiterString());
0446   }
0447   entry_->setField(QStringLiteral("track"), tracks.join(FieldFormat::rowDelimiterString()));
0448 
0449   if(entry_->collection()->hasField(QStringLiteral("discogs"))) {
0450     entry_->setField(QStringLiteral("discogs"), mapValue(resultMap_, "uri"));
0451   }
0452 
0453   if(entry_->collection()->hasField(QStringLiteral("nationality"))) {
0454     entry_->setField(QStringLiteral("nationality"), mapValue(resultMap_, "country"));
0455   }
0456 
0457   if(entry_->collection()->hasField(QStringLiteral("barcode"))) {
0458     foreach(const QVariant& identifier, resultMap_.value(QLatin1String("identifiers")).toList()) {
0459       const QVariantMap idMap = identifier.toMap();
0460       if(mapValue(idMap, "type") == QLatin1String("Barcode")) {
0461         entry_->setField(QStringLiteral("barcode"), mapValue(idMap, "value"));
0462         break;
0463       }
0464     }
0465   }
0466 
0467   if(entry_->collection()->hasField(QStringLiteral("producer"))) {
0468     QStringList producers;
0469     foreach(const QVariant& extraartist, resultMap_.value(QLatin1String("extraartists")).toList()) {
0470       if(mapValue(extraartist.toMap(), "role").contains(QStringLiteral("Producer"))) {
0471         producers << mapValue(extraartist.toMap(), "name");
0472       }
0473     }
0474     entry_->setField(QStringLiteral("producer"), producers.join(FieldFormat::delimiterString()));
0475   }
0476 
0477   entry_->setField(QStringLiteral("comments"), mapValue(resultMap_, "notes"));
0478 }
0479 
0480 Tellico::Fetch::ConfigWidget* DiscogsFetcher::configWidget(QWidget* parent_) const {
0481   return new DiscogsFetcher::ConfigWidget(parent_, this);
0482 }
0483 
0484 QString DiscogsFetcher::defaultName() {
0485   return i18n("Discogs Audio Search");
0486 }
0487 
0488 QString DiscogsFetcher::defaultIcon() {
0489   return favIcon("http://www.discogs.com");
0490 }
0491 
0492 Tellico::StringHash DiscogsFetcher::allOptionalFields() {
0493   StringHash hash;
0494   hash[QStringLiteral("producer")] = i18n("Producer");
0495   hash[QStringLiteral("nationality")] = i18n("Nationality");
0496   hash[QStringLiteral("discogs")] = i18n("Discogs Link");
0497   hash[QStringLiteral("barcode")] = i18n("Barcode");
0498   return hash;
0499 }
0500 
0501 DiscogsFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const DiscogsFetcher* fetcher_)
0502     : Fetch::ConfigWidget(parent_) {
0503   QGridLayout* l = new QGridLayout(optionsWidget());
0504   l->setSpacing(4);
0505   l->setColumnStretch(1, 10);
0506 
0507   int row = -1;
0508   QLabel* al = new QLabel(i18n("Registration is required for accessing this data source. "
0509                                "If you agree to the terms and conditions, <a href='%1'>sign "
0510                                "up for an account</a>, and enter your information below.",
0511                                 QLatin1String("https://www.discogs.com/developers/#page:authentication")),
0512                           optionsWidget());
0513   al->setOpenExternalLinks(true);
0514   al->setWordWrap(true);
0515   ++row;
0516   l->addWidget(al, row, 0, 1, 2);
0517   // richtext gets weird with size
0518   al->setMinimumWidth(al->sizeHint().width());
0519 
0520   QLabel* label = new QLabel(i18n("User token: "), optionsWidget());
0521   l->addWidget(label, ++row, 0);
0522 
0523   m_apiKeyEdit = new QLineEdit(optionsWidget());
0524   connect(m_apiKeyEdit, &QLineEdit::textChanged, this, &ConfigWidget::slotSetModified);
0525   l->addWidget(m_apiKeyEdit, row, 1);
0526   label->setBuddy(m_apiKeyEdit);
0527 
0528   l->setRowStretch(++row, 10);
0529 
0530   if(fetcher_) {
0531     m_apiKeyEdit->setText(fetcher_->m_apiKey);
0532   }
0533 
0534   // now add additional fields widget
0535   addFieldsWidget(DiscogsFetcher::allOptionalFields(), fetcher_ ? fetcher_->optionalFields() : QStringList());
0536 }
0537 
0538 void DiscogsFetcher::ConfigWidget::saveConfigHook(KConfigGroup& config_) {
0539   QString apiKey = m_apiKeyEdit->text().trimmed();
0540   if(!apiKey.isEmpty()) {
0541     config_.writeEntry("API Key", apiKey);
0542   }
0543 }
0544 
0545 QString DiscogsFetcher::ConfigWidget::preferredName() const {
0546   return DiscogsFetcher::defaultName();
0547 }