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 }