Git commit 6ac873862e9e44f6f6ade133d46917b3c86b4a26 by Robby Stephenson.
Committed on 16/01/2023 at 21:35.
Pushed by rstephenson into branch 'master'.

Add data source for FilmAffinity, with Spanish and English

M  +4    -0    ChangeLog
M  +8    -0    doc/configuration.docbook
M  +1    -0    src/fetch/CMakeLists.txt
M  +2    -1    src/fetch/fetch.h
M  +2    -0    src/fetch/fetcherinitializer.cpp
A  +518  -0    src/fetch/filmaffinityfetcher.cpp     [License: GPL (v2/3)]
A  +131  -0    src/fetch/filmaffinityfetcher.h     [License: GPL (v2/3)]
M  +6    -0    src/tests/CMakeLists.txt
A  +173  -0    src/tests/filmaffinityfetchertest.cpp     [License: GPL (v2/3)]
C  +19   -90   src/tests/filmaffinityfetchertest.h [from: src/fetch/fetch.h - 
055% similarity]

https://invent.kde.org/office/tellico/commit/6ac873862e9e44f6f6ade133d46917b3c86b4a26

diff --git a/ChangeLog b/ChangeLog
index c4bb1d9d..a3be2db0 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,7 @@
+2023-01-16  Robby Stephenson  <ro...@periapsis.org>
+
+       * Added data source for FilmAffinity.
+
 2023-01-12  Robby Stephenson  <ro...@periapsis.org>
 
        * Fixed bug with timing of multisource config read (Bug 461861).
diff --git a/doc/configuration.docbook b/doc/configuration.docbook
index 450ecda4..47dc003f 100644
--- a/doc/configuration.docbook
+++ b/doc/configuration.docbook
@@ -168,6 +168,7 @@ while the full list is <ulink 
url="https://tellico-project.org/data-sources";>ava
 <listitem><simpara><link 
linkend="allocine">AlloCiné</link>,</simpara></listitem>
 <listitem><simpara><link 
linkend="tmdb">TheMovieDB.org</link>,</simpara></listitem>
 <listitem><simpara>the <link linkend="omdb">Open Movie 
Database</link>,</simpara></listitem>
+<listitem><simpara><link 
linkend="filmaffinity">FilmAffinity</link>,</simpara></listitem>
 <!-- comics -->
 <listitem><simpara><link 
linkend="bedetheque">BDGest</link>,</simpara></listitem>
 <listitem><simpara><link linkend="comicvine">Comic 
Vine</link>,</simpara></listitem>
@@ -366,6 +367,13 @@ The <ulink url="http://www.imdb.com";>Internet Movie 
Database</ulink> provides in
 </para>
 </sect3>
 
+<sect3 id="filmaffinity">
+<title>FilmAffinity</title>
+<para>
+<ulink url="https://filmaffinity.com";>FilmAffinity</ulink> is an independent 
film site.
+</para>
+</sect3>
+
 </sect2>
 
 <!-- start of music sources -->
diff --git a/src/fetch/CMakeLists.txt b/src/fetch/CMakeLists.txt
index 9c6a4d83..359c1236 100644
--- a/src/fetch/CMakeLists.txt
+++ b/src/fetch/CMakeLists.txt
@@ -30,6 +30,7 @@ SET(fetch_STAT_SRCS
    fetchmanager.cpp
    fetchrequest.cpp
    fetchresult.cpp
+   filmaffinityfetcher.cpp
    filmasterfetcher.cpp
    gaminghistoryfetcher.cpp
    gcstarpluginfetcher.cpp
diff --git a/src/fetch/fetch.h b/src/fetch/fetch.h
index 4cbbd4bd..0905962b 100644
--- a/src/fetch/fetch.h
+++ b/src/fetch/fetch.h
@@ -110,7 +110,8 @@ enum Type {
   UPCItemDb,
   TheTVDB,
   RPGGeek,
-  GamingHistory
+  GamingHistory,
+  FilmAffinity
 };
 
   }
diff --git a/src/fetch/fetcherinitializer.cpp b/src/fetch/fetcherinitializer.cpp
index 454ba475..e042a167 100644
--- a/src/fetch/fetcherinitializer.cpp
+++ b/src/fetch/fetcherinitializer.cpp
@@ -77,6 +77,7 @@
 #include "thetvdbfetcher.h"
 #include "rpggeekfetcher.h"
 #include "gaminghistoryfetcher.h"
+#include "filmaffinityfetcher.h"
 
 /**
  * Ideally, I'd like these initializations to be in each cpp file for each 
collection type
@@ -132,6 +133,7 @@ Tellico::Fetch::FetcherInitializer::FetcherInitializer() {
   RegisterFetcher<Fetch::TheTVDBFetcher> registerTheTVDB(TheTVDB);
   RegisterFetcher<Fetch::RPGGeekFetcher> registerRPGGeek(RPGGeek);
   RegisterFetcher<Fetch::GamingHistoryFetcher> 
registerGamingHistory(GamingHistory);
+  RegisterFetcher<Fetch::FilmAffinityFetcher> 
registerFilmAffinity(FilmAffinity);
 
 // these data sources depend on being able to import bibtex
 #ifdef ENABLE_BTPARSE
diff --git a/src/fetch/filmaffinityfetcher.cpp 
b/src/fetch/filmaffinityfetcher.cpp
new file mode 100644
index 00000000..3f9bd39f
--- /dev/null
+++ b/src/fetch/filmaffinityfetcher.cpp
@@ -0,0 +1,518 @@
+/***************************************************************************
+    Copyright (C) 2023 Robby Stephenson <ro...@periapsis.org>
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or         *
+ *   modify it under the terms of the GNU General Public License as        *
+ *   published by the Free Software Foundation; either version 2 of        *
+ *   the License or (at your option) version 3 or any later version        *
+ *   accepted by the membership of KDE e.V. (or its successor approved     *
+ *   by the membership of KDE e.V.), which shall act as a proxy            *
+ *   defined in Section 14 of version 3 of the license.                    *
+ *                                                                         *
+ *   This program is distributed in the hope that it will be useful,       *
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
+ *   GNU General Public License for more details.                          *
+ *                                                                         *
+ *   You should have received a copy of the GNU General Public License     *
+ *   along with this program.  If not, see <http://www.gnu.org/licenses/>. *
+ *                                                                         *
+ ***************************************************************************/
+
+#include "filmaffinityfetcher.h"
+#include "../utils/guiproxy.h"
+#include "../utils/string_utils.h"
+#include "../collections/videocollection.h"
+#include "../entry.h"
+#include "../fieldformat.h"
+#include "../core/filehandler.h"
+#include "../images/imagefactory.h"
+#include "../gui/combobox.h"
+#include "../tellico_debug.h"
+
+#include <KLocalizedString>
+#include <KIO/Job>
+#include <KJobUiDelegate>
+#include <KJobWidgets/KJobWidgets>
+
+#include <QRegularExpression>
+#include <QLabel>
+#include <QFile>
+#include <QTextStream>
+#include <QGridLayout>
+#include <QSpinBox>
+#include <QUrlQuery>
+#include <QStandardPaths>
+
+namespace {
+  static const char* FILMAFFINITY_SEARCH_URL = "https://www.filmaffinity.com";;
+  static const uint FILMAFFINITY_DEFAULT_CAST_SIZE = 10;
+}
+
+using namespace Tellico;
+using Tellico::Fetch::FilmAffinityFetcher;
+
+FilmAffinityFetcher::FilmAffinityFetcher(QObject* parent_)
+    : Fetcher(parent_), m_started(false), m_locale(ES), 
m_numCast(FILMAFFINITY_DEFAULT_CAST_SIZE) {
+}
+
+FilmAffinityFetcher::~FilmAffinityFetcher() {
+}
+
+// static
+const FilmAffinityFetcher::LocaleData& FilmAffinityFetcher::localeData(int 
locale_) {
+  Q_ASSERT(locale_ >= 0);
+  Q_ASSERT(locale_ <  2);
+  static LocaleData dataVector[6] = {
+    {
+      QStringLiteral("es"),
+      QStringLiteral("(Serie de TV)"),
+      QString::fromUtf8("Año"),
+      QStringLiteral("Título original"),
+      QStringLiteral("País"),
+      QString::fromUtf8("Duración"),
+      QString::fromUtf8("Dirección"),
+      QStringLiteral("Reparto"),
+      QString::fromUtf8("Género"),
+      QStringLiteral("Guion"),
+      QStringLiteral("Historia:"),
+      QString::fromUtf8("Compañías"),
+      QStringLiteral("Distribuidora"),
+      QStringLiteral("Emitida por:"),
+      QString::fromUtf8("Música"),
+      QStringLiteral("Sinopsis")
+    },
+    {
+      QStringLiteral("us"),
+      QStringLiteral("(TV Series)"),
+      QStringLiteral("Year"),
+      QStringLiteral("Original title"),
+      QStringLiteral("Country"),
+      QStringLiteral("Running time"),
+      QStringLiteral("Director"),
+      QStringLiteral("Cast"),
+      QStringLiteral("Genre"),
+      QStringLiteral("Screenwriter"),
+      QStringLiteral("Story:"),
+      QStringLiteral("Producer"),
+      QStringLiteral("Distributor:"),
+      QStringLiteral("Broadcast by:"),
+      QStringLiteral("Music"),
+      QStringLiteral("Synopsis")
+    }
+  };
+
+  return dataVector[qBound(0, locale_, 
static_cast<int>(sizeof(dataVector)/sizeof(LocaleData)))];
+}
+
+QString FilmAffinityFetcher::source() const {
+  return m_name.isEmpty() ? defaultName() : m_name;
+}
+
+bool FilmAffinityFetcher::canFetch(int type) const {
+  return type == Data::Collection::Video;
+}
+
+bool FilmAffinityFetcher::canSearch(Fetch::FetchKey k) const {
+  return k == Title;
+}
+
+void FilmAffinityFetcher::readConfigHook(const KConfigGroup& config_) {
+  const int locale = config_.readEntry("Locale", int(ES));
+  m_locale = static_cast<Locale>(locale);
+  m_numCast = config_.readEntry("Max Cast", FILMAFFINITY_DEFAULT_CAST_SIZE);
+}
+
+void FilmAffinityFetcher::search() {
+  m_started = true;
+  m_matches.clear();
+
+  QUrl u(QString::fromLatin1(FILMAFFINITY_SEARCH_URL));
+  u.setPath(QLatin1String("/") + localeData(m_locale).siteSlug + 
QLatin1String("/advsearch.php"));
+  QString searchValue = request().value();
+  QUrlQuery q;
+  // extract the year from the end of the search string, accept the posible 
corner case of a movie
+  // having some other year in the title?
+  QRegularExpression yearRx(QStringLiteral("\\s(19|20)\\d\\d$"));
+  auto match = yearRx.match(searchValue);
+  if(match.hasMatch()) {
+    searchValue.remove(match.captured());
+    const auto& year = match.captured().simplified();
+    q.addQueryItem(QStringLiteral("fromyear"), year);
+    q.addQueryItem(QStringLiteral("toyear"), year);
+  }
+  q.addQueryItem(QStringLiteral("stext"), searchValue);
+
+  switch(request().key()) {
+    case Title:
+      //q.addQueryItem(QStringLiteral("year"), QStringLiteral("yes"));
+      q.addQueryItem(QStringLiteral("stype[]"), QLatin1String("title"));
+      break;
+
+    default:
+      myWarning() << "key not recognized: " << request().key();
+      stop();
+      return;
+  }
+  u.setQuery(q);
+//  myDebug() << "url: " << u.url();
+
+  m_job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
+  KJobWidgets::setWindow(m_job, GUI::Proxy::widget());
+  connect(m_job.data(), &KJob::result, this, 
&FilmAffinityFetcher::slotComplete);
+}
+
+void FilmAffinityFetcher::stop() {
+  if(!m_started) {
+    return;
+  }
+
+  if(m_job) {
+    m_job->kill();
+    m_job = nullptr;
+  }
+  m_started = false;
+  emit signalDone(this);
+}
+
+void FilmAffinityFetcher::slotComplete(KJob*) {
+  if(m_job->error()) {
+    m_job->uiDelegate()->showErrorMessage();
+    stop();
+    return;
+  }
+
+  QByteArray data = m_job->data();
+  if(data.isEmpty()) {
+    myDebug() << "no data";
+    stop();
+    return;
+  }
+
+  const QString output = Tellico::decodeHTML(data);
+#if 0
+  myWarning() << "Remove debug from filmaffinityfetcher.cpp";
+  QFile f(QStringLiteral("/tmp/test1.html"));
+  if(f.open(QIODevice::WriteOnly)) {
+    QTextStream t(&f);
+    t.setCodec("UTF-8");
+    t << output;
+  }
+  f.close();
+#endif
+
+  // look for a specific div, with an href and title, sometime uses 
single-quote, sometimes double-quotes
+  QRegularExpression resultRx(QStringLiteral("<div class=\"fa-shadow 
adv-search-item\">(.+?)<div class=\"mc-actions\">"),
+                              QRegularExpression::DotMatchesEverythingOption);
+  QRegularExpression 
titleRx(QStringLiteral("<a\\s+href=\"(.+?)\"\\s+title=\"(.+?)\">(.+?)<img"));
+  // the year is within the title text as a 4-digit number, starting with 1 or 
2
+  QRegularExpression yearRx(QStringLiteral("\\(([12]\\d\\d\\d)\\)"));
+
+  QString href, title, year;
+  QRegularExpressionMatchIterator i = resultRx.globalMatch(output);
+  while(i.hasNext() && m_started) {
+    auto topMatch = i.next();
+    auto anchorMatch = titleRx.match(topMatch.captured(1));
+    if(anchorMatch.hasMatch()) {
+      href = anchorMatch.captured(1);
+      title = anchorMatch.captured(2).trimmed();
+      auto yearMatch = yearRx.match(anchorMatch.captured(3));
+      if(yearMatch.hasMatch()) {
+        year = yearMatch.captured(1);
+      }
+    }
+    if(!href.isEmpty()) {
+      QUrl url(QString::fromLatin1(FILMAFFINITY_SEARCH_URL));
+      url = url.resolved(QUrl(href));
+//      myDebug() << url << title << year;
+      FetchResult* r = new FetchResult(this, title, year);
+      m_matches.insert(r->uid, url);
+      emit signalResultFound(r);
+    }
+  }
+
+  // since the fetch is done, don't worry about holding the job pointer
+  m_job = nullptr;
+  stop();
+}
+
+Tellico::Data::EntryPtr FilmAffinityFetcher::fetchEntryHook(uint uid_) {
+  // if we already grabbed this one, then just pull it out of the dict
+  Data::EntryPtr entry = m_entries[uid_];
+  if(entry) {
+    return entry;
+  }
+
+  QUrl url = m_matches[uid_];
+  if(url.isEmpty()) {
+    myWarning() << "no url in map";
+    return Data::EntryPtr();
+  }
+
+  const QString results = Tellico::decodeHTML(FileHandler::readDataFile(url, 
true));
+  if(results.isEmpty()) {
+    myDebug() << "no text results";
+    return Data::EntryPtr();
+  }
+
+#if 0
+  myDebug() << url.url();
+  myWarning() << "Remove debug2 from filmaffinityfetcher.cpp";
+  QFile f(QStringLiteral("/tmp/test-filmaffinity.html"));
+  if(f.open(QIODevice::WriteOnly)) {
+    QTextStream t(&f);
+    t.setCodec("UTF-8");
+    t << results;
+  }
+  f.close();
+#endif
+
+  entry = parseEntry(results);
+  if(!entry) {
+    myDebug() << "error in processing entry";
+    return Data::EntryPtr();
+  }
+
+  const QString fa = QStringLiteral("filmaffinity");
+  if(optionalFields().contains(fa)) {
+    Data::FieldPtr field(new Data::Field(fa, i18n("FilmAffinity Link"), 
Data::Field::URL));
+    field->setCategory(i18n("General"));
+    entry->collection()->addField(field);
+    entry->setField(fa, url.url());
+  }
+
+  m_entries.insert(uid_, entry); // keep for later
+  return entry;
+}
+
+Tellico::Data::EntryPtr FilmAffinityFetcher::parseEntry(const QString& str_) {
+  Data::CollPtr coll(new Data::VideoCollection(true));
+  Data::EntryPtr entry(new Data::Entry(coll));
+  coll->addEntries(entry);
+
+  const LocaleData& data = localeData(m_locale);
+
+  QRegularExpression titleRx(QStringLiteral("<span 
itemprop=\"name\">(.+?)</span"));
+  QRegularExpressionMatch match = titleRx.match(str_);
+  if(match.hasMatch()) {
+    // remove anything in parentheses
+    QString title = match.captured(1).simplified();
+    title.remove(data.tvSeries);
+    title = title.trimmed();
+    entry->setField(QStringLiteral("title"), title);
+  }
+
+  const QString origtitle = QStringLiteral("origtitle");
+  QRegularExpression tagRx(QStringLiteral("<.+?>"));
+  QRegularExpression spanRx(QStringLiteral("<span.*?>(.+?)</span"));
+  QRegularExpression 
defRx(QStringLiteral("<dt>(.+?)</dt>\\s*?<dd.*?>(.+?)</dd>"),
+                           QRegularExpression::DotMatchesEverythingOption);
+  QRegularExpressionMatchIterator i = defRx.globalMatch(str_);
+  while(i.hasNext()) {
+    auto match = i.next();
+    const auto& term = match.captured(1);
+    if(term == data.year) {
+      entry->setField(QStringLiteral("year"), match.captured(2).trimmed());
+    } else if(term == data.origTitle &&
+              optionalFields().contains(origtitle)) {
+      Data::FieldPtr f(new Data::Field(origtitle, i18n("Original Title")));
+      f->setFormatType(FieldFormat::FormatTitle);
+      coll->addField(f);
+      // might have an aka in a span
+      QString oTitle = match.captured(2);
+      const int start = oTitle.indexOf(QLatin1String("<span"));
+      if(start > -1) oTitle = oTitle.left(start);
+      entry->setField(origtitle, oTitle.remove(tagRx).simplified());
+    } else if(term == data.runningTime) {
+      QRegularExpression timeRx(QStringLiteral("\\d+"));
+      auto timeMatch = timeRx.match(match.captured(2));
+      if(timeMatch.hasMatch()) {
+        entry->setField(QStringLiteral("running-time"), timeMatch.captured());
+      }
+    } else if(term == data.country) {
+      QRegularExpression countryRx(QStringLiteral("alt=\"(.+?)\""));
+      auto countryMatch = countryRx.match(match.captured(2));
+      if(countryMatch.hasMatch()) {
+        entry->setField(QStringLiteral("nationality"), 
countryMatch.captured(1));
+      }
+    } else if(term == data.director) {
+      QStringList directors;
+      auto iSpan = spanRx.globalMatch(match.captured(2));
+      while(iSpan.hasNext()) {
+        auto spanMatch = iSpan.next();
+        directors += spanMatch.captured(1).remove(tagRx).simplified();
+      }
+      if(!directors.isEmpty()) {
+        entry->setField(QStringLiteral("director"), 
directors.join(FieldFormat::delimiterString()));
+      }
+    } else if(term == data.cast) {
+      QStringList cast;
+      const auto& captured = match.captured(2);
+      // only read up to thie hidden credits
+      const auto end = captured.indexOf(QLatin1String("hidden-credit"));
+      auto iSpan = spanRx.globalMatch(captured.left(end));
+      while(iSpan.hasNext() && cast.size() < m_numCast) {
+        auto spanMatch = iSpan.next();
+        cast += spanMatch.captured(1).remove(tagRx).simplified();
+      }
+      if(!cast.isEmpty()) {
+        entry->setField(QStringLiteral("cast"), 
cast.join(FieldFormat::rowDelimiterString()));
+      }
+    } else if(term == data.genre) {
+      QStringList genres;
+      auto iSpan = spanRx.globalMatch(match.captured(2));
+      while(iSpan.hasNext()) {
+        auto spanMatch = iSpan.next();
+        genres += spanMatch.captured(1).remove(tagRx).simplified();
+      }
+      if(!genres.isEmpty()) {
+        entry->setField(QStringLiteral("genre"), 
genres.join(FieldFormat::delimiterString()));
+      }
+    } else if(term == data.writer) {
+      QStringList writers;
+      const auto& captured = match.captured(2);
+      // skip ahead to "Story"
+      const auto start = captured.indexOf(data.story);
+      auto iSpan = spanRx.globalMatch(captured.mid(qMax(0,start)));
+      while(iSpan.hasNext()) {
+        auto spanMatch = iSpan.next();
+        writers += spanMatch.captured(1).remove(tagRx).simplified();
+      }
+      if(!writers.isEmpty()) {
+        entry->setField(QStringLiteral("writer"), 
writers.join(FieldFormat::delimiterString()));
+      }
+    } else if(term == data.producer) {
+      // producer seems to be all the studio, use distributor as the main
+      QStringList studios;
+      const auto& captured = match.captured(2);
+      // skip ahead to "Story"
+      const auto start1 = captured.indexOf(data.distributor);
+      const auto start2 = captured.indexOf(data.broadcast);
+      auto iSpan = 
spanRx.globalMatch(captured.mid(qMax(0,qMax(start1,start2))));
+      while(iSpan.hasNext()) {
+        auto spanMatch = iSpan.next();
+        studios += spanMatch.captured(1).remove(tagRx).simplified();
+      }
+      if(!studios.isEmpty()) {
+        entry->setField(QStringLiteral("studio"), 
studios.join(FieldFormat::delimiterString()));
+      }
+    } else if(term == data.music) {
+      entry->setField(QStringLiteral("composer"), 
match.captured(2).remove(tagRx).trimmed());
+    } else if(term == data.plot) {
+      entry->setField(QStringLiteral("plot"), match.captured(2).trimmed());
+    }
+  }
+
+  QString cover;
+  QRegularExpression 
coverRx(QStringLiteral("<img\\s.*?itemprop=\"image\".+?src=\"(.+?)\".*?>"));
+  match = coverRx.match(str_);
+  if(match.hasMatch()) {
+    cover = match.captured(1);
+  } else {
+    coverRx.setPattern(QStringLiteral("<meta property=\"og:image\" 
content=\"(.+?)\""));
+    match = coverRx.match(str_);
+    if(match.hasMatch()) {
+      cover = match.captured(1);
+    }
+  }
+  if(!cover.isEmpty()) {
+//    myDebug() << "cover:" << cover;
+    const QString id = ImageFactory::addImage(QUrl::fromUserInput(cover), true 
/* quiet */);
+    if(id.isEmpty()) {
+      message(i18n("The cover image could not be loaded."), 
MessageHandler::Warning);
+    }
+    // empty image ID is ok
+    entry->setField(QStringLiteral("cover"), id);
+  }
+
+  return entry;
+}
+
+Tellico::Fetch::FetchRequest FilmAffinityFetcher::updateRequest(Data::EntryPtr 
entry_) {
+  QString t = entry_->field(QStringLiteral("title"));
+  if(!t.isEmpty()) {
+    return FetchRequest(Fetch::Title, t);
+  }
+  return FetchRequest();
+}
+
+Tellico::Fetch::ConfigWidget* FilmAffinityFetcher::configWidget(QWidget* 
parent_) const {
+  return new FilmAffinityFetcher::ConfigWidget(parent_);
+}
+
+QString FilmAffinityFetcher::defaultName() {
+  return QStringLiteral("FilmAffinity");
+}
+
+QString FilmAffinityFetcher::defaultIcon() {
+  return favIcon("https://www.filmaffinity.com";);
+}
+
+Tellico::StringHash FilmAffinityFetcher::allOptionalFields() {
+  StringHash hash;
+  hash[QStringLiteral("origtitle")] = i18n("Original Title");
+  hash[QStringLiteral("filmaffinity")] = i18n("FilmAffinity Link");
+  return hash;
+}
+
+FilmAffinityFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const 
FilmAffinityFetcher* fetcher_)
+    : Fetch::ConfigWidget(parent_) {
+  QGridLayout* l = new QGridLayout(optionsWidget());
+  l->setSpacing(4);
+  l->setColumnStretch(1, 10);
+
+  int row = -1;
+
+  QLabel* label = new QLabel(i18n("&Maximum cast: "), optionsWidget());
+  l->addWidget(label, ++row, 0);
+  m_numCast = new QSpinBox(optionsWidget());
+  m_numCast->setMaximum(99);
+  m_numCast->setMinimum(0);
+  m_numCast->setValue(FILMAFFINITY_DEFAULT_CAST_SIZE);
+#if (QT_VERSION < QT_VERSION_CHECK(5, 14, 0))
+  void (QSpinBox::* textChanged)(const QString&) = &QSpinBox::valueChanged;
+#else
+  void (QSpinBox::* textChanged)(const QString&) = &QSpinBox::textChanged;
+#endif
+  connect(m_numCast, textChanged, this, &ConfigWidget::slotSetModified);
+  l->addWidget(m_numCast, row, 1);
+  QString w = i18n("The list of cast members may include many people. Set the 
maximum number returned from the search.");
+  label->setWhatsThis(w);
+  m_numCast->setWhatsThis(w);
+  label->setBuddy(m_numCast);
+
+  label = new QLabel(i18n("Language: "), optionsWidget());
+  l->addWidget(label, ++row, 0);
+  m_localeCombo = new GUI::ComboBox(optionsWidget());
+  QIcon iconES(QStandardPaths::locate(QStandardPaths::GenericDataLocation,
+                                      
QStringLiteral("kf5/locale/countries/es/flag.png")));
+  m_localeCombo->addItem(iconES, i18nc("Country", "Spain"), 
int(FilmAffinityFetcher::ES));
+  QIcon iconUS(QStandardPaths::locate(QStandardPaths::GenericDataLocation,
+                                      
QStringLiteral("kf5/locale/countries/us/flag.png")));
+  m_localeCombo->addItem(iconUS, i18nc("Country", "USA"), 
int(FilmAffinityFetcher::US));
+  void (GUI::ComboBox::* activatedInt)(int) = &GUI::ComboBox::activated;
+  connect(m_localeCombo, activatedInt, this, &ConfigWidget::slotSetModified);
+  l->addWidget(m_localeCombo, row, 1);
+  label->setBuddy(m_localeCombo);
+
+  l->setRowStretch(++row, 10);
+
+  addFieldsWidget(FilmAffinityFetcher::allOptionalFields(), fetcher_ ? 
fetcher_->optionalFields() : QStringList());
+
+  if(fetcher_) {
+    m_localeCombo->setCurrentData(fetcher_->m_locale);
+    m_numCast->setValue(fetcher_->m_numCast);
+  }
+}
+
+void FilmAffinityFetcher::ConfigWidget::saveConfigHook(KConfigGroup& config_) {
+  config_.writeEntry("Locale", m_localeCombo->currentData().toInt());
+  config_.writeEntry("Max Cast", m_numCast->value());
+}
+
+QString FilmAffinityFetcher::ConfigWidget::preferredName() const {
+  return FilmAffinityFetcher::defaultName();
+}
diff --git a/src/fetch/filmaffinityfetcher.h b/src/fetch/filmaffinityfetcher.h
new file mode 100644
index 00000000..d0699912
--- /dev/null
+++ b/src/fetch/filmaffinityfetcher.h
@@ -0,0 +1,131 @@
+/***************************************************************************
+    Copyright (C) 2023 Robby Stephenson <ro...@periapsis.org>
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or         *
+ *   modify it under the terms of the GNU General Public License as        *
+ *   published by the Free Software Foundation; either version 2 of        *
+ *   the License or (at your option) version 3 or any later version        *
+ *   accepted by the membership of KDE e.V. (or its successor approved     *
+ *   by the membership of KDE e.V.), which shall act as a proxy            *
+ *   defined in Section 14 of version 3 of the license.                    *
+ *                                                                         *
+ *   This program is distributed in the hope that it will be useful,       *
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
+ *   GNU General Public License for more details.                          *
+ *                                                                         *
+ *   You should have received a copy of the GNU General Public License     *
+ *   along with this program.  If not, see <http://www.gnu.org/licenses/>. *
+ *                                                                         *
+ ***************************************************************************/
+
+#ifndef TELLICO_FETCH_FILMAFFINITYFETCHER_H
+#define TELLICO_FETCH_FILMAFFINITYFETCHER_H
+
+#include "fetcher.h"
+#include "configwidget.h"
+
+#include <QPointer>
+
+class QSpinBox;
+class QUrl;
+class KJob;
+namespace KIO {
+  class StoredTransferJob;
+}
+
+namespace Tellico {
+
+  namespace GUI {
+    class ComboBox;
+  }
+
+  namespace Fetch {
+
+/**
+ * A fetcher for kino-teatr.ua
+ *
+ * @author Robby Stephenson
+ */
+class FilmAffinityFetcher : public Fetcher {
+Q_OBJECT
+
+public:
+  enum Locale {
+    ES = 0,
+    US = 1
+  };
+
+  FilmAffinityFetcher(QObject* parent);
+  virtual ~FilmAffinityFetcher();
+
+  virtual QString source() const Q_DECL_OVERRIDE;
+  virtual bool isSearching() const Q_DECL_OVERRIDE { return m_started; }
+  virtual bool canSearch(FetchKey k) const Q_DECL_OVERRIDE;
+  virtual void stop() Q_DECL_OVERRIDE;
+  virtual Data::EntryPtr fetchEntryHook(uint uid) Q_DECL_OVERRIDE;
+  virtual Type type() const Q_DECL_OVERRIDE { return FilmAffinity; }
+  virtual bool canFetch(int type) const Q_DECL_OVERRIDE;
+  virtual void readConfigHook(const KConfigGroup& config) Q_DECL_OVERRIDE;
+
+  struct LocaleData {
+    QString siteSlug;
+    QString tvSeries;
+    QString year;
+    QString origTitle;
+    QString country;
+    QString runningTime;
+    QString director;
+    QString cast;
+    QString genre;
+    QString writer;
+    QString story;
+    QString producer;
+    QString distributor;
+    QString broadcast;
+    QString music;
+    QString plot;
+  };
+  static const LocaleData& localeData(int locale);
+
+  virtual Fetch::ConfigWidget* configWidget(QWidget* parent) const 
Q_DECL_OVERRIDE;
+
+  class ConfigWidget : public Fetch::ConfigWidget {
+  public:
+    explicit ConfigWidget(QWidget* parent_, const FilmAffinityFetcher* fetcher 
= nullptr);
+    virtual void saveConfigHook(KConfigGroup&) Q_DECL_OVERRIDE;
+    virtual QString preferredName() const Q_DECL_OVERRIDE;
+
+  private:
+    GUI::ComboBox* m_localeCombo;
+    QSpinBox* m_numCast;
+  };
+  friend class ConfigWidget;
+
+  static QString defaultName();
+  static QString defaultIcon();
+  static StringHash allOptionalFields();
+
+private Q_SLOTS:
+  void slotComplete(KJob* job);
+
+private:
+  virtual void search() Q_DECL_OVERRIDE;
+  virtual FetchRequest updateRequest(Data::EntryPtr entry) Q_DECL_OVERRIDE;
+  Data::EntryPtr parseEntry(const QString& str);
+
+  QHash<uint, Data::EntryPtr> m_entries;
+  QHash<uint, QUrl> m_matches;
+  QPointer<KIO::StoredTransferJob> m_job;
+
+  bool m_started;
+  Locale m_locale;
+  int m_numCast;
+};
+
+  } // end namespace
+} // end namespace
+#endif
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
index fdcbd4f9..15542b9c 100644
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -623,6 +623,12 @@ ecm_add_test(externalfetchertest.cpp
                    KF5::XmlGui
 )
 
+ecm_add_test(filmaffinityfetchertest.cpp
+             ../fetch/filmaffinityfetcher.cpp
+    TEST_NAME filmaffinityfetchertest
+    LINK_LIBRARIES fetcherstest ${TELLICO_TEST_LIBS}
+)
+
 ecm_add_test(filmasterfetchertest.cpp
              ../fetch/filmasterfetcher.cpp
     TEST_NAME filmasterfetchertest
diff --git a/src/tests/filmaffinityfetchertest.cpp 
b/src/tests/filmaffinityfetchertest.cpp
new file mode 100644
index 00000000..b610002d
--- /dev/null
+++ b/src/tests/filmaffinityfetchertest.cpp
@@ -0,0 +1,173 @@
+/***************************************************************************
+    Copyright (C) 2023 Robby Stephenson <ro...@periapsis.org>
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or         *
+ *   modify it under the terms of the GNU General Public License as        *
+ *   published by the Free Software Foundation; either version 2 of        *
+ *   the License or (at your option) version 3 or any later version        *
+ *   accepted by the membership of KDE e.V. (or its successor approved     *
+ *   by the membership of KDE e.V.), which shall act as a proxy            *
+ *   defined in Section 14 of version 3 of the license.                    *
+ *                                                                         *
+ *   This program is distributed in the hope that it will be useful,       *
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
+ *   GNU General Public License for more details.                          *
+ *                                                                         *
+ *   You should have received a copy of the GNU General Public License     *
+ *   along with this program.  If not, see <http://www.gnu.org/licenses/>. *
+ *                                                                         *
+ ***************************************************************************/
+
+#undef QT_NO_CAST_FROM_ASCII
+
+#include "filmaffinityfetchertest.h"
+
+#include "../fetch/filmaffinityfetcher.h"
+#include "../entry.h"
+#include "../collections/videocollection.h"
+#include "../collectionfactory.h"
+#include "../images/imagefactory.h"
+#include "../fieldformat.h"
+#include "../fetch/fetcherjob.h"
+
+#include <KSharedConfig>
+
+#include <QTest>
+
+QTEST_GUILESS_MAIN( FilmAffinityFetcherTest )
+
+FilmAffinityFetcherTest::FilmAffinityFetcherTest() : AbstractFetcherTest() {
+}
+
+void FilmAffinityFetcherTest::initTestCase() {
+  Tellico::ImageFactory::init();
+  Tellico::RegisterCollection<Tellico::Data::VideoCollection> 
registerVideo(Tellico::Data::Collection::Video, "video");
+
+  m_config = KSharedConfig::openConfig(QString(), 
KConfig::SimpleConfig)->group(QStringLiteral("filmaffinity"));
+  m_config.writeEntry("Custom Fields", 
QStringLiteral("origtitle,filmaffinity"));
+}
+
+void FilmAffinityFetcherTest::testSuperman() {
+  Tellico::Fetch::FetchRequest request(Tellico::Data::Collection::Video, 
Tellico::Fetch::Title, QStringLiteral("Superman Returns"));
+  Tellico::Fetch::Fetcher::Ptr fetcher(new 
Tellico::Fetch::FilmAffinityFetcher(this));
+  m_config.writeEntry("Locale", int(Tellico::Fetch::FilmAffinityFetcher::US));
+  fetcher->readConfig(m_config);
+
+  Tellico::Data::EntryList results = DO_FETCH1(fetcher, request, 1);
+
+  QCOMPARE(results.size(), 1);
+
+  // the first entry had better be the right one
+  Tellico::Data::EntryPtr entry = results.at(0);
+
+  QCOMPARE(entry->field("title"), QStringLiteral("Superman Returns"));
+  QCOMPARE(entry->field("year"), QStringLiteral("2006"));
+  QCOMPARE(entry->field("nationality"), QStringLiteral("United States"));
+  QCOMPARE(entry->field("director"), QStringLiteral("Bryan Singer"));
+  QCOMPARE(entry->field("writer"), QStringLiteral("Bryan Singer; Michael 
Dougherty; Dan Harris"));
+  QCOMPARE(entry->field("composer"), QStringLiteral("John Ottman"));
+  QCOMPARE(entry->field("studio"), QStringLiteral("Warner Bros."));
+  QCOMPARE(set(entry, "genre"), set(QStringLiteral("Sci-Fi; Fantasy; Action; 
Romance")));
+  QCOMPARE(entry->field("running-time"), QStringLiteral("153"));
+  QStringList castList = 
Tellico::FieldFormat::splitTable(entry->field(QStringLiteral("cast")));
+  QVERIFY(castList.count() > 2);
+  QCOMPARE(castList.at(0), QStringLiteral("Brandon Routh"));
+  QCOMPARE(castList.at(1), QStringLiteral("Kevin Spacey"));
+  QVERIFY(entry->field("plot").startsWith(QStringLiteral("Following a 
mysterious absence")));
+  QVERIFY(!entry->field("cover").isEmpty());
+  QVERIFY(!entry->field(QStringLiteral("cover")).contains(QLatin1Char('/')));
+  QCOMPARE(entry->field("filmaffinity"), 
QStringLiteral("https://www.filmaffinity.com/us/film300630.html";));
+}
+
+
+void FilmAffinityFetcherTest::testSupermanES() {
+  Tellico::Fetch::FetchRequest request(Tellico::Data::Collection::Video, 
Tellico::Fetch::Title, QStringLiteral("Superman Returns"));
+  Tellico::Fetch::Fetcher::Ptr fetcher(new 
Tellico::Fetch::FilmAffinityFetcher(this));
+  m_config.writeEntry("Locale", int(Tellico::Fetch::FilmAffinityFetcher::ES));
+  fetcher->readConfig(m_config);
+
+  Tellico::Data::EntryList results = DO_FETCH1(fetcher, request, 1);
+
+  QCOMPARE(results.size(), 1);
+
+  // the first entry had better be the right one
+  Tellico::Data::EntryPtr entry = results.at(0);
+
+  QCOMPARE(entry->field("title"), QStringLiteral("Superman Returns: El 
regreso"));
+  QCOMPARE(entry->field("origtitle"), QStringLiteral("Superman Returns"));
+  QCOMPARE(entry->field("year"), QStringLiteral("2006"));
+  QCOMPARE(entry->field("nationality"), QStringLiteral("Estados Unidos"));
+  QCOMPARE(entry->field("director"), QStringLiteral("Bryan Singer"));
+  QCOMPARE(entry->field("writer"), QStringLiteral("Bryan Singer; Michael 
Dougherty; Dan Harris"));
+  QCOMPARE(entry->field("composer"), QStringLiteral("John Ottman"));
+  QCOMPARE(entry->field("studio"), QStringLiteral("Warner Bros."));
+  QCOMPARE(set(entry, "genre"), set(QString::fromUtf8("Ciencia ficción; 
Fantástico; Acción; Romance")));
+  QCOMPARE(entry->field("running-time"), QStringLiteral("153"));
+  QStringList castList = 
Tellico::FieldFormat::splitTable(entry->field(QStringLiteral("cast")));
+  QVERIFY(castList.count() > 2);
+  QCOMPARE(castList.at(0), QStringLiteral("Brandon Routh"));
+  QCOMPARE(castList.at(1), QStringLiteral("Kevin Spacey"));
+  QVERIFY(entry->field("plot").startsWith(QString::fromUtf8("Tras varios 
años")));
+  QVERIFY(!entry->field("cover").isEmpty());
+  QVERIFY(!entry->field(QStringLiteral("cover")).contains(QLatin1Char('/')));
+  QCOMPARE(entry->field("filmaffinity"), 
QStringLiteral("https://www.filmaffinity.com/es/film300630.html";));
+}
+
+void FilmAffinityFetcherTest::testFirefly() {
+  Tellico::Fetch::FetchRequest request(Tellico::Data::Collection::Video, 
Tellico::Fetch::Title, QStringLiteral("Firefly 2002"));
+  Tellico::Fetch::Fetcher::Ptr fetcher(new 
Tellico::Fetch::FilmAffinityFetcher(this));
+  m_config.writeEntry("Locale", int(Tellico::Fetch::FilmAffinityFetcher::US));
+  fetcher->readConfig(m_config);
+
+  Tellico::Data::EntryList results = DO_FETCH1(fetcher, request, 1);
+
+  QCOMPARE(results.size(), 1);
+
+  // the first entry had better be the right one
+  Tellico::Data::EntryPtr entry = results.at(0);
+
+  QCOMPARE(entry->field("title"), QStringLiteral("Firefly"));
+  QCOMPARE(entry->field("year"), QStringLiteral("2002"));
+  QCOMPARE(entry->field("nationality"), QStringLiteral("United States"));
+  QCOMPARE(entry->field("studio"), QStringLiteral("FOX"));
+  QVERIFY(!entry->field("plot").isEmpty());
+  QVERIFY(!entry->field("cover").isEmpty());
+  QVERIFY(!entry->field(QStringLiteral("cover")).contains(QLatin1Char('/')));
+  QCOMPARE(entry->field("filmaffinity"), 
QStringLiteral("https://www.filmaffinity.com/us/film929343.html";));
+}
+
+void FilmAffinityFetcherTest::testAlcarras() {
+  Tellico::Fetch::FetchRequest request(Tellico::Data::Collection::Video, 
Tellico::Fetch::Title, QString::fromUtf8("Alcarràs"));
+  Tellico::Fetch::Fetcher::Ptr fetcher(new 
Tellico::Fetch::FilmAffinityFetcher(this));
+  m_config.writeEntry("Locale", int(Tellico::Fetch::FilmAffinityFetcher::ES));
+  fetcher->readConfig(m_config);
+
+  Tellico::Data::EntryList results = DO_FETCH1(fetcher, request, 1);
+
+  QCOMPARE(results.size(), 1);
+
+  // the first entry had better be the right one
+  Tellico::Data::EntryPtr entry = results.at(0);
+
+  QCOMPARE(entry->field("title"), QString::fromUtf8("Alcarràs"));
+  QCOMPARE(entry->field("year"), QStringLiteral("2022"));
+  QCOMPARE(entry->field("nationality"), QString::fromUtf8("España"));
+  QCOMPARE(entry->field("director"), QString::fromUtf8("Carla Simón"));
+  QCOMPARE(entry->field("writer"), QString::fromUtf8("Carla Simón; Arnau 
Vilaró"));
+  QCOMPARE(entry->field("composer"), QStringLiteral("Andrea Koch"));
+  QCOMPARE(entry->field("studio"), QString::fromUtf8("Avalon P.C; Elastica 
Films; Vilaüt Films; Kino Produzioni; Movistar Plus+; RTVE; TV3"));
+  QCOMPARE(set(entry, "genre"), set(QStringLiteral("Drama")));
+  QCOMPARE(entry->field("running-time"), QStringLiteral("120"));
+  QStringList castList = 
Tellico::FieldFormat::splitTable(entry->field(QStringLiteral("cast")));
+  QVERIFY(castList.count() > 2);
+  QCOMPARE(castList.at(0), QStringLiteral("Jordi Pujol Dolcet"));
+  QCOMPARE(castList.at(1), QStringLiteral("Anna Otín"));
+  QVERIFY(entry->field("plot").startsWith(QStringLiteral("Durante 
generaciones")));
+  QVERIFY(!entry->field("cover").isEmpty());
+  QVERIFY(!entry->field(QStringLiteral("cover")).contains(QLatin1Char('/')));
+  QCOMPARE(entry->field("filmaffinity"), 
QStringLiteral("https://www.filmaffinity.com/es/film457848.html";));
+}
diff --git a/src/fetch/fetch.h b/src/tests/filmaffinityfetchertest.h
similarity index 55%
copy from src/fetch/fetch.h
copy to src/tests/filmaffinityfetchertest.h
index 4cbbd4bd..2a6a4036 100644
--- a/src/fetch/fetch.h
+++ b/src/tests/filmaffinityfetchertest.h
@@ -1,5 +1,5 @@
 /***************************************************************************
-    Copyright (C) 2003-2009 Robby Stephenson <ro...@periapsis.org>
+    Copyright (C) 2023 Robby Stephenson <ro...@periapsis.org>
  ***************************************************************************/
 
 /***************************************************************************
@@ -22,98 +22,27 @@
  *                                                                         *
  ***************************************************************************/
 
-#ifndef TELLICO_FETCH_H
-#define TELLICO_FETCH_H
+#ifndef FILMAFFINITYFETCHERTEST_H
+#define FILMAFFINITYFETCHERTEST_H
 
-namespace Tellico {
-  namespace Fetch {
+#include "abstractfetchertest.h"
 
-/**
- * FetchFirst must be first, and the rest must follow consecutively in value.
- * FetchLast must be last!
- */
-enum FetchKey {
-  FetchFirst = 0,
-  Title,
-  Person,
-  ISBN,
-  UPC,
-  Keyword,
-  DOI,
-  ArxivID,
-  PubmedID,
-  LCCN,
-  Raw,
-  ExecUpdate,
-  FetchLast
-};
+#include <KConfigGroup>
 
-// real ones must start at 0!
-enum Type {
-  Unknown = -1,
-  Amazon = 0,
-  IMDB,
-  Z3950,
-  SRU,
-  Entrez,
-  ExecExternal,
-  Yahoo, // Removed
-  AnimeNfo, // Removed
-  IBS,
-  ISBNdb,
-  GCstarPlugin,
-  CrossRef,
-  Citebase, // Removed
-  Arxiv,
-  Bibsonomy,
-  GoogleScholar,
-  Discogs,
-  WineCom,
-  TheMovieDB,
-  MusicBrainz,
-  GiantBomb,
-  OpenLibrary,
-  Multiple,
-  Freebase, // Removed
-  DVDFr,
-  Filmaster,
-  Douban,
-  BiblioShare,
-  MovieMeter,
-  GoogleBook,
-  MAS, // Removed
-  Springer,
-  Allocine,
-  ScreenRush, // Removed
-  FilmStarts, // Removed
-  SensaCine, // Removed
-  Beyazperde, // Removed
-  HathiTrust,
-  TheGamesDB,
-  DBLP,
-  VNDB,
-  MRLookup,
-  BoardGameGeek,
-  Bedetheque,
-  OMDB,
-  KinoPoisk,
-  VideoGameGeek,
-  DBC,
-  IGDB,
-  Kino,
-  MobyGames,
-  ComicVine,
-  KinoTeatr,
-  Colnect,
-  Numista,
-  TVmaze,
-  UPCItemDb,
-  TheTVDB,
-  RPGGeek,
-  GamingHistory
-};
+class FilmAffinityFetcherTest : public AbstractFetcherTest {
+Q_OBJECT
+public:
+  FilmAffinityFetcherTest();
 
-  }
-}
+private Q_SLOTS:
+  void initTestCase();
+  void testSuperman();
+  void testSupermanES();
+  void testFirefly();
+  void testAlcarras();
+
+private:
+  KConfigGroup m_config;
+};
 
 #endif

Reply via email to