changeset 320:1b5c04268d6a

services/kitsu: ACTUALLY finish GetAnimeList there are some things the API just... doesn't provide. therefore we have to request the genres separately any time a new anime info box is opened...
author Paper <paper@paper.us.eu.org>
date Wed, 12 Jun 2024 19:49:19 -0400
parents d928ec7b6a0d
children 8141f409d52c
files include/core/anime.h include/core/strings.h include/gui/pages/anime_list.h include/gui/widgets/anime_info.h include/services/kitsu.h include/services/services.h src/core/anime_db.cc src/core/config.cc src/core/strings.cc src/gui/pages/anime_list.cc src/gui/widgets/anime_info.cc src/services/anilist.cc src/services/kitsu.cc src/services/services.cc
diffstat 14 files changed, 318 insertions(+), 105 deletions(-) [+]
line wrap: on
line diff
--- a/include/core/anime.h	Wed Jun 12 17:52:26 2024 -0400
+++ b/include/core/anime.h	Wed Jun 12 19:49:19 2024 -0400
@@ -161,13 +161,13 @@
 	void SetUserStatus(ListStatus status);
 	void SetUserScore(int score);
 	void SetUserProgress(int progress);
-	void SetUserDateStarted(Date const& started);
-	void SetUserDateCompleted(Date const& completed);
+	void SetUserDateStarted(const Date& started);
+	void SetUserDateCompleted(const Date& completed);
 	void SetUserIsPrivate(bool is_private);
 	void SetUserRewatchedTimes(int rewatched);
 	void SetUserIsRewatching(bool rewatching);
 	void SetUserTimeUpdated(uint64_t updated);
-	void SetUserNotes(std::string const& notes);
+	void SetUserNotes(const std::string& notes);
 
 	/* Series data */
 	int GetId() const;
@@ -190,13 +190,13 @@
 	void SetId(int id);
 	void SetServiceId(Service service, const std::string& id);
 	void SetTitle(TitleLanguage language, const std::string& title);
-	void SetTitleSynonyms(std::vector<std::string> const& synonyms);
-	void AddTitleSynonym(std::string const& synonym);
+	void SetTitleSynonyms(const std::vector<std::string>& synonyms);
+	void AddTitleSynonym(const std::string& synonym);
 	void SetEpisodes(int episodes);
 	void SetAiringStatus(SeriesStatus status);
-	void SetAirDate(Date const& date);
-	void SetGenres(std::vector<std::string> const& genres);
-	void SetProducers(std::vector<std::string> const& producers);
+	void SetAirDate(const Date& date);
+	void SetGenres(const std::vector<std::string>& genres);
+	void SetProducers(const std::vector<std::string>& producers);
 	void SetFormat(SeriesFormat format);
 	void SetAudienceScore(double audience_score);
 	void SetSynopsis(std::string synopsis);
--- a/include/core/strings.h	Wed Jun 12 17:52:26 2024 -0400
+++ b/include/core/strings.h	Wed Jun 12 19:49:19 2024 -0400
@@ -17,7 +17,6 @@
  * into a string, separated by delimiters.
  */
 std::string Implode(const std::vector<std::string>& vector, const std::string& delimiter);
-std::string Implode(const std::set<std::string>& set, const std::string& delimiter);
 std::vector<std::string> Split(const std::string& text, const std::string& delimiter);
 
 /* Substring removal functions */
@@ -48,7 +47,7 @@
 QString ToQString(const std::wstring& wstring);
 
 /* not really an "int"... but who cares? */
-template<typename T = int, std::enable_if_t<std::is_integral<T>::value, bool> = true>
+template<typename T = int, std::enable_if_t<std::is_arithmetic<T>::value, bool> = true>
 T ToInt(const std::string& str, T def = 0) {
 	std::istringstream s(str);
 	s >> std::noboolalpha >> def;
--- a/include/gui/pages/anime_list.h	Wed Jun 12 17:52:26 2024 -0400
+++ b/include/gui/pages/anime_list.h	Wed Jun 12 19:49:19 2024 -0400
@@ -18,7 +18,7 @@
 	Q_OBJECT
 
 public:
-	AnimeListPageUpdateEntryThread(AnimeListPage* parent);
+	AnimeListPageUpdateEntryThread(QObject* parent = nullptr);
 
 	void AddToQueue(int id);
 
@@ -29,8 +29,7 @@
 	void run() override;
 
 private:
-	AnimeListPage* page_ = nullptr;
-	std::mutex _queue_mutex;
+	std::mutex queue_mutex_;
 	std::queue<int> queue_;
 };
 
--- a/include/gui/widgets/anime_info.h	Wed Jun 12 17:52:26 2024 -0400
+++ b/include/gui/widgets/anime_info.h	Wed Jun 12 19:49:19 2024 -0400
@@ -2,12 +2,35 @@
 #define MINORI_GUI_WIDGETS_ANIME_INFO_H_
 
 #include <QWidget>
+#include <QThread>
 #include "gui/widgets/text.h"
 
+#include <mutex>
+#include <queue>
+
 namespace Anime {
 class Anime;
 }
 
+class AnimeInfoWidgetGetMetadataThread final : public QThread {
+	Q_OBJECT
+
+public:
+	AnimeInfoWidgetGetMetadataThread(QObject* parent = nullptr);
+
+	void AddToQueue(int id);
+
+signals:
+	void NeedRefresh(int id);
+
+protected:
+	void run() override;
+
+private:
+	std::mutex queue_mutex_;
+	std::queue<int> queue_;
+};
+
 class AnimeInfoWidget final : public QWidget {
 	Q_OBJECT
 
@@ -16,10 +39,15 @@
 	AnimeInfoWidget(const Anime::Anime& anime, QWidget* parent = nullptr);
 	void SetAnime(const Anime::Anime& anime);
 
+protected:
+	void RefreshGenres(const Anime::Anime& anime);
+
 private:
 	TextWidgets::OneLineSection _title;
 	TextWidgets::LabelledSection _details;
 	TextWidgets::SelectableSection _synopsis;
+
+	int id_ = 0;
 };
 
 #endif // MINORI_GUI_WIDGETS_ANIME_INFO_H_
--- a/include/services/kitsu.h	Wed Jun 12 17:52:26 2024 -0400
+++ b/include/services/kitsu.h	Wed Jun 12 19:49:19 2024 -0400
@@ -13,6 +13,8 @@
 /* neither of these are stored in the config and only held temporarily */
 bool AuthorizeUser(const std::string& email, const std::string& password);
 
+bool RetrieveAnimeMetadata(int id);
+
 int GetAnimeList();
 std::vector<int> Search(const std::string& search);
 std::vector<int> GetSeason(Anime::SeriesSeason season, Date::Year year);
--- a/include/services/services.h	Wed Jun 12 17:52:26 2024 -0400
+++ b/include/services/services.h	Wed Jun 12 19:49:19 2024 -0400
@@ -9,9 +9,13 @@
 
 namespace Services {
 
-/* TODO: need to limit these to one thread (or put a mutex on the anime db) */
+void Synchronize();
 
-void Synchronize();
+/* true = metadata was retrieved
+ * false = metadata failed to be retrieved OR
+ *         no metadata to be retrieved */
+bool RetrieveAnimeMetadata(int id);
+
 std::vector<int> Search(const std::string& search);
 std::vector<int> GetSeason(Anime::SeriesSeason season, Date::Year year);
 void UpdateAnimeEntry(int id);
--- a/src/core/anime_db.cc	Wed Jun 12 17:52:26 2024 -0400
+++ b/src/core/anime_db.cc	Wed Jun 12 19:49:19 2024 -0400
@@ -243,6 +243,13 @@
 	Anime& anime = database.items[id];
 
 	anime.SetId(id);
+	for (const auto& service : Services) {
+		nlohmann::json::json_pointer p("/ids/" + Strings::ToLower(Translate::ToString(service)));
+
+		if (json.contains(p) && json[p].is_string())
+			anime.SetServiceId(service, json[p].get<std::string>());
+	}
+
 	for (const auto& lang : TitleLanguages) {
 		nlohmann::json::json_pointer p("/title/" + Strings::ToLower(Translate::ToString(lang)));
 
--- a/src/core/config.cc	Wed Jun 12 17:52:26 2024 -0400
+++ b/src/core/config.cc	Wed Jun 12 19:49:19 2024 -0400
@@ -48,19 +48,19 @@
 
 	anime_list.score_format = Translate::ToScoreFormat(toml::find_or(data, "Anime List", "Score format", "100-point"));
 	anime_list.language = Translate::ToLanguage(toml::find_or(data, "Anime List", "Title language", "Romaji"));
-	anime_list.display_aired_episodes = toml::find_or<bool>(data, "Anime List", "Display only aired episodes", true);
-	anime_list.display_available_episodes = toml::find_or<bool>(data, "Anime List", "Display only available episodes in library", true);
-	anime_list.highlight_anime_if_available = toml::find_or<bool>(data, "Anime List", "Highlight anime if available", true);
+	anime_list.display_aired_episodes = toml::find_or(data, "Anime List", "Display only aired episodes", true);
+	anime_list.display_available_episodes = toml::find_or(data, "Anime List", "Display only available episodes in library", true);
+	anime_list.highlight_anime_if_available = toml::find_or(data, "Anime List", "Highlight anime if available", true);
 	anime_list.highlighted_anime_above_others =
 		(anime_list.highlight_anime_if_available)
-		? toml::find_or<bool>(data, "Anime List", "Display highlighted anime above others", false)
+		? toml::find_or(data, "Anime List", "Display highlighted anime above others", false)
 		: false;
 
 	auth.anilist.auth_token = toml::find_or(data, "Authentication/AniList", "Auth Token", "");
-	auth.anilist.user_id = toml::find_or<int>(data, "Authentication/AniList", "User ID", 0);
+	auth.anilist.user_id = toml::find_or(data, "Authentication/AniList", "User ID", 0);
 
 	auth.kitsu.access_token = toml::find_or(data, "Authentication/Kitsu", "Access Token", "");
-	auth.kitsu.access_token_expiration = toml::find_or(data, "Authentication/Kitsu", "Access Token Expiration", 0LL);
+	auth.kitsu.access_token_expiration = toml::find_or(data, "Authentication/Kitsu", "Access Token Expiration", static_cast<Time::Timestamp>(0));
 	auth.kitsu.refresh_token = toml::find_or(data, "Authentication/Kitsu", "Refresh Token", "");
 	auth.kitsu.user_id = toml::find_or(data, "Authentication/Kitsu", "User ID", "");
 
@@ -90,10 +90,10 @@
 		switch (player.type) {
 			default:
 			case animone::PlayerType::Default:
-				enabled = toml::find_or<bool>(data, "Recognition/Players", player.name, true);
+				enabled = toml::find_or(data, "Recognition/Players", player.name, true);
 				break;
 			case animone::PlayerType::WebBrowser:
-				enabled = toml::find_or<bool>(data, "Recognition/Browsers", player.name, true);
+				enabled = toml::find_or(data, "Recognition/Browsers", player.name, true);
 				break;
 		}
 	}
--- a/src/core/strings.cc	Wed Jun 12 17:52:26 2024 -0400
+++ b/src/core/strings.cc	Wed Jun 12 19:49:19 2024 -0400
@@ -40,21 +40,6 @@
 	return out;
 }
 
-std::string Implode(const std::set<std::string>& set, const std::string& delimiter) {
-	if (set.size() < 1)
-		return "";
-
-	std::string out;
-
-	for (auto it = set.cbegin(); it != set.cend(); it++) {
-		out.append(*it);
-		if (it != std::prev(set.cend(), 1))
-			out.append(delimiter);
-	}
-
-	return out;
-}
-
 std::vector<std::string> Split(const std::string& text, const std::string& delimiter) {
 	if (text.length() < 1)
 		return {};
--- a/src/gui/pages/anime_list.cc	Wed Jun 12 17:52:26 2024 -0400
+++ b/src/gui/pages/anime_list.cc	Wed Jun 12 19:49:19 2024 -0400
@@ -33,24 +33,29 @@
 
 #include <set>
 
-AnimeListPageUpdateEntryThread::AnimeListPageUpdateEntryThread(AnimeListPage* parent) : QThread(parent) {
-	page_ = parent;
-}
+AnimeListPageUpdateEntryThread::AnimeListPageUpdateEntryThread(QObject* parent) : QThread(parent) {}
 
 void AnimeListPageUpdateEntryThread::AddToQueue(int id) {
-	const std::lock_guard<std::mutex> guard(_queue_mutex);
+	const std::lock_guard<std::mutex> guard(queue_mutex_);
 	queue_.push(id);
 }
 
 /* processes the queue... */
 void AnimeListPageUpdateEntryThread::run() {
-	{
-		const std::lock_guard<std::mutex> guard(_queue_mutex);
-		while (!queue_.empty() && !isInterruptionRequested()) {
-			Services::UpdateAnimeEntry(queue_.front());
-			queue_.pop();
-		}
+	queue_mutex_.lock();
+	while (!queue_.empty() && !isInterruptionRequested()) {
+		int id = queue_.front();
+
+		/* unlock the mutex for a long blocking operation, so items
+		 * can be added without worry */
+		queue_mutex_.unlock();
+		Services::UpdateAnimeEntry(id);
+		queue_mutex_.lock();
+
+		queue_.pop();
 	}
+	queue_mutex_.unlock();
+
 	emit NeedRefresh();
 }
 
@@ -447,7 +452,7 @@
 
 /* --------- QTabWidget replication end ---------- */
 
-AnimeListPage::AnimeListPage(QWidget* parent) : QWidget(parent), update_entry_thread_(this) {
+AnimeListPage::AnimeListPage(QWidget* parent) : QWidget(parent) {
 	/* Tab bar */
 	tab_bar = new QTabBar(this);
 	tab_bar->setExpanding(false);
--- a/src/gui/widgets/anime_info.cc	Wed Jun 12 17:52:26 2024 -0400
+++ b/src/gui/widgets/anime_info.cc	Wed Jun 12 19:49:19 2024 -0400
@@ -1,21 +1,61 @@
 #include "gui/widgets/anime_info.h"
 #include "core/anime.h"
+#include "core/anime_db.h"
 #include "core/strings.h"
 #include "gui/translate/anime.h"
 #include "gui/widgets/text.h"
+#include "services/services.h"
 #include <QHBoxLayout>
 #include <QTextStream>
 
+AnimeInfoWidgetGetMetadataThread::AnimeInfoWidgetGetMetadataThread(QObject* parent) : QThread(parent) {}
+
+void AnimeInfoWidgetGetMetadataThread::AddToQueue(int id) {
+	const std::lock_guard<std::mutex> guard(queue_mutex_);
+	queue_.push(id);
+}
+
+/* processes the queue... */
+void AnimeInfoWidgetGetMetadataThread::run() {
+	queue_mutex_.lock();
+	while (!queue_.empty() && !isInterruptionRequested()) {
+		int id = queue_.front();
+
+		queue_mutex_.unlock();
+
+		if (Services::RetrieveAnimeMetadata(id))
+			emit NeedRefresh(id);
+
+		queue_mutex_.lock();
+
+		queue_.pop();
+	}
+	queue_mutex_.unlock();
+}
+
+/* all widgets share this thread */
+static AnimeInfoWidgetGetMetadataThread get_metadata_thread;
+
 AnimeInfoWidget::AnimeInfoWidget(QWidget* parent)
 	: QWidget(parent)
 	, _title(tr("Alternative titles"), "")
-	, _details(tr("Details"), tr("Type:\nEpisodes:\nStatus:\nSeason:\nGenres:\nScore:"), "")
+	, _details(tr("Details"), tr("Type:\nEpisodes:\nStatus:\nSeason:\nGenres:\nProducers:\nScore:"), "")
 	, _synopsis(tr("Synopsis"), "") {
 	QVBoxLayout* layout = new QVBoxLayout(this);
 
 	layout->addWidget(&_title);
 	layout->addWidget(&_details);
 	layout->addWidget(&_synopsis);
+
+	/* ... */
+	connect(&get_metadata_thread, &AnimeInfoWidgetGetMetadataThread::NeedRefresh, this, [this](int id) {
+		setUpdatesEnabled(false);
+
+		if (id == id_)
+			RefreshGenres(Anime::db.items[id]);
+
+		setUpdatesEnabled(true);
+	});
 }
 
 AnimeInfoWidget::AnimeInfoWidget(const Anime::Anime& anime, QWidget* parent) : AnimeInfoWidget(parent) {
@@ -23,27 +63,45 @@
 }
 
 void AnimeInfoWidget::SetAnime(const Anime::Anime& anime) {
+	setUpdatesEnabled(false);
+
+	id_ = anime.GetId();
+
+	get_metadata_thread.AddToQueue(id_);
+	if (!get_metadata_thread.isRunning())
+		get_metadata_thread.start();
+
 	/* alt titles */
 	_title.GetLine()->SetText(Strings::ToQString(Strings::Implode(anime.GetTitleSynonyms(), ", ")));
 
+	RefreshGenres(anime);
+
+	_synopsis.GetParagraph()->SetText(Strings::ToQString(anime.GetSynopsis()));
+
+	setUpdatesEnabled(true);
+
+	updateGeometry();
+}
+
+void AnimeInfoWidget::RefreshGenres(const Anime::Anime& anime) {
 	/* details */
 	QString details_data;
 	QTextStream details_data_s(&details_data);
 
 	/* we have to convert ALL of these strings to
 	 * QString because QTextStream sucks and assumes
-	 * Latin1 (on Windows?) */
+	 * Latin-1 (on Windows?) */
 	const auto genres = anime.GetGenres();
+	const auto producers = anime.GetProducers();
+
 	details_data_s << Strings::ToQString(Translate::ToLocalString(anime.GetFormat())) << "\n"
 	               << anime.GetEpisodes() << "\n"
 	               << Strings::ToQString(Translate::ToLocalString(anime.GetAiringStatus())) << "\n"
 	               << Strings::ToQString(Translate::ToLocalString(anime.GetSeason())) << " "
 	               << anime.GetAirDate().GetYear().value_or(2000) << "\n"
-	               << Strings::ToQString((genres.size() > 1) ? Strings::Implode(genres, ", ") : "-") << "\n"
+	               << Strings::ToQString((genres.size() > 1)    ? Strings::Implode(genres, ", ")    : "-") << "\n"
+	               << Strings::ToQString((producers.size() > 1) ? Strings::Implode(producers, ", ") : "-") << "\n"
 	               << anime.GetAudienceScore() << "%";
-	_details.GetData()->setText(details_data);
 
-	_synopsis.GetParagraph()->SetText(Strings::ToQString(anime.GetSynopsis()));
-
-	updateGeometry();
+	_details.GetData()->setText(details_data);
 }
--- a/src/services/anilist.cc	Wed Jun 12 17:52:26 2024 -0400
+++ b/src/services/anilist.cc	Wed Jun 12 19:49:19 2024 -0400
@@ -234,8 +234,6 @@
 		return 0;
 	}
 
-	session.SetStatusBar("AniList: Retrieving anime list...");
-
 	/* NOTE: these really ought to be in the qrc file */
 	constexpr std::string_view query = "query ($id: Int) {\n"
 	                                   "  MediaListCollection (userId: $id, type: ANIME) {\n"
--- a/src/services/kitsu.cc	Wed Jun 12 17:52:26 2024 -0400
+++ b/src/services/kitsu.cc	Wed Jun 12 19:49:19 2024 -0400
@@ -123,7 +123,50 @@
 
 /* ----------------------------------------------------------------------------- */
 
-static std::optional<nlohmann::json> SendJSONAPIRequest(const std::string& path, const std::map<std::string, std::string>& params) {
+static void AddAnimeFilters(std::map<std::string, std::string>& map) {
+	static const std::vector<std::string> fields = {
+		"abbreviatedTitles",
+		"averageRating",
+		"episodeCount",
+		"episodeLength",
+		"posterImage",
+		"startDate",
+		"status",
+		"subtype",
+		"titles",
+		"categories",
+		"synopsis",
+		"animeProductions",
+	};
+	static const std::string imploded = Strings::Implode(fields, ",");
+
+	map["fields[anime]"] = imploded;
+	map["fields[animeProductions]"] = "producer";
+	map["fields[categories]"] = "title";
+	map["fields[producers]"] = "name";
+}
+
+static void AddLibraryEntryFilters(std::map<std::string, std::string>& map) {
+	static const std::vector<std::string> fields = {
+		"anime",
+		"startedAt",
+		"finishedAt",
+		"notes",
+		"progress",
+		"ratingTwenty",
+		"reconsumeCount",
+		"reconsuming",
+		"status",
+		"updatedAt",
+	};
+	static const std::string imploded = Strings::Implode(fields, ",");
+
+	map["fields[libraryEntries]"] = imploded;
+}
+
+/* ----------------------------------------------------------------------------- */
+
+static std::optional<nlohmann::json> SendJSONAPIRequest(const std::string& path, const std::map<std::string, std::string>& params = {}) {
 	std::optional<std::string> token = AccountAccessToken();
 	if (!token)
 		return std::nullopt;
@@ -140,16 +183,14 @@
 	if (response.empty())
 		return std::nullopt;
 
-	std::optional<nlohmann::json> result;
+	nlohmann::json json;
 	try {
-		result = nlohmann::json::parse(response);
+		json = nlohmann::json::parse(response);
 	} catch (const std::exception& ex) {
 		session.SetStatusBar(std::string("Kitsu: Failed to parse response with error \"") + ex.what() + "\"!");
 		return std::nullopt;
 	}
 
-	const nlohmann::json& json = result.value();
-
 	if (json.contains("/errors"_json_pointer)) {
 		for (const auto& item : json["/errors"])
 			std::cerr << "Kitsu: API returned error \"" << json["/errors/title"_json_pointer] << "\" with detail \"" << json["/errors/detail"] << std::endl;
@@ -158,7 +199,7 @@
 		return std::nullopt;
 	}
 
-	return result;
+	return json;
 }
 
 static void ParseTitleJson(Anime::Anime& anime, const nlohmann::json& json) {
@@ -204,6 +245,21 @@
 	anime.SetUserStatus(lookup.at(str));
 }
 
+static void ParseSeriesStatus(Anime::Anime& anime, const std::string& str) {
+	static const std::map<std::string, Anime::SeriesStatus> lookup = {
+		{"current", Anime::SeriesStatus::Releasing},
+		{"finished", Anime::SeriesStatus::Finished},
+		{"tba", Anime::SeriesStatus::Hiatus}, // is this right?
+		{"unreleased", Anime::SeriesStatus::Cancelled},
+		{"upcoming", Anime::SeriesStatus::NotYetReleased},
+	};
+
+	if (lookup.find(str) == lookup.end())
+		return;
+
+	anime.SetAiringStatus(lookup.at(str));
+}
+
 static int ParseAnimeJson(const nlohmann::json& json) {
 	static const std::string FAILED_TO_PARSE = "Kitsu: Failed to parse anime object!";
 
@@ -221,10 +277,6 @@
 	const auto& attributes = json["/attributes"_json_pointer];
 
 	int id = Anime::db.LookupServiceIdOrUnused(Anime::Service::Kitsu, service_id);
-	if (!id) {
-		session.SetStatusBar(FAILED_TO_PARSE + " getting unused ID");
-		return 0;
-	}
 
 	Anime::Anime& anime = Anime::db.items[id];
 
@@ -237,19 +289,24 @@
 	if (attributes.contains("/titles"_json_pointer) && attributes["/titles"_json_pointer].is_object())
 		ParseTitleJson(anime, attributes["/titles"_json_pointer]);
 
-	// FIXME: parse abbreviatedTitles for synonyms??
+	if (attributes.contains("/abbreviatedTitles"_json_pointer) && attributes["/abbreviatedTitles"_json_pointer].is_array())
+		for (const auto& title : attributes["/abbreviatedTitles"_json_pointer])
+			anime.AddTitleSynonym(title.get<std::string>());
 
-	if (attributes.contains("/averageRating"_json_pointer) && attributes["/averageRating"_json_pointer].is_number())
-		anime.SetAudienceScore(JSON::GetNumber<double>(attributes, "/averageRating"_json_pointer));
+	if (attributes.contains("/averageRating"_json_pointer) && attributes["/averageRating"_json_pointer].is_string())
+		anime.SetAudienceScore(Strings::ToInt<double>(attributes["/averageRating"_json_pointer].get<std::string>()));
 
 	if (attributes.contains("/startDate"_json_pointer) && attributes["/startDate"_json_pointer].is_string())
 		anime.SetAirDate(attributes["/startDate"_json_pointer].get<std::string>());
 
-	// TODO: endDate
+	// XXX endDate
 
 	if (attributes.contains("/subtype"_json_pointer) && attributes["/subtype"_json_pointer].is_string())
 		ParseSubtype(anime, attributes["/subtype"_json_pointer].get<std::string>());
 
+	if (attributes.contains("/status"_json_pointer) && attributes["/status"_json_pointer].is_string())
+		ParseSeriesStatus(anime, attributes["/status"_json_pointer].get<std::string>());
+
 	if (attributes.contains("/posterImage/original"_json_pointer) && attributes["/posterImage/original"_json_pointer].is_string())
 		anime.SetPosterUrl(attributes["/posterImage/original"_json_pointer].get<std::string>());
 
@@ -330,48 +387,63 @@
 	return id;
 }
 
+static void ParseMetadataJson(Anime::Anime& anime, const nlohmann::json& json) {
+	std::vector<std::string> categories;
+	std::vector<std::string> producers;
+
+	for (const auto& item : json) {
+		std::string variant;
+		{
+			static const nlohmann::json::json_pointer p = "/type"_json_pointer;
+
+			if (!item.contains(p) || !item[p].is_string())
+				continue;
+
+			variant = item[p].get<std::string>();
+		}
+
+		/* now parse variants */
+		if (variant == "categories") {
+			static const nlohmann::json::json_pointer p = "/attributes/title"_json_pointer;
+
+			if (!item.contains(p) || !item[p].is_string())
+				continue;
+
+			categories.push_back(item[p].get<std::string>());
+		} else if (variant == "producers") {
+			static const nlohmann::json::json_pointer p = "/attributes/name"_json_pointer;
+
+			if (!item.contains(p) || !item[p].is_string())
+				continue;
+
+			producers.push_back(item[p].get<std::string>());
+		}
+	}
+
+	anime.SetGenres(categories);
+	anime.SetProducers(producers);
+}
+
 static bool ParseAnyJson(const nlohmann::json& json) {
-	enum class Variant {
-		Unknown,
-		Anime,
-		LibraryEntry,
-		Category,
-		Producer,
-	};
-
-	static const std::map<std::string, Variant> lookup = {
-		{"anime", Variant::Anime},
-		{"libraryEntries", Variant::LibraryEntry},
-		{"category", Variant::Category},
-		{"producers", Variant::Producer}
-	};
-
 	static const nlohmann::json::json_pointer required = "/type"_json_pointer;
 	if (!json.contains(required) && !json[required].is_string()) {
 		session.SetStatusBar(std::string("Kitsu: Failed to parse generic object! (missing ") + required.to_string() + ")");
 		return 0;
 	}
 
-	Variant variant = Variant::Unknown;
-
-	std::string json_type = json["/type"_json_pointer].get<std::string>();
-
-	if (lookup.find(json_type) != lookup.end())
-		variant = lookup.at(json_type);
+	std::string variant = json["/type"_json_pointer].get<std::string>();
 
-	switch (variant) {
-		case Variant::Anime:
-			return !!ParseAnimeJson(json);
-		case Variant::LibraryEntry:
-			return !!ParseLibraryJson(json);
-		/* ... */
-		case Variant::Category:
-		case Variant::Producer:
-			return true;
-		default:
-			std::cerr << "Kitsu: received unknown type " << json_type << std::endl;
-			return true;
+	if (variant == "anime") {
+		return !!ParseAnimeJson(json);
+	} else if (variant == "libraryEntries") {
+		return !!ParseLibraryJson(json);
+	} else if (variant == "categories" || variant == "producers") {
+		/* do nothing */
+	} else {
+		std::cerr << "Kitsu: received unknown type " << variant << std::endl;
 	}
+
+	return true;
 }
 
 int GetAnimeList() {
@@ -394,6 +466,8 @@
 		{"page[offset]", Strings::ToUtf8String(page)},
 		{"page[limit]", Strings::ToUtf8String(LIBRARY_MAX_SIZE)}
 	};
+	AddAnimeFilters(params);
+	AddLibraryEntryFilters(params);
 
 	Anime::db.RemoveAllUserData();
 
@@ -429,6 +503,44 @@
 	return 1;
 }
 
+bool RetrieveAnimeMetadata(int id) {
+	/* TODO: the genres should *probably* be a std::optional */
+	Anime::Anime& anime = Anime::db.items[id];
+	if (anime.GetGenres().size() > 0)
+		return false;
+
+	std::optional<std::string> service_id = anime.GetServiceId(Anime::Service::Kitsu);
+	if (!service_id)
+		return false;
+
+	session.SetStatusBar("Kitsu: Retrieving anime metadata...");
+
+	static const std::map<std::string, std::string> params = {
+		{"include", Strings::Implode({
+			"categories",
+			"animeProductions",
+			"animeProductions.producer",
+		}, ",")}
+	};
+
+	std::optional<nlohmann::json> response = SendJSONAPIRequest("/anime/" + service_id.value(), params);
+	if (!response)
+		return false;
+
+	const auto& json = response.value();
+
+	if (!json.contains("/included"_json_pointer) || !json["/included"_json_pointer].is_array()) {
+		session.SetStatusBar("Kitsu: Server returned bad data when trying to retrieve anime metadata!");
+		return false;
+	}
+
+	ParseMetadataJson(anime, json["/included"_json_pointer]);
+
+	session.SetStatusBar("Kitsu: Successfully retrieved anime metadata!");
+
+	return true;
+}
+
 /* unimplemented for now */
 std::vector<int> Search(const std::string& search) {
 	return {};
--- a/src/services/services.cc	Wed Jun 12 17:52:26 2024 -0400
+++ b/src/services/services.cc	Wed Jun 12 19:49:19 2024 -0400
@@ -1,11 +1,14 @@
 #include "services/services.h"
 #include "core/session.h"
+#include "gui/translate/anime.h"
 #include "services/anilist.h"
 #include "services/kitsu.h"
 
 namespace Services {
 
 void Synchronize() {
+	session.SetStatusBar(Translate::ToString(session.config.service) + ": Retrieving anime list...");
+
 	switch (session.config.service) {
 		case Anime::Service::AniList: AniList::GetAnimeList(); break;
 		case Anime::Service::Kitsu: Kitsu::GetAnimeList(); break;
@@ -13,7 +16,16 @@
 	}
 }
 
+bool RetrieveAnimeMetadata(int id) {
+	switch (session.config.service) {
+		case Anime::Service::Kitsu:	return Kitsu::RetrieveAnimeMetadata(id);
+		default: return false;
+	}
+}
+
 std::vector<int> Search(const std::string& search) {
+	session.SetStatusBar(Translate::ToString(session.config.service) + ": Requesting search query...");
+
 	switch (session.config.service) {
 		case Anime::Service::AniList: return AniList::Search(search);
 		case Anime::Service::Kitsu: return Kitsu::Search(search);
@@ -22,6 +34,8 @@
 }
 
 std::vector<int> GetSeason(Anime::SeriesSeason season, Date::Year year) {
+	session.SetStatusBar(Translate::ToString(session.config.service) + ": Retrieving anime season data...");
+
 	switch (session.config.service) {
 		case Anime::Service::AniList: return AniList::GetSeason(season, year);
 		case Anime::Service::Kitsu: return Kitsu::GetSeason(season, year);
@@ -30,6 +44,8 @@
 }
 
 void UpdateAnimeEntry(int id) {
+	session.SetStatusBar(Translate::ToString(session.config.service) + ": Updating remote anime entry...");
+
 	switch (session.config.service) {
 		case Anime::Service::AniList: AniList::UpdateAnimeEntry(id); break;
 		case Anime::Service::Kitsu: Kitsu::UpdateAnimeEntry(id); break;