1 /***************************************************************************
2 Copyright (C) 2008-2020 Robby Stephenson <robby@periapsis.org>
3 ***************************************************************************/
4
5 /***************************************************************************
6 * *
7 * This program is free software; you can redistribute it and/or *
8 * modify it under the terms of the GNU General Public License as *
9 * published by the Free Software Foundation; either version 2 of *
10 * the License or (at your option) version 3 or any later version *
11 * accepted by the membership of KDE e.V. (or its successor approved *
12 * by the membership of KDE e.V.), which shall act as a proxy *
13 * defined in Section 14 of version 3 of the license. *
14 * *
15 * This program is distributed in the hope that it will be useful, *
16 * but WITHOUT ANY WARRANTY; without even the implied warranty of *
17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
18 * GNU General Public License for more details. *
19 * *
20 * You should have received a copy of the GNU General Public License *
21 * along with this program. If not, see <http://www.gnu.org/licenses/>. *
22 * *
23 ***************************************************************************/
24
25 #include <config.h> // for TELLICO_VERSION
26
27 #include "discogsfetcher.h"
28 #include "../collections/musiccollection.h"
29 #include "../images/imagefactory.h"
30 #include "../utils/guiproxy.h"
31 #include "../utils/string_utils.h"
32 #include "../core/filehandler.h"
33 #include "../tellico_debug.h"
34
35 #include <KLocalizedString>
36 #include <KConfigGroup>
37 #include <KIO/Job>
38 #include <KJobUiDelegate>
39 #include <KJobWidgets/KJobWidgets>
40
41 #include <QLineEdit>
42 #include <QLabel>
43 #include <QFile>
44 #include <QTextStream>
45 #include <QBoxLayout>
46 #include <QJsonDocument>
47 #include <QJsonObject>
48 #include <QUrlQuery>
49
50 namespace {
51 static const int DISCOGS_MAX_RETURNS_TOTAL = 20;
52 static const char* DISCOGS_API_URL = "https://api.discogs.com";
53 }
54
55 using namespace Tellico;
56 using Tellico::Fetch::DiscogsFetcher;
57
DiscogsFetcher(QObject * parent_)58 DiscogsFetcher::DiscogsFetcher(QObject* parent_)
59 : Fetcher(parent_)
60 , m_limit(DISCOGS_MAX_RETURNS_TOTAL)
61 , m_started(false)
62 , m_page(1) {
63 }
64
~DiscogsFetcher()65 DiscogsFetcher::~DiscogsFetcher() {
66 }
67
source() const68 QString DiscogsFetcher::source() const {
69 return m_name.isEmpty() ? defaultName() : m_name;
70 }
71
canSearch(Fetch::FetchKey k) const72 bool DiscogsFetcher::canSearch(Fetch::FetchKey k) const {
73 return k == Title || k == Person || k == Keyword || k == UPC;
74 }
75
canFetch(int type) const76 bool DiscogsFetcher::canFetch(int type) const {
77 return type == Data::Collection::Album;
78 }
79
readConfigHook(const KConfigGroup & config_)80 void DiscogsFetcher::readConfigHook(const KConfigGroup& config_) {
81 QString k = config_.readEntry("API Key");
82 if(!k.isEmpty()) {
83 m_apiKey = k;
84 }
85 }
86
setLimit(int limit_)87 void DiscogsFetcher::setLimit(int limit_) {
88 m_limit = qBound(1, limit_, DISCOGS_MAX_RETURNS_TOTAL);
89 }
90
search()91 void DiscogsFetcher::search() {
92 m_page = 1;
93 continueSearch();
94 }
95
continueSearch()96 void DiscogsFetcher::continueSearch() {
97 m_started = true;
98
99 if(m_apiKey.isEmpty()) {
100 myDebug() << "empty API key";
101 message(i18n("An access key is required to use this data source.")
102 + QLatin1Char(' ') +
103 i18n("Those values must be entered in the data source settings."), MessageHandler::Error);
104 stop();
105 return;
106 }
107
108 QUrl u(QString::fromLatin1(DISCOGS_API_URL));
109 u.setPath(QStringLiteral("/database/search"));
110
111 QUrlQuery q;
112 switch(request().key()) {
113 case Title:
114 q.addQueryItem(QStringLiteral("release_title"), request().value());
115 q.addQueryItem(QStringLiteral("type"), QStringLiteral("release"));
116 break;
117
118 case Person:
119 q.addQueryItem(QStringLiteral("artist"), request().value());
120 q.addQueryItem(QStringLiteral("type"), QStringLiteral("release"));
121 break;
122
123 case Keyword:
124 q.addQueryItem(QStringLiteral("q"), request().value());
125 break;
126
127 case UPC:
128 q.addQueryItem(QStringLiteral("barcode"), request().value());
129 break;
130
131 case Raw:
132 q.setQuery(request().value());
133 break;
134
135 default:
136 myWarning() << "key not recognized:" << request().key();
137 stop();
138 return;
139 }
140 q.addQueryItem(QStringLiteral("page"), QString::number(m_page));
141 q.addQueryItem(QStringLiteral("per_page"), QString::number(m_limit));
142 q.addQueryItem(QStringLiteral("token"), m_apiKey);
143 u.setQuery(q);
144
145 // myDebug() << "url: " << u.url();
146
147 m_job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
148 m_job->addMetaData(QStringLiteral("UserAgent"), QStringLiteral("Tellico/%1")
149 .arg(QStringLiteral(TELLICO_VERSION)));
150 KJobWidgets::setWindow(m_job, GUI::Proxy::widget());
151 connect(m_job.data(), &KJob::result, this, &DiscogsFetcher::slotComplete);
152 }
153
stop()154 void DiscogsFetcher::stop() {
155 if(!m_started) {
156 return;
157 }
158 if(m_job) {
159 m_job->kill();
160 m_job = nullptr;
161 }
162 m_started = false;
163 emit signalDone(this);
164 }
165
fetchEntryHook(uint uid_)166 Tellico::Data::EntryPtr DiscogsFetcher::fetchEntryHook(uint uid_) {
167 Data::EntryPtr entry = m_entries.value(uid_);
168 if(!entry) {
169 myWarning() << "no entry in dict";
170 return Data::EntryPtr();
171 }
172
173 QString id = entry->field(QStringLiteral("discogs-id"));
174 if(!id.isEmpty()) {
175 // quiet
176 QUrl u(QString::fromLatin1(DISCOGS_API_URL));
177 u.setPath(QStringLiteral("/releases/%1").arg(id));
178 QByteArray data = FileHandler::readDataFile(u, true);
179
180 #if 0
181 myWarning() << "Remove data debug from discogsfetcher.cpp";
182 QFile f(QString::fromLatin1("/tmp/test-discogs-data.json"));
183 if(f.open(QIODevice::WriteOnly)) {
184 QTextStream t(&f);
185 t.setCodec("UTF-8");
186 t << data;
187 }
188 f.close();
189 #endif
190
191 QJsonParseError error;
192 QJsonDocument doc = QJsonDocument::fromJson(data, &error);
193 const QVariantMap resultMap = doc.object().toVariantMap();
194 if(resultMap.contains(QStringLiteral("message")) && mapValue(resultMap, "id").isEmpty()) {
195 message(mapValue(resultMap, "message"), MessageHandler::Error);
196 myLog() << "DiscogsFetcher -" << mapValue(resultMap, "message");
197 } else if(error.error == QJsonParseError::NoError) {
198 populateEntry(entry, resultMap, true);
199 } else {
200 myDebug() << "Bad JSON results";
201 }
202 }
203
204 const QString image_id = entry->field(QStringLiteral("cover"));
205 // if it's still a url, we need to load it
206 if(image_id.contains(QLatin1Char('/'))) {
207 const QString id = ImageFactory::addImage(QUrl::fromUserInput(image_id), true /* quiet */);
208 if(id.isEmpty()) {
209 myDebug() << "empty id for" << image_id;
210 message(i18n("The cover image could not be loaded."), MessageHandler::Warning);
211 }
212 // empty image ID is ok
213 entry->setField(QStringLiteral("cover"), id);
214 }
215
216 // don't want to include ID field
217 entry->setField(QStringLiteral("discogs-id"), QString());
218
219 return entry;
220 }
221
updateRequest(Data::EntryPtr entry_)222 Tellico::Fetch::FetchRequest DiscogsFetcher::updateRequest(Data::EntryPtr entry_) {
223 const QString barcode = entry_->field(QStringLiteral("barcode"));
224 if(!barcode.isEmpty()) {
225 return FetchRequest(UPC, barcode);
226 }
227
228 const QString title = entry_->field(QStringLiteral("title"));
229 const QString artist = entry_->field(QStringLiteral("artist"));
230 const QString year = entry_->field(QStringLiteral("year"));
231 // if any two of those are non-empty, combine them for a keyword search
232 const int sum = (title.isEmpty() ? 0:1) + (artist.isEmpty() ? 0:1) + (year.isEmpty() ? 0:1);
233 if(sum > 1) {
234 QUrlQuery q;
235 if(!title.isEmpty()) {
236 q.addQueryItem(QStringLiteral("title"), title);
237 }
238 if(!artist.isEmpty()) {
239 q.addQueryItem(QStringLiteral("artist"), artist);
240 }
241 if(!year.isEmpty()) {
242 q.addQueryItem(QStringLiteral("year"), year);
243 }
244 return FetchRequest(Raw, q.toString());
245 }
246
247 if(!title.isEmpty()) {
248 return FetchRequest(Title, title);
249 }
250
251 if(!artist.isEmpty()) {
252 return FetchRequest(Person, artist);
253 }
254 return FetchRequest();
255 }
256
slotComplete(KJob *)257 void DiscogsFetcher::slotComplete(KJob*) {
258 if(m_job->error()) {
259 m_job->uiDelegate()->showErrorMessage();
260 stop();
261 return;
262 }
263
264 QByteArray data = m_job->data();
265 if(data.isEmpty()) {
266 myDebug() << "no data";
267 stop();
268 return;
269 }
270
271 #if 0 // checking remaining discogs rate limit allocation
272 const QStringList allHeaders = m_job->queryMetaData(QStringLiteral("HTTP-Headers")).split(QLatin1Char('\n'));
273 foreach(const QString& header, allHeaders) {
274 if(header.startsWith(QStringLiteral("x-discogs-ratelimit-remaining"))) {
275 const int index = header.indexOf(QLatin1Char(':'));
276 if(index > 0) {
277 myDebug() << "DiscogsFetcher: rate limit remaining:" << header.mid(index + 1);
278 }
279 break;
280 }
281 }
282 #endif
283 // see bug 319662. If fetcher is cancelled, job is killed
284 // if the pointer is retained, it gets double-deleted
285 m_job = nullptr;
286
287 #if 0
288 myWarning() << "Remove debug from discogsfetcher.cpp";
289 QFile f(QString::fromLatin1("/tmp/test-discogs.json"));
290 if(f.open(QIODevice::WriteOnly)) {
291 QTextStream t(&f);
292 t.setCodec("UTF-8");
293 t << data;
294 }
295 f.close();
296 #endif
297
298 Data::CollPtr coll(new Data::MusicCollection(true));
299 // always add ID for fetchEntryHook
300 Data::FieldPtr field(new Data::Field(QStringLiteral("discogs-id"), QStringLiteral("Discogs ID"), Data::Field::Line));
301 field->setCategory(i18n("General"));
302 coll->addField(field);
303
304 if(optionalFields().contains(QStringLiteral("discogs"))) {
305 Data::FieldPtr field(new Data::Field(QStringLiteral("discogs"), i18n("Discogs Link"), Data::Field::URL));
306 field->setCategory(i18n("General"));
307 coll->addField(field);
308 }
309 if(optionalFields().contains(QStringLiteral("nationality"))) {
310 Data::FieldPtr field(new Data::Field(QStringLiteral("nationality"), i18n("Nationality")));
311 field->setCategory(i18n("General"));
312 field->setFlags(Data::Field::AllowCompletion | Data::Field::AllowMultiple | Data::Field::AllowGrouped);
313 field->setFormatType(FieldFormat::FormatPlain);
314 coll->addField(field);
315 }
316 if(optionalFields().contains(QStringLiteral("producer"))) {
317 Data::FieldPtr field(new Data::Field(QStringLiteral("producer"), i18n("Producer")));
318 field->setCategory(i18n("General"));
319 field->setFlags(Data::Field::AllowCompletion | Data::Field::AllowMultiple | Data::Field::AllowGrouped);
320 field->setFormatType(FieldFormat::FormatName);
321 coll->addField(field);
322 }
323 if(optionalFields().contains(QStringLiteral("barcode"))) {
324 Data::FieldPtr field(new Data::Field(QStringLiteral("barcode"), i18n("Barcode")));
325 field->setCategory(i18n("General"));
326 coll->addField(field);
327 }
328
329 QJsonDocument doc = QJsonDocument::fromJson(data);
330 // const QVariantMap resultMap = doc.object().toVariantMap().value(QStringLiteral("feed")).toMap();
331 const QVariantMap resultMap = doc.object().toVariantMap();
332
333 if(mapValue(resultMap, "message").startsWith(QLatin1String("Invalid consumer token"))) {
334 message(i18n("The Discogs.com server reports a token error."),
335 MessageHandler::Error);
336 stop();
337 return;
338 }
339
340 const int totalPages = mapValue(resultMap, "pagination", "pages").toInt();
341 m_hasMoreResults = m_page < totalPages;
342 ++m_page;
343
344 int count = 0;
345 foreach(const QVariant& result, resultMap.value(QLatin1String("results")).toList()) {
346 if(count >= m_limit) {
347 break;
348 }
349
350 // myDebug() << "found result:" << result;
351
352 Data::EntryPtr entry(new Data::Entry(coll));
353 populateEntry(entry, result.toMap(), false);
354
355 FetchResult* r = new FetchResult(this, entry);
356 m_entries.insert(r->uid, entry);
357 emit signalResultFound(r);
358 ++count;
359 }
360
361 stop();
362 }
363
populateEntry(Data::EntryPtr entry_,const QVariantMap & resultMap_,bool fullData_)364 void DiscogsFetcher::populateEntry(Data::EntryPtr entry_, const QVariantMap& resultMap_, bool fullData_) {
365 entry_->setField(QStringLiteral("discogs-id"), mapValue(resultMap_, "id"));
366 entry_->setField(QStringLiteral("title"), mapValue(resultMap_, "title"));
367 const QString year = mapValue(resultMap_, "year");
368 if(year != QLatin1String("0")) {
369 entry_->setField(QStringLiteral("year"), year);
370 }
371 entry_->setField(QStringLiteral("genre"), mapValue(resultMap_, "genres"));
372
373 QStringList artists;
374 foreach(const QVariant& artist, resultMap_.value(QLatin1String("artists")).toList()) {
375 artists << mapValue(artist.toMap(), "name");
376 }
377 artists.removeDuplicates(); // sometimes the same value is repeated
378 entry_->setField(QStringLiteral("artist"), artists.join(FieldFormat::delimiterString()));
379
380 QStringList labels;
381 foreach(const QVariant& label, resultMap_.value(QLatin1String("labels")).toList()) {
382 labels << mapValue(label.toMap(), "name");
383 }
384 entry_->setField(QStringLiteral("label"), labels.join(FieldFormat::delimiterString()));
385
386 /* cover value is not always in the full data, so go ahead and set it now */
387 QString coverUrl = mapValue(resultMap_, "cover_image");
388 if(coverUrl.isEmpty()) {
389 coverUrl = mapValue(resultMap_, "thumb");
390 }
391 if(!coverUrl.isEmpty()) {
392 entry_->setField(QStringLiteral("cover"), coverUrl);
393 }
394
395 // if we only need cursory data, then we're done
396 if(!fullData_) {
397 return;
398 }
399
400 // check the formats, it could have multiple
401 // if there is a CD, prefer that in the track list
402 bool hasCD = false;
403 foreach(const QVariant& format, resultMap_.value(QLatin1String("formats")).toList()) {
404 const QString formatName = mapValue(format.toMap(), "name");
405 if(formatName == QLatin1String("CD")) {
406 entry_->setField(QStringLiteral("medium"), i18n("Compact Disc"));
407 hasCD = true;
408 } else if(formatName == QLatin1String("Vinyl")) {
409 entry_->setField(QStringLiteral("medium"), i18n("Vinyl"));
410 } else if(formatName == QLatin1String("Cassette")) {
411 entry_->setField(QStringLiteral("medium"), i18n("Cassette"));
412 } else if(!hasCD && formatName == QLatin1String("DVD")) {
413 // sometimes a CD and DVD both are included. If we're using the CD, ignore the DVD
414 entry_->setField(QStringLiteral("medium"), i18n("DVD"));
415 }
416 }
417
418 QStringList tracks;
419 foreach(const QVariant& track, resultMap_.value(QLatin1String("tracklist")).toList()) {
420 const QVariantMap trackMap = track.toMap();
421 if(mapValue(trackMap, "type_") != QLatin1String("track")) {
422 continue;
423 }
424
425 // Releases might include a CD and a DVD, for example
426 // prefer only the tracks on the CD. Allow positions of just numbers
427 if(hasCD && !(mapValue(trackMap, "position").at(0).isNumber() ||
428 mapValue(trackMap, "position").startsWith(QLatin1String("CD")))) {
429 continue;
430 }
431
432 QStringList trackInfo;
433 trackInfo << mapValue(trackMap, "title");
434 if(trackMap.contains(QStringLiteral("artists"))) {
435 QStringList artists;
436 foreach(const QVariant& artist, trackMap.value(QLatin1String("artists")).toList()) {
437 artists << mapValue(artist.toMap(), "name");
438 }
439 trackInfo << artists.join(FieldFormat::delimiterString());
440 } else {
441 trackInfo << entry_->field(QStringLiteral("artist"));
442 }
443 trackInfo << mapValue(trackMap, "duration");
444 tracks << trackInfo.join(FieldFormat::columnDelimiterString());
445 }
446 entry_->setField(QStringLiteral("track"), tracks.join(FieldFormat::rowDelimiterString()));
447
448 if(entry_->collection()->hasField(QStringLiteral("discogs"))) {
449 entry_->setField(QStringLiteral("discogs"), mapValue(resultMap_, "uri"));
450 }
451
452 if(entry_->collection()->hasField(QStringLiteral("nationality"))) {
453 entry_->setField(QStringLiteral("nationality"), mapValue(resultMap_, "country"));
454 }
455
456 if(entry_->collection()->hasField(QStringLiteral("barcode"))) {
457 foreach(const QVariant& identifier, resultMap_.value(QLatin1String("identifiers")).toList()) {
458 const QVariantMap idMap = identifier.toMap();
459 if(mapValue(idMap, "type") == QLatin1String("Barcode")) {
460 entry_->setField(QStringLiteral("barcode"), mapValue(idMap, "value"));
461 break;
462 }
463 }
464 }
465
466 if(entry_->collection()->hasField(QStringLiteral("producer"))) {
467 QStringList producers;
468 foreach(const QVariant& extraartist, resultMap_.value(QLatin1String("extraartists")).toList()) {
469 if(mapValue(extraartist.toMap(), "role").contains(QStringLiteral("Producer"))) {
470 producers << mapValue(extraartist.toMap(), "name");
471 }
472 }
473 entry_->setField(QStringLiteral("producer"), producers.join(FieldFormat::delimiterString()));
474 }
475
476 entry_->setField(QStringLiteral("comments"), mapValue(resultMap_, "notes"));
477 }
478
configWidget(QWidget * parent_) const479 Tellico::Fetch::ConfigWidget* DiscogsFetcher::configWidget(QWidget* parent_) const {
480 return new DiscogsFetcher::ConfigWidget(parent_, this);
481 }
482
defaultName()483 QString DiscogsFetcher::defaultName() {
484 return i18n("Discogs Audio Search");
485 }
486
defaultIcon()487 QString DiscogsFetcher::defaultIcon() {
488 return favIcon("http://www.discogs.com");
489 }
490
allOptionalFields()491 Tellico::StringHash DiscogsFetcher::allOptionalFields() {
492 StringHash hash;
493 hash[QStringLiteral("producer")] = i18n("Producer");
494 hash[QStringLiteral("nationality")] = i18n("Nationality");
495 hash[QStringLiteral("discogs")] = i18n("Discogs Link");
496 hash[QStringLiteral("barcode")] = i18n("Barcode");
497 return hash;
498 }
499
ConfigWidget(QWidget * parent_,const DiscogsFetcher * fetcher_)500 DiscogsFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const DiscogsFetcher* fetcher_)
501 : Fetch::ConfigWidget(parent_) {
502 QGridLayout* l = new QGridLayout(optionsWidget());
503 l->setSpacing(4);
504 l->setColumnStretch(1, 10);
505
506 int row = -1;
507 QLabel* al = new QLabel(i18n("Registration is required for accessing this data source. "
508 "If you agree to the terms and conditions, <a href='%1'>sign "
509 "up for an account</a>, and enter your information below.",
510 QLatin1String("https://www.discogs.com/developers/#page:authentication")),
511 optionsWidget());
512 al->setOpenExternalLinks(true);
513 al->setWordWrap(true);
514 ++row;
515 l->addWidget(al, row, 0, 1, 2);
516 // richtext gets weird with size
517 al->setMinimumWidth(al->sizeHint().width());
518
519 QLabel* label = new QLabel(i18n("User token: "), optionsWidget());
520 l->addWidget(label, ++row, 0);
521
522 m_apiKeyEdit = new QLineEdit(optionsWidget());
523 connect(m_apiKeyEdit, &QLineEdit::textChanged, this, &ConfigWidget::slotSetModified);
524 l->addWidget(m_apiKeyEdit, row, 1);
525 label->setBuddy(m_apiKeyEdit);
526
527 l->setRowStretch(++row, 10);
528
529 if(fetcher_) {
530 m_apiKeyEdit->setText(fetcher_->m_apiKey);
531 }
532
533 // now add additional fields widget
534 addFieldsWidget(DiscogsFetcher::allOptionalFields(), fetcher_ ? fetcher_->optionalFields() : QStringList());
535 }
536
saveConfigHook(KConfigGroup & config_)537 void DiscogsFetcher::ConfigWidget::saveConfigHook(KConfigGroup& config_) {
538 QString apiKey = m_apiKeyEdit->text().trimmed();
539 if(!apiKey.isEmpty()) {
540 config_.writeEntry("API Key", apiKey);
541 }
542 }
543
preferredName() const544 QString DiscogsFetcher::ConfigWidget::preferredName() const {
545 return DiscogsFetcher::defaultName();
546 }
547