File indexing completed on 2024-05-12 05:09:38

0001 /***************************************************************************
0002     Copyright (C) 2019-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 "mobygamesfetcher.h"
0026 #include "../collections/gamecollection.h"
0027 #include "../images/imagefactory.h"
0028 #include "../gui/combobox.h"
0029 #include "../core/filehandler.h"
0030 #include "../utils/guiproxy.h"
0031 #include "../utils/mapvalue.h"
0032 #include "../utils/tellico_utils.h"
0033 #include "../core/tellico_strings.h"
0034 #include "../tellico_debug.h"
0035 
0036 #include <KLocalizedString>
0037 #include <KConfigGroup>
0038 #include <KJob>
0039 #include <KJobUiDelegate>
0040 #include <KJobWidgets/KJobWidgets>
0041 #include <KIO/StoredTransferJob>
0042 
0043 #include <QUrl>
0044 #include <QLabel>
0045 #include <QFile>
0046 #include <QTextStream>
0047 #include <QGridLayout>
0048 #include <QTextCodec>
0049 #include <QJsonDocument>
0050 #include <QJsonObject>
0051 #include <QUrlQuery>
0052 #include <QThread>
0053 #include <QTimer>
0054 
0055 namespace {
0056   static const int MOBYGAMES_MAX_RETURNS_TOTAL = 10;
0057   static const char* MOBYGAMES_API_URL = "https://api.mobygames.com/v1";
0058 }
0059 
0060 using namespace Tellico;
0061 using Tellico::Fetch::MobyGamesFetcher;
0062 
0063 MobyGamesFetcher::MobyGamesFetcher(QObject* parent_)
0064     : Fetcher(parent_)
0065     , m_started(false)
0066     , m_imageSize(SmallImage)
0067     , m_requestPlatformId(0) {
0068   //  setLimit(MOBYGAMES_MAX_RETURNS_TOTAL);
0069   m_idleTime.start();
0070   // delay reading the platform names from the cache file
0071   QTimer::singleShot(0, this, &MobyGamesFetcher::populateHashes);
0072 }
0073 
0074 MobyGamesFetcher::~MobyGamesFetcher() {
0075 }
0076 
0077 QString MobyGamesFetcher::source() const {
0078   return m_name.isEmpty() ? defaultName() : m_name;
0079 }
0080 
0081 QString MobyGamesFetcher::attribution() const {
0082   return i18n(providedBy, QLatin1String("https://mobygames.com"), QLatin1String("MobyGames"));
0083 }
0084 
0085 bool MobyGamesFetcher::canSearch(Fetch::FetchKey k) const {
0086   return k == Title || k == Keyword;
0087 }
0088 
0089 bool MobyGamesFetcher::canFetch(int type) const {
0090   return type == Data::Collection::Game;
0091 }
0092 
0093 void MobyGamesFetcher::readConfigHook(const KConfigGroup& config_) {
0094   QString k = config_.readEntry("API Key");
0095   if(!k.isEmpty()) {
0096     m_apiKey = k;
0097   }
0098   const int imageSize = config_.readEntry("Image Size", -1);
0099   if(imageSize > -1) {
0100     m_imageSize = static_cast<ImageSize>(imageSize);
0101   }
0102 }
0103 
0104 void MobyGamesFetcher::search() {
0105   continueSearch();
0106 }
0107 
0108 void MobyGamesFetcher::continueSearch() {
0109   m_started = true;
0110   m_requestPlatformId = 0;
0111 
0112   QUrl u(QString::fromLatin1(MOBYGAMES_API_URL));
0113   u.setPath(u.path() + QStringLiteral("/games"));
0114 
0115   QUrlQuery q;
0116   switch(request().key()) {
0117     case Title:
0118       q.addQueryItem(QStringLiteral("title"), request().value());
0119       break;
0120 
0121     case Keyword:
0122       {
0123       // figure out if the platform is part of the search string
0124       int pId = 0;
0125       QString value = request().value(); // resulting value
0126       QString matchedPlatform;
0127       // iterate over all known platforms; this doesn't seem to be too much of a performance hit
0128       QHash<int, QString>::const_iterator i = m_platforms.constBegin();
0129       while(i != m_platforms.constEnd()) {
0130         // don't forget that some platform names are substrings of others, like Wii and WiiU
0131         if(i.value().length() > matchedPlatform.length() && request().value().contains(i.value())) {
0132           pId = i.key();
0133           matchedPlatform = i.value();
0134           QString v = request().value(); // reset search value
0135           v.remove(matchedPlatform); // remove platform from search value
0136           value = v.simplified();
0137           // can't break, because of potential substring platform name
0138         }
0139         ++i;
0140       }
0141       q.addQueryItem(QStringLiteral("title"), value);
0142       if(pId > 0) {
0143         m_requestPlatformId = pId;
0144         q.addQueryItem(QStringLiteral("platform"), QString::number(pId));
0145       }
0146       }
0147       break;
0148 
0149     case Raw:
0150       q.setQuery(request().value());
0151       break;
0152 
0153     default:
0154       myWarning() << source() << "- key not recognized:" << request().key();
0155       stop();
0156       return;
0157   }
0158 
0159   if(m_apiKey.isEmpty()) {
0160     myDebug() << source() << "- empty API key";
0161     message(i18n("An access key is required to use this data source.")
0162             + QLatin1Char(' ') +
0163             i18n("Those values must be entered in the data source settings."), MessageHandler::Error);
0164     stop();
0165     return;
0166   }
0167 
0168   q.addQueryItem(QStringLiteral("api_key"), m_apiKey);
0169   q.addQueryItem(QStringLiteral("limit"), QString::number(MOBYGAMES_MAX_RETURNS_TOTAL));
0170   q.addQueryItem(QStringLiteral("format"), QStringLiteral("normal"));
0171   u.setQuery(q);
0172 //  u = QUrl::fromLocalFile(QStringLiteral("/home/robby/games.json"));
0173 //  myDebug() << u;
0174 
0175   markTime();
0176   m_job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
0177   KJobWidgets::setWindow(m_job, GUI::Proxy::widget());
0178   connect(m_job.data(), &KJob::result, this, &MobyGamesFetcher::slotComplete);
0179 }
0180 
0181 void MobyGamesFetcher::stop() {
0182   if(!m_started) {
0183     return;
0184   }
0185   if(m_job) {
0186     m_job->kill();
0187     m_job = nullptr;
0188   }
0189   m_started = false;
0190   emit signalDone(this);
0191 }
0192 
0193 Tellico::Data::EntryPtr MobyGamesFetcher::fetchEntryHook(uint uid_) {
0194   if(!m_entries.contains(uid_)) {
0195     myDebug() << "no entry ptr";
0196     return Data::EntryPtr();
0197   }
0198 
0199   Data::EntryPtr entry = m_entries.value(uid_);
0200 
0201   QUrl u(QString::fromLatin1(MOBYGAMES_API_URL));
0202   u.setPath(u.path() + QStringLiteral("/games/%1/platforms/%2")
0203                        .arg(entry->field(QStringLiteral("moby-id")),
0204                             entry->field(QStringLiteral("platform-id"))));
0205   QUrlQuery q;
0206   q.addQueryItem(QStringLiteral("api_key"), m_apiKey);
0207   u.setQuery(q);
0208 //  myDebug() << u;
0209 
0210   markTime();
0211   QPointer<KIO::StoredTransferJob> job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
0212   KJobWidgets::setWindow(job, GUI::Proxy::widget());
0213   if(!job->exec()) {
0214     myDebug() << job->errorString() << u;
0215     return entry;
0216   }
0217   QByteArray data = job->data();
0218   if(data.isEmpty()) {
0219     myDebug() << "no data for" << u;
0220     return entry;
0221   }
0222 #if 0
0223   myWarning() << "Remove platforms debug from mobygamesfetcher.cpp";
0224   QFile file(QStringLiteral("/tmp/moby-game-info.json"));
0225   if(file.open(QIODevice::WriteOnly)) {
0226     QTextStream t(&file);
0227     t.setCodec("UTF-8");
0228     t << data;
0229   }
0230   file.close();
0231 #endif
0232 
0233   QJsonDocument doc = QJsonDocument::fromJson(data);
0234   QVariantMap map = doc.object().toVariantMap();
0235   foreach(const QVariant& rating, map.value(QStringLiteral("ratings")).toList()) {
0236     const QVariantMap ratingMap = rating.toMap();
0237     const QString ratingSystem = ratingMap.value(QStringLiteral("rating_system_name")).toString();
0238     if(ratingSystem == QStringLiteral("PEGI Rating")) {
0239       QString rating = ratingMap.value(QStringLiteral("rating_name")).toString();
0240       if(!rating.startsWith(QStringLiteral("PEGI"))) {
0241         rating.prepend(QStringLiteral("PEGI "));
0242       }
0243       entry->setField(QStringLiteral("pegi"), rating);
0244     } else if(ratingSystem == QStringLiteral("ESRB Rating")) {
0245       const int esrb = ratingMap.value(QStringLiteral("rating_id")).toInt();
0246       if(m_esrbHash.contains(esrb)) {
0247         entry->setField(QStringLiteral("certification"), m_esrbHash.value(esrb));
0248       }
0249     }
0250   }
0251   // just use the first release
0252   const QVariantList releaseList = map.value(QStringLiteral("releases")).toList();
0253   if(!releaseList.isEmpty()) {
0254     const QVariantMap releaseMap = releaseList.at(0).toMap();
0255     QStringList pubs, devs;
0256     foreach(const QVariant& company, releaseMap.value(QStringLiteral("companies")).toList()) {
0257       const QVariantMap companyMap = company.toMap();
0258       if(companyMap.value(QStringLiteral("role")) == QStringLiteral("Developed by")) {
0259         devs += companyMap.value(QStringLiteral("company_name")).toString();
0260       } else if(companyMap.value(QStringLiteral("role")) == QStringLiteral("Published by")) {
0261         pubs += companyMap.value(QStringLiteral("company_name")).toString();
0262       }
0263     }
0264 //    myDebug() << pubs << devs;
0265     entry->setField(QStringLiteral("publisher"), pubs.join(FieldFormat::delimiterString()));
0266     entry->setField(QStringLiteral("developer"), devs.join(FieldFormat::delimiterString()));
0267   }
0268 
0269   if(m_imageSize == NoImage) {
0270     entry->setField(QStringLiteral("moby-id"), QString());
0271     entry->setField(QStringLiteral("platform-id"), QString());
0272     return entry;
0273   }
0274 
0275   // check for empty cover
0276   const QString image_id = entry->field(QStringLiteral("cover"));
0277   if(!image_id.isEmpty()) {
0278     return entry;
0279   }
0280 
0281   u = QUrl(QString::fromLatin1(MOBYGAMES_API_URL));
0282   u.setPath(u.path() + QStringLiteral("/games/%1/platforms/%2/covers")
0283                        .arg(entry->field(QStringLiteral("moby-id")),
0284                             entry->field(QStringLiteral("platform-id"))));
0285   u.setQuery(q);
0286 //  myDebug() << u;
0287 
0288   markTime();
0289   job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
0290   KJobWidgets::setWindow(job, GUI::Proxy::widget());
0291   if(!job->exec()) {
0292     myDebug() << job->errorString() << u;
0293     return entry;
0294   }
0295   data = job->data();
0296   if(data.isEmpty()) {
0297     myDebug() << "no data for" << u;
0298     return entry;
0299   }
0300 #if 0
0301   myWarning() << "Remove covers debug from mobygamesfetcher.cpp";
0302   QFile file2(QStringLiteral("/tmp/moby-covers.json"));
0303   if(file2.open(QIODevice::WriteOnly)) {
0304     QTextStream t(&file2);
0305     t.setCodec("UTF-8");
0306     t << data;
0307   }
0308   file2.close();
0309 #endif
0310 
0311   QString coverUrl;
0312   doc = QJsonDocument::fromJson(data);
0313   map = doc.object().toVariantMap();
0314   // prefer "Front Cover" but fall back to "Media"
0315   QString front, media;
0316   QVariantList coverGroupList = map.value(QStringLiteral("cover_groups")).toList();
0317   foreach(const QVariant& coverGroup, coverGroupList) {
0318     // just take the cover from the first group with front cover, appear to be grouped by country
0319     QVariantList coverList = coverGroup.toMap().value(QStringLiteral("covers")).toList();
0320     foreach(const QVariant& coverVariant, coverList) {
0321       const QVariantMap coverMap = coverVariant.toMap();
0322       if(media.isEmpty() &&
0323          coverMap.value(QStringLiteral("scan_of")) == QStringLiteral("Media")) {
0324         media = m_imageSize == SmallImage ?
0325                 coverMap.value(QStringLiteral("thumbnail_image")).toString() :
0326                 coverMap.value(QStringLiteral("image")).toString();
0327       } else if(coverMap.value(QStringLiteral("scan_of")) == QStringLiteral("Front Cover")) {
0328         front = m_imageSize == SmallImage ?
0329                 coverMap.value(QStringLiteral("thumbnail_image")).toString() :
0330                 coverMap.value(QStringLiteral("image")).toString();
0331         break;
0332       }
0333     }
0334     if(!front.isEmpty()) {
0335       // no need to continue iteration through cover groups
0336       break;
0337     }
0338   }
0339 
0340   coverUrl = front.isEmpty() ? media : front; // fall back to media image
0341 
0342   if(!coverUrl.isEmpty()) {
0343 //    myDebug() << coverUrl;
0344     const QString id = ImageFactory::addImage(QUrl::fromUserInput(coverUrl), true /* quiet */);
0345     if(id.isEmpty()) {
0346       message(i18n("The cover image could not be loaded."), MessageHandler::Warning);
0347     }
0348     // empty image ID is ok
0349     entry->setField(QStringLiteral("cover"), id);
0350   }
0351 
0352   const QString screenshot = QStringLiteral("screenshot");
0353   if(optionalFields().contains(screenshot)) {
0354     if(!entry->collection()->hasField(screenshot)) {
0355       entry->collection()->addField(Data::Field::createDefaultField(Data::Field::ScreenshotField));
0356     }
0357     u = QUrl(QString::fromLatin1(MOBYGAMES_API_URL));
0358     u.setPath(u.path() + QStringLiteral("/games/%1/platforms/%2/screenshots")
0359                          .arg(entry->field(QStringLiteral("moby-id")),
0360                               entry->field(QStringLiteral("platform-id"))));
0361     u.setQuery(q);
0362     markTime();
0363     job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
0364     KJobWidgets::setWindow(job, GUI::Proxy::widget());
0365     if(!job->exec()) {
0366       myDebug() << job->errorString() << u;
0367       return entry;
0368     }
0369 #if 0
0370     myWarning() << "Remove screenshots debug from mobygamesfetcher.cpp";
0371     QFile file3(QStringLiteral("/tmp/moby-screenshots.json"));
0372     if(file3.open(QIODevice::WriteOnly)) {
0373       QTextStream t(&file3);
0374       t.setCodec("UTF-8");
0375       t << job->data();
0376     }
0377     file3.close();
0378 #endif
0379     QString screenshotUrl;
0380     doc = QJsonDocument::fromJson(job->data());
0381     map = doc.object().toVariantMap();
0382     auto list = map.value(QStringLiteral("screenshots")).toList();
0383     if(!list.isEmpty()) {
0384       screenshotUrl = mapValue(list.at(0).toMap(), "image");
0385     }
0386     if(!screenshotUrl.isEmpty()) {
0387 //      myDebug() << screenshotUrl;
0388       const QString id = ImageFactory::addImage(QUrl::fromUserInput(screenshotUrl), true /* quiet */);
0389       entry->setField(screenshot, id);
0390     }
0391   }
0392 
0393   // clear the placeholder fields
0394   entry->setField(QStringLiteral("moby-id"), QString());
0395   entry->setField(QStringLiteral("platform-id"), QString());
0396   return entry;
0397 }
0398 
0399 Tellico::Fetch::FetchRequest MobyGamesFetcher::updateRequest(Data::EntryPtr entry_) {
0400   const QString title = entry_->field(QStringLiteral("title"));
0401   const QString platform = entry_->field(QStringLiteral("platform"));
0402   // if the platform name is not empty, we can use that to limit the title search
0403   if(!platform.isEmpty()) {
0404     // iterate through platform map to potentially match name
0405     // would ultimately be faster to have a second hash to map name to id or a bidirectional
0406     // could assume the platform name is already normalized, but allow user to have entered something
0407     // not quite the same
0408     if(m_platforms.isEmpty()) {
0409       updatePlatforms();
0410     }
0411     const int pId = m_platforms.key(Data::GameCollection::normalizePlatform(platform));
0412     if(pId > 0) {
0413       return FetchRequest(Raw, QString::fromLatin1("title=%1&platform=%2").arg(title, QString::number(pId)));
0414     }
0415   }
0416 
0417   // fallback to pure title search
0418   if(!title.isEmpty()) {
0419     return FetchRequest(Title, title);
0420   }
0421   return FetchRequest();
0422 }
0423 
0424 void MobyGamesFetcher::slotComplete(KJob* job_) {
0425   KIO::StoredTransferJob* job = static_cast<KIO::StoredTransferJob*>(job_);
0426 
0427   if(job->error()) {
0428     job->uiDelegate()->showErrorMessage();
0429     stop();
0430     return;
0431   }
0432 
0433   const QByteArray data = job->data();
0434   if(data.isEmpty()) {
0435     myDebug() << "no data";
0436     stop();
0437     return;
0438   }
0439   // see bug 319662. If fetcher is cancelled, job is killed
0440   // if the pointer is retained, it gets double-deleted
0441   m_job = nullptr;
0442 
0443 #if 0
0444   myWarning() << "Remove debug from mobygamesfetcher.cpp";
0445   QFile file(QStringLiteral("/tmp/moby-results.json"));
0446   if(file.open(QIODevice::WriteOnly)) {
0447     QTextStream t(&file);
0448     t.setCodec("UTF-8");
0449     t << data;
0450   }
0451   file.close();
0452 #endif
0453 
0454   Data::CollPtr coll(new Data::GameCollection(true));
0455   if(optionalFields().contains(QStringLiteral("pegi"))) {
0456     coll->addField(Data::Field::createDefaultField(Data::Field::PegiField));
0457   }
0458   if(optionalFields().contains(QStringLiteral("mobygames"))) {
0459     Data::FieldPtr field(new Data::Field(QStringLiteral("mobygames"), i18n("MobyGames Link"), Data::Field::URL));
0460     field->setCategory(i18n("General"));
0461     coll->addField(field);
0462   }
0463   // placeholder for mobygames id, to be removed later
0464   Data::FieldPtr f1(new Data::Field(QStringLiteral("moby-id"), QString(), Data::Field::Number));
0465   coll->addField(f1);
0466   Data::FieldPtr f2(new Data::Field(QStringLiteral("platform-id"), QString(), Data::Field::Number));
0467   coll->addField(f2);
0468 
0469   QJsonDocument doc = QJsonDocument::fromJson(data);
0470   QVariantMap map = doc.object().toVariantMap();
0471 
0472   // check for error
0473   if(map.contains(QStringLiteral("error"))) {
0474     const QString msg = map.value(QStringLiteral("message")).toString();
0475     message(msg, MessageHandler::Error);
0476     myDebug() << "MobyGamesFetcher -" << msg;
0477     stop();
0478     return;
0479   }
0480 
0481   if(m_platforms.isEmpty()) {
0482     updatePlatforms();
0483   }
0484 
0485   foreach(const QVariant& result, map.value(QStringLiteral("games")).toList()) {
0486     QVariantMap resultMap = result.toMap();
0487     Data::EntryList entries = createEntries(coll, resultMap);
0488     foreach(const Data::EntryPtr& entry, entries) {
0489       FetchResult* r = new FetchResult(this, entry);
0490       m_entries.insert(r->uid, entry);
0491       emit signalResultFound(r);
0492     }
0493   }
0494 
0495   stop();
0496 }
0497 
0498 Tellico::Data::EntryList MobyGamesFetcher::createEntries(Data::CollPtr coll_, const QVariantMap& resultMap_) {
0499   Data::EntryPtr entry(new Data::Entry(coll_));
0500   entry->setField(QStringLiteral("title"), mapValue(resultMap_, "title"));
0501   entry->setField(QStringLiteral("description"), mapValue(resultMap_, "description"));
0502   entry->setField(QStringLiteral("moby-id"), mapValue(resultMap_, "game_id"));
0503 
0504   QStringList genres;
0505   foreach(const QVariant& genreMap, resultMap_.value(QStringLiteral("genres")).toList()) {
0506     const QString g = genreMap.toMap().value(QStringLiteral("genre_name")).toString();
0507     if(!g.isEmpty()) {
0508       genres << g;
0509     }
0510   }
0511   entry->setField(QStringLiteral("genre"), genres.join(FieldFormat::delimiterString()));
0512 
0513   if(optionalFields().contains(QStringLiteral("mobygames"))) {
0514     entry->setField(QStringLiteral("mobygames"), mapValue(resultMap_, "moby_url"));
0515   }
0516 
0517   const QString platformS(QStringLiteral("platform"));
0518 
0519   // for efficiency, check if the search includes a platform
0520   // since the results will include all the platforms, not just the searched one
0521   if(request().key() == Raw &&
0522      request().value().contains(platformS)) {
0523     QUrlQuery q(request().value());
0524     m_requestPlatformId = q.queryItemValue(platformS).toInt();
0525   }
0526 
0527   Data::EntryList entries;
0528   // return a new entry for every platform
0529   foreach(const QVariant& platformMapV, resultMap_.value(QStringLiteral("platforms")).toList()) {
0530     Data::EntryPtr newEntry(new Data::Entry(*entry));
0531 
0532     const QVariantMap platformMap = platformMapV.toMap();
0533     const int platformId = platformMap.value(QStringLiteral("platform_id")).toInt();
0534     if(m_platforms.contains(platformId)) {
0535       const QString platform = m_platforms[platformId];
0536       // make the assumption that if the platform name isn't already in the allowed list, it should be added
0537       Data::FieldPtr f = newEntry->collection()->fieldByName(platformS);
0538       if(f && !f->allowed().contains(platform)) {
0539         f->setAllowed(QStringList(f->allowed()) << platform);
0540       }
0541       newEntry->setField(platformS, platform);
0542     } else {
0543       myDebug() << "platform list does not contain" << platformId << mapValue(platformMap, "platform_name");
0544     }
0545 
0546     newEntry->setField(QStringLiteral("platform-id"),
0547                        platformMap.value(QStringLiteral("platform_id")).toString());
0548     newEntry->setField(QStringLiteral("year"),
0549                        platformMap.value(QStringLiteral("first_release_date")).toString().left(4));
0550     if(m_requestPlatformId == 0 || m_requestPlatformId == platformId) entries << newEntry;
0551   }
0552   return entries;
0553 }
0554 
0555 void MobyGamesFetcher::markTime() {
0556   // need to wait a bit after previous query, Moby error message say 1 sec
0557   if(m_idleTime.elapsed() < 1000) QThread::msleep(1000);
0558   m_idleTime.restart();
0559 }
0560 
0561 void MobyGamesFetcher::populateHashes() {
0562   // cheat by grabbing i18n values from default collection
0563   Data::CollPtr c(new Data::GameCollection(true));
0564   QStringList esrb = c->fieldByName(QStringLiteral("certification"))->allowed();
0565   Q_ASSERT(esrb.size() == 8);
0566   while(esrb.size() < 8) {
0567     esrb << QString();
0568   }
0569   // 89 == EC
0570   // 90 == Everyone
0571   // etc.
0572   // 95 == Pending
0573   // https://www.mobygames.com/attribute/sheet/attributeId,89
0574   m_esrbHash.insert(89, esrb.at(6));
0575   m_esrbHash.insert(90, esrb.at(5));
0576   m_esrbHash.insert(91, esrb.at(4));
0577   m_esrbHash.insert(92, esrb.at(3));
0578   m_esrbHash.insert(93, esrb.at(2));
0579   m_esrbHash.insert(94, esrb.at(1));
0580   m_esrbHash.insert(95, esrb.at(7));
0581 
0582   // Read the cached data for the platform list
0583   QFile file(Tellico::saveLocation(QStringLiteral("mobygames-data/")) + QLatin1String("platforms.json"));
0584   if(file.open(QIODevice::ReadOnly)) {
0585     m_platforms.clear();
0586     const QVariantMap topMap = QJsonDocument::fromJson(file.readAll()).object().toVariantMap();
0587     foreach(const QVariant& platform, topMap.value(QStringLiteral("platforms")).toList()) {
0588       const QVariantMap m = platform.toMap();
0589       Data::GameCollection::GamePlatform pId = Data::GameCollection::guessPlatform(mapValue(m, "platform_name"));
0590       if(pId == Data::GameCollection::UnknownPlatform) {
0591         // platform is not in the default list, just keep it as is
0592         m_platforms.insert(m.value(QStringLiteral("platform_id")).toInt(),
0593                            mapValue(m, "platform_name"));
0594       } else {
0595         m_platforms.insert(m.value(QStringLiteral("platform_id")).toInt(),
0596                            Data::GameCollection::platformName(pId));
0597       }
0598     }
0599   } else if(file.exists()) { // don't want errors for non-existing file
0600     myDebug() << "Failed to read from" << file.fileName() << file.errorString();
0601   }
0602 }
0603 
0604 void MobyGamesFetcher::updatePlatforms() {
0605   QUrl u(QString::fromLatin1(MOBYGAMES_API_URL));
0606   u.setPath(u.path() + QStringLiteral("/platforms"));
0607   QUrlQuery q;
0608   q.addQueryItem(QStringLiteral("api_key"), m_apiKey);
0609   u.setQuery(q);
0610 
0611 //  u = QUrl::fromLocalFile(QStringLiteral("/home/robby/platforms.json")); // for testing
0612 //  myDebug() << "Reading platforms from" << u;
0613   markTime();
0614   const QByteArray data = FileHandler::readDataFile(u, true);
0615   QFile file(Tellico::saveLocation(QStringLiteral("mobygames-data/")) + QLatin1String("platforms.json"));
0616   if(!file.open(QIODevice::WriteOnly) || file.write(data) == -1) {
0617     myDebug() << "unable to write to" << file.fileName() << file.errorString();
0618     return;
0619   }
0620   file.close();
0621   populateHashes();
0622 }
0623 
0624 Tellico::Fetch::ConfigWidget* MobyGamesFetcher::configWidget(QWidget* parent_) const {
0625   return new MobyGamesFetcher::ConfigWidget(parent_, this);
0626 }
0627 
0628 QString MobyGamesFetcher::defaultName() {
0629   return QStringLiteral("MobyGames");
0630 }
0631 
0632 QString MobyGamesFetcher::defaultIcon() {
0633   return favIcon("https://www.mobygames.com/static/img/favicon.ico");
0634 }
0635 
0636 Tellico::StringHash MobyGamesFetcher::allOptionalFields() {
0637   StringHash hash;
0638   hash[QStringLiteral("pegi")] = i18n("PEGI Rating");
0639   hash[QStringLiteral("mobygames")] = i18n("MobyGames Link");
0640   hash[QStringLiteral("screenshot")] = i18n("Screenshot");
0641   return hash;
0642 }
0643 
0644 MobyGamesFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const MobyGamesFetcher* fetcher_)
0645     : Fetch::ConfigWidget(parent_) {
0646   QGridLayout* l = new QGridLayout(optionsWidget());
0647   l->setSpacing(4);
0648   l->setColumnStretch(1, 10);
0649 
0650   int row = -1;
0651 
0652   QLabel* al = new QLabel(i18n("Registration is required for accessing this data source. "
0653                                "If you agree to the terms and conditions, <a href='%1'>sign "
0654                                "up for an account</a>, and enter your information below.",
0655                                 QStringLiteral("https://www.mobygames.com/info/api")),
0656                           optionsWidget());
0657   al->setOpenExternalLinks(true);
0658   al->setWordWrap(true);
0659   ++row;
0660   l->addWidget(al, row, 0, 1, 2);
0661   // richtext gets weird with size
0662   al->setMinimumWidth(al->sizeHint().width());
0663 
0664   QLabel* label = new QLabel(i18n("Access key: "), optionsWidget());
0665   l->addWidget(label, ++row, 0);
0666 
0667   m_apiKeyEdit = new QLineEdit(optionsWidget());
0668   connect(m_apiKeyEdit, &QLineEdit::textChanged, this, &ConfigWidget::slotSetModified);
0669   l->addWidget(m_apiKeyEdit, row, 1);
0670   label->setBuddy(m_apiKeyEdit);
0671 
0672   label = new QLabel(i18n("&Image size: "), optionsWidget());
0673   l->addWidget(label, ++row, 0);
0674   m_imageCombo = new GUI::ComboBox(optionsWidget());
0675   m_imageCombo->addItem(i18n("Small Image"), SmallImage);
0676 //  m_imageCombo->addItem(i18n("Medium Image"), MediumImage); // no medium right now, either thumbnail (small) or large
0677   m_imageCombo->addItem(i18n("Large Image"), LargeImage);
0678   m_imageCombo->addItem(i18n("No Image"), NoImage);
0679   void (GUI::ComboBox::* activatedInt)(int) = &GUI::ComboBox::activated;
0680   connect(m_imageCombo, activatedInt, this, &ConfigWidget::slotSetModified);
0681   l->addWidget(m_imageCombo, row, 1);
0682   QString w = i18n("The cover image may be downloaded as well. However, too many large images in the "
0683                    "collection may degrade performance.");
0684   label->setWhatsThis(w);
0685   m_imageCombo->setWhatsThis(w);
0686   label->setBuddy(m_imageCombo);
0687 
0688   l->setRowStretch(++row, 10);
0689 
0690   // now add additional fields widget
0691   addFieldsWidget(MobyGamesFetcher::allOptionalFields(), fetcher_ ? fetcher_->optionalFields() : QStringList());
0692 
0693   if(fetcher_) {
0694     m_apiKeyEdit->setText(fetcher_->m_apiKey);
0695     m_imageCombo->setCurrentData(fetcher_->m_imageSize);
0696   } else { // defaults
0697     m_imageCombo->setCurrentData(SmallImage);
0698   }
0699 }
0700 
0701 void MobyGamesFetcher::ConfigWidget::saveConfigHook(KConfigGroup& config_) {
0702   const QString apiKey = m_apiKeyEdit->text().trimmed();
0703   if(!apiKey.isEmpty()) {
0704     config_.writeEntry("API Key", apiKey);
0705   }
0706   const int n = m_imageCombo->currentData().toInt();
0707   config_.writeEntry("Image Size", n);
0708 }
0709 
0710 QString MobyGamesFetcher::ConfigWidget::preferredName() const {
0711   return MobyGamesFetcher::defaultName();
0712 }