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 }