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

0001 /***************************************************************************
0002     Copyright (C) 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 "tvmazefetcher.h"
0026 #include "../collections/videocollection.h"
0027 #include "../images/imagefactory.h"
0028 #include "../core/filehandler.h"
0029 #include "../utils/guiproxy.h"
0030 #include "../utils/mapvalue.h"
0031 #include "../core/tellico_strings.h"
0032 #include "../tellico_debug.h"
0033 
0034 #include <KLocalizedString>
0035 #include <KConfigGroup>
0036 #include <KJob>
0037 #include <KJobUiDelegate>
0038 #include <KJobWidgets/KJobWidgets>
0039 #include <KIO/StoredTransferJob>
0040 
0041 #include <QLabel>
0042 #include <QFile>
0043 #include <QTextStream>
0044 #include <QVBoxLayout>
0045 #include <QTextCodec>
0046 #include <QJsonDocument>
0047 #include <QJsonObject>
0048 #include <QJsonArray>
0049 #include <QUrlQuery>
0050 
0051 namespace {
0052   static const int TVMAZE_MAX_RETURNS_TOTAL = 20;
0053   static const char* TVMAZE_API_URL = "https://api.tvmaze.com";
0054 }
0055 
0056 using namespace Tellico;
0057 using Tellico::Fetch::TVmazeFetcher;
0058 
0059 TVmazeFetcher::TVmazeFetcher(QObject* parent_)
0060     : Fetcher(parent_)
0061     , m_started(false) {
0062 }
0063 
0064 TVmazeFetcher::~TVmazeFetcher() {
0065 }
0066 
0067 QString TVmazeFetcher::source() const {
0068   return m_name.isEmpty() ? defaultName() : m_name;
0069 }
0070 
0071 // https://www.tvmaze.com/api#licensing
0072 QString TVmazeFetcher::attribution() const {
0073   return i18n(providedBy, QLatin1String("https://tvmaze.com"), QLatin1String("TVmaze"));
0074 }
0075 
0076 bool TVmazeFetcher::canSearch(Fetch::FetchKey k) const {
0077   return k == Title;
0078 }
0079 
0080 bool TVmazeFetcher::canFetch(int type) const {
0081   return type == Data::Collection::Video;
0082 }
0083 
0084 void TVmazeFetcher::readConfigHook(const KConfigGroup& config_) {
0085   Q_UNUSED(config_)
0086 }
0087 
0088 void TVmazeFetcher::saveConfigHook(KConfigGroup& config_) {
0089   Q_UNUSED(config_)
0090 }
0091 
0092 void TVmazeFetcher::search() {
0093   continueSearch();
0094 }
0095 
0096 void TVmazeFetcher::continueSearch() {
0097   m_started = true;
0098 
0099 
0100   QUrl u(QString::fromLatin1(TVMAZE_API_URL));
0101 
0102   switch(request().key()) {
0103     case Title:
0104       u = u.adjusted(QUrl::StripTrailingSlash);
0105       u.setPath(u.path() + QLatin1String("/search/shows"));
0106       {
0107         QUrlQuery q;
0108         q.addQueryItem(QStringLiteral("q"), request().value());
0109         u.setQuery(q);
0110       }
0111       break;
0112 
0113     default:
0114       myWarning() << source() << "- key not recognized:" << request().key();
0115       stop();
0116       return;
0117   }
0118 
0119   m_job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
0120   KJobWidgets::setWindow(m_job, GUI::Proxy::widget());
0121   connect(m_job.data(), &KJob::result, this, &TVmazeFetcher::slotComplete);
0122 }
0123 
0124 void TVmazeFetcher::stop() {
0125   if(!m_started) {
0126     return;
0127   }
0128   if(m_job) {
0129     m_job->kill();
0130     m_job = nullptr;
0131   }
0132   m_started = false;
0133   emit signalDone(this);
0134 }
0135 
0136 Tellico::Fetch::FetchRequest TVmazeFetcher::updateRequest(Data::EntryPtr entry_) {
0137   QString title = entry_->field(QStringLiteral("title"));
0138   if(!title.isEmpty()) {
0139     return FetchRequest(Title, title);
0140   }
0141   return FetchRequest();
0142 }
0143 
0144 void TVmazeFetcher::slotComplete(KJob* job_) {
0145   KIO::StoredTransferJob* job = static_cast<KIO::StoredTransferJob*>(job_);
0146 
0147   if(job->error()) {
0148     job->uiDelegate()->showErrorMessage();
0149     stop();
0150     return;
0151   }
0152 
0153   const QByteArray data = job->data();
0154   if(data.isEmpty()) {
0155     myDebug() << "TVmaze: no data";
0156     stop();
0157     return;
0158   }
0159   // see bug 319662. If fetcher is cancelled, job is killed
0160   // if the pointer is retained, it gets double-deleted
0161   m_job = nullptr;
0162 
0163 #if 0
0164   myWarning() << "Remove debug from tvmazefetcher..cpp";
0165   QFile f(QStringLiteral("/tmp/test.json"));
0166   if(f.open(QIODevice::WriteOnly)) {
0167     QTextStream t(&f);
0168     t.setCodec("UTF-8");
0169     t << data;
0170   }
0171   f.close();
0172 #endif
0173 
0174   Data::CollPtr coll(new Data::VideoCollection(true));
0175   // always add the tvmaze-id for fetchEntryHook
0176   Data::FieldPtr field(new Data::Field(QStringLiteral("tvmaze-id"), QString(), Data::Field::Line));
0177   field->setCategory(i18n("General"));
0178   coll->addField(field);
0179 
0180   if(optionalFields().contains(QStringLiteral("network"))) {
0181     Data::FieldPtr field(new Data::Field(QStringLiteral("network"), i18n("Network"), Data::Field::Line));
0182     field->setCategory(i18n("General"));
0183     coll->addField(field);
0184   }
0185   if(optionalFields().contains(QStringLiteral("imdb"))) {
0186     coll->addField(Data::Field::createDefaultField(Data::Field::ImdbField));
0187   }
0188   if(optionalFields().contains(QStringLiteral("episode"))) {
0189     coll->addField(Data::Field::createDefaultField(Data::Field::EpisodeField));
0190   }
0191   if(optionalFields().contains(QStringLiteral("alttitle"))) {
0192     Data::FieldPtr field(new Data::Field(QStringLiteral("alttitle"), i18n("Alternative Titles"), Data::Field::Table));
0193     field->setFormatType(FieldFormat::FormatTitle);
0194     coll->addField(field);
0195   }
0196 
0197   QJsonDocument doc = QJsonDocument::fromJson(data);
0198   const QJsonArray results = doc.array();
0199 
0200   if(results.isEmpty()) {
0201     stop();
0202     return;
0203   }
0204 
0205   int count = 0;
0206   foreach(const QJsonValue& result, results) {
0207     Data::EntryPtr entry(new Data::Entry(coll));
0208     populateEntry(entry, result.toObject().value(QLatin1String("show"))
0209                                .toObject().toVariantMap(), false);
0210 
0211     FetchResult* r = new FetchResult(this, entry);
0212     m_entries.insert(r->uid, entry);
0213     emit signalResultFound(r);
0214     ++count;
0215     if(count >= TVMAZE_MAX_RETURNS_TOTAL) {
0216       break;
0217     }
0218   }
0219 
0220   stop();
0221 }
0222 
0223 Tellico::Data::EntryPtr TVmazeFetcher::fetchEntryHook(uint uid_) {
0224   Data::EntryPtr entry = m_entries.value(uid_);
0225   if(!entry) {
0226     myWarning() << "no entry in dict";
0227     return Data::EntryPtr();
0228   }
0229 
0230   const QString id = entry->field(QStringLiteral("tvmaze-id"));
0231   if(!id.isEmpty()) {
0232     // quiet
0233     QUrl u(QString::fromLatin1(TVMAZE_API_URL));
0234     u.setPath(QStringLiteral("/shows/%1").arg(id));
0235     QUrlQuery q;
0236     q.addQueryItem(QStringLiteral("embed[]"), QLatin1String("cast"));
0237     q.addQueryItem(QStringLiteral("embed[]"), QLatin1String("crew"));
0238     if(optionalFields().contains(QStringLiteral("episode"))) {
0239       q.addQueryItem(QStringLiteral("embed[]"), QLatin1String("episodes"));
0240     }
0241     if(optionalFields().contains(QStringLiteral("alttitle"))) {
0242       q.addQueryItem(QStringLiteral("embed[]"), QLatin1String("akas"));
0243     }
0244     u.setQuery(q);
0245     QByteArray data = FileHandler::readDataFile(u, true);
0246 #if 0
0247     myWarning() << "Remove debug2 from tvmazefetcher..cpp";
0248     QFile f(QStringLiteral("/tmp/test2.json"));
0249     if(f.open(QIODevice::WriteOnly)) {
0250       QTextStream t(&f);
0251       t.setCodec("UTF-8");
0252       t << data;
0253     }
0254     f.close();
0255 #endif
0256     QJsonDocument doc = QJsonDocument::fromJson(data);
0257     populateEntry(entry, doc.object().toVariantMap(), true);
0258   }
0259 
0260   // image might still be a URL
0261   const QString image_id = entry->field(QStringLiteral("cover"));
0262   if(image_id.contains(QLatin1Char('/'))) {
0263     const QString id = ImageFactory::addImage(QUrl::fromUserInput(image_id), true /* quiet */);
0264     if(id.isEmpty()) {
0265       message(i18n("The cover image could not be loaded."), MessageHandler::Warning);
0266     }
0267     // empty image ID is ok
0268     entry->setField(QStringLiteral("cover"), id);
0269   }
0270 
0271   // don't want to include ID field - absence indicates entry is fully populated
0272   entry->setField(QStringLiteral("tvmaze-id"), QString());
0273 
0274   return entry;
0275 }
0276 
0277 void TVmazeFetcher::populateEntry(Data::EntryPtr entry_, const QVariantMap& resultMap_, bool fullData_) {
0278   entry_->setField(QStringLiteral("tvmaze-id"), mapValue(resultMap_, "id"));
0279   entry_->setField(QStringLiteral("title"), mapValue(resultMap_, "name"));
0280   entry_->setField(QStringLiteral("year"),  mapValue(resultMap_, "premiered").left(4));
0281 
0282   // if we only need cursory data, then we're done
0283   if(!fullData_) {
0284     return;
0285   }
0286 
0287   QStringList directors, producers, writers, composers;
0288   QVariantList crewList = resultMap_.value(QStringLiteral("_embedded")).toMap()
0289                                     .value(QStringLiteral("crew")).toList();
0290   foreach(const QVariant& crew, crewList) {
0291     const QVariantMap crewMap = crew.toMap();
0292     const QString job = mapValue(crewMap, "type");
0293     // going to get a lot of producers
0294     if(job.contains(QLatin1String("Director"))) {
0295       directors += mapValue(crewMap, "person", "name");
0296     } else if(job.contains(QLatin1String("Producer"))) {
0297       producers += mapValue(crewMap, "person", "name");
0298     } else if(job.contains(QLatin1String("Creator"))) {
0299       writers += mapValue(crewMap, "person", "name");
0300     } else if(job.contains(QLatin1String("Composer"))) {
0301       composers += mapValue(crewMap, "person", "name");
0302     }
0303   }
0304   entry_->setField(QStringLiteral("director"), directors.join(FieldFormat::delimiterString()));
0305   entry_->setField(QStringLiteral("producer"), producers.join(FieldFormat::delimiterString()));
0306   entry_->setField(QStringLiteral("writer"),     writers.join(FieldFormat::delimiterString()));
0307   entry_->setField(QStringLiteral("composer"), composers.join(FieldFormat::delimiterString()));
0308 
0309   const QString network(QStringLiteral("network"));
0310   if(entry_->collection()->hasField(network)) {
0311     entry_->setField(network, mapValue(resultMap_, "network", "name"));
0312   }
0313 
0314   const QString imdb(QStringLiteral("imdb"));
0315   if(entry_->collection()->hasField(imdb)) {
0316     entry_->setField(imdb, QLatin1String("https://www.imdb.com/title/") + mapValue(resultMap_, "externals", "imdb"));
0317   }
0318 
0319   QStringList actors;
0320   QVariantList castList = resultMap_.value(QStringLiteral("_embedded")).toMap()
0321                                     .value(QStringLiteral("cast")).toList();
0322   foreach(const QVariant& cast, castList) {
0323     QVariantMap castMap = cast.toMap();
0324     actors << mapValue(castMap, "person", "name") + FieldFormat::columnDelimiterString() + mapValue(castMap, "character", "name");
0325   }
0326   entry_->setField(QStringLiteral("cast"), actors.join(FieldFormat::rowDelimiterString()));
0327 
0328   QStringList genres;
0329   foreach(const QVariant& genre, resultMap_.value(QLatin1String("genres")).toList()) {
0330     genres << genre.toString();
0331   }
0332   entry_->setField(QStringLiteral("genre"), genres.join(FieldFormat::delimiterString()));
0333 
0334   const QString episode(QStringLiteral("episode"));
0335   if(entry_->collection()->hasField(episode)) {
0336     QStringList episodes;
0337     QVariantList episodeList = resultMap_.value(QStringLiteral("_embedded")).toMap()
0338                                          .value(QStringLiteral("episodes")).toList();
0339     foreach(const QVariant& row, episodeList) {
0340       QVariantMap map = row.toMap();
0341       episodes << mapValue(map, "name") + FieldFormat::columnDelimiterString() +
0342                   mapValue(map, "season") + FieldFormat::columnDelimiterString() +
0343                   mapValue(map, "number");
0344     }
0345     entry_->setField(episode, episodes.join(FieldFormat::rowDelimiterString()));
0346   }
0347 
0348   const QString alttitle(QStringLiteral("alttitle"));
0349   if(entry_->collection()->hasField(alttitle)) {
0350     QStringList alttitles;
0351     QVariantList titleList = resultMap_.value(QStringLiteral("_embedded")).toMap()
0352                                        .value(QStringLiteral("akas")).toList();
0353     foreach(const QVariant& title, titleList) {
0354       alttitles << mapValue(title.toMap(), "name");
0355     }
0356     entry_->setField(alttitle, alttitles.join(FieldFormat::rowDelimiterString()));
0357   }
0358 
0359   entry_->setField(QStringLiteral("cover"), mapValue(resultMap_, "image", "original"));
0360   entry_->setField(QStringLiteral("language"), mapValue(resultMap_, "language"));
0361   entry_->setField(QStringLiteral("plot"), mapValue(resultMap_, "summary"));
0362 }
0363 
0364 Tellico::Fetch::ConfigWidget* TVmazeFetcher::configWidget(QWidget* parent_) const {
0365   return new TVmazeFetcher::ConfigWidget(parent_, this);
0366 }
0367 
0368 QString TVmazeFetcher::defaultName() {
0369   return QStringLiteral("TVmaze");
0370 }
0371 
0372 QString TVmazeFetcher::defaultIcon() {
0373   return favIcon("https://static.tvmaze.com/images/favico/favicon.ico");
0374 }
0375 
0376 Tellico::StringHash TVmazeFetcher::allOptionalFields() {
0377   StringHash hash;
0378   hash[QStringLiteral("imdb")] = i18n("IMDb Link");
0379   hash[QStringLiteral("episode")] = i18n("Episodes");
0380   hash[QStringLiteral("network")] = i18n("Network");
0381   hash[QStringLiteral("alttitle")] = i18n("Alternative Titles");
0382   // TODO: network
0383   return hash;
0384 }
0385 
0386 TVmazeFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const TVmazeFetcher* fetcher_)
0387     : Fetch::ConfigWidget(parent_) {
0388   QVBoxLayout* l = new QVBoxLayout(optionsWidget());
0389   l->addWidget(new QLabel(i18n("This source has no options."), optionsWidget()));
0390   l->addStretch();
0391 
0392   // now add additional fields widget
0393   addFieldsWidget(TVmazeFetcher::allOptionalFields(), fetcher_ ? fetcher_->optionalFields() : QStringList());
0394 }
0395 
0396 QString TVmazeFetcher::ConfigWidget::preferredName() const {
0397   return TVmazeFetcher::defaultName();
0398 }