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

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