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 }