diff src/services/kitsu.cc @ 319:d928ec7b6a0d

services/kitsu: implement GetAnimeList() it finally works!
author Paper <paper@paper.us.eu.org>
date Wed, 12 Jun 2024 17:52:26 -0400
parents b1f4d1867ab1
children 1b5c04268d6a
line wrap: on
line diff
--- a/src/services/kitsu.cc	Wed Jun 12 05:25:41 2024 -0400
+++ b/src/services/kitsu.cc	Wed Jun 12 17:52:26 2024 -0400
@@ -123,7 +123,7 @@
 
 /* ----------------------------------------------------------------------------- */
 
-static std::optional<std::string> SendRequest(const std::string& path, const std::map<std::string, std::string>& params) {
+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;
@@ -136,7 +136,29 @@
 
 	const std::string url = HTTP::EncodeParamsList(std::string(BASE_API_PATH) + path, params);
 
-	return Strings::ToUtf8String(HTTP::Request(url, headers, "", HTTP::Type::Get));
+	const std::string response = Strings::ToUtf8String(HTTP::Request(url, headers, "", HTTP::Type::Get));
+	if (response.empty())
+		return std::nullopt;
+
+	std::optional<nlohmann::json> result;
+	try {
+		result = 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;
+
+		session.SetStatusBar("Kitsu: Request failed with errors!");
+		return std::nullopt;
+	}
+
+	return result;
 }
 
 static void ParseTitleJson(Anime::Anime& anime, const nlohmann::json& json) {
@@ -167,17 +189,32 @@
 	anime.SetFormat(lookup.at(str));
 }
 
-static const std::string FAILED_TO_PARSE = "Kitsu: Failed to parse anime object!";
+static void ParseListStatus(Anime::Anime& anime, const std::string& str) {
+	static const std::map<std::string, Anime::ListStatus> lookup = {
+		{"completed", Anime::ListStatus::Completed},
+		{"current", Anime::ListStatus::Current},
+		{"dropped", Anime::ListStatus::Dropped},
+		{"on_hold", Anime::ListStatus::Paused},
+		{"planned", Anime::ListStatus::Planning}
+	};
+
+	if (lookup.find(str) == lookup.end())
+		return;
+
+	anime.SetUserStatus(lookup.at(str));
+}
 
 static int ParseAnimeJson(const nlohmann::json& json) {
+	static const std::string FAILED_TO_PARSE = "Kitsu: Failed to parse anime object!";
+
 	const std::string service_id = json["/id"_json_pointer].get<std::string>();
 	if (service_id.empty()) {
-		session.SetStatusBar(FAILED_TO_PARSE);
+		session.SetStatusBar(FAILED_TO_PARSE + " (/id)");
 		return 0;
 	}
 
 	if (!json.contains("/attributes"_json_pointer)) {
-		session.SetStatusBar(FAILED_TO_PARSE);
+		session.SetStatusBar(FAILED_TO_PARSE + " (/attributes)");
 		return 0;
 	}
 
@@ -185,7 +222,7 @@
 
 	int id = Anime::db.LookupServiceIdOrUnused(Anime::Service::Kitsu, service_id);
 	if (!id) {
-		session.SetStatusBar(FAILED_TO_PARSE);
+		session.SetStatusBar(FAILED_TO_PARSE + " getting unused ID");
 		return 0;
 	}
 
@@ -193,39 +230,59 @@
 
 	anime.SetId(id);
 	anime.SetServiceId(Anime::Service::Kitsu, service_id);
-	anime.SetSynopsis(attributes["/synopsis"_json_pointer].get<std::string>());
-	ParseTitleJson(anime, attributes["/titles"_json_pointer]);
+
+	if (attributes.contains("/synopsis"_json_pointer) && attributes["/synopsis"_json_pointer].is_string())
+		anime.SetSynopsis(attributes["/synopsis"_json_pointer].get<std::string>());
+
+	if (attributes.contains("/titles"_json_pointer) && attributes["/titles"_json_pointer].is_object())
+		ParseTitleJson(anime, attributes["/titles"_json_pointer]);
 
 	// FIXME: parse abbreviatedTitles for synonyms??
 
-	anime.SetAudienceScore(JSON::GetNumber<double>(attributes, "/averageRating"_json_pointer));
+	if (attributes.contains("/averageRating"_json_pointer) && attributes["/averageRating"_json_pointer].is_number())
+		anime.SetAudienceScore(JSON::GetNumber<double>(attributes, "/averageRating"_json_pointer));
 
-	if (attributes.contains("/startDate"_json_pointer))
+	if (attributes.contains("/startDate"_json_pointer) && attributes["/startDate"_json_pointer].is_string())
 		anime.SetAirDate(attributes["/startDate"_json_pointer].get<std::string>());
 
 	// TODO: endDate
 
-	if (attributes.contains("/subtype"_json_pointer))
+	if (attributes.contains("/subtype"_json_pointer) && attributes["/subtype"_json_pointer].is_string())
 		ParseSubtype(anime, attributes["/subtype"_json_pointer].get<std::string>());
 
-	anime.SetPosterUrl(attributes["/posterImage/original"_json_pointer].get<std::string>());
-	anime.SetEpisodes(attributes["/episodeCount"_json_pointer].get<int>());
-	anime.SetDuration(attributes["/episodeLength"_json_pointer].get<int>());
+	if (attributes.contains("/posterImage/original"_json_pointer) && attributes["/posterImage/original"_json_pointer].is_string())
+		anime.SetPosterUrl(attributes["/posterImage/original"_json_pointer].get<std::string>());
+
+	if (attributes.contains("/episodeCount"_json_pointer) && attributes["/episodeCount"_json_pointer].is_number())
+		anime.SetEpisodes(attributes["/episodeCount"_json_pointer].get<int>());
+
+	if (attributes.contains("/episodeLength"_json_pointer) && attributes["/episodeLength"_json_pointer].is_number())
+		anime.SetDuration(attributes["/episodeLength"_json_pointer].get<int>());
 
 	return id;
 }
 
 static int ParseLibraryJson(const nlohmann::json& json) {
-	if (!json.contains("/relationships/anime/data"_json_pointer)
-		|| !json.contains("/attributes"_json_pointer)
-		|| !json.contains("/id"_json_pointer)) {
+	static const std::vector<nlohmann::json::json_pointer> required = {
+		"/id"_json_pointer,
+		"/relationships/anime/data/id"_json_pointer,
+		"/attributes"_json_pointer,
+	};
+
+	for (const auto& ptr : required) {
+		if (!json.contains(ptr)) {
+			session.SetStatusBar(std::string("Kitsu: Failed to parse library object! (missing ") + ptr.to_string() + ")");
+			return 0;
+		}
+	}
+
+	std::string service_id = json["/relationships/anime/data/id"_json_pointer].get<std::string>();
+	if (service_id.empty()) {
 		session.SetStatusBar("Kitsu: Failed to parse library object!");
 		return 0;
 	}
 
-	int id = ParseAnimeJson(json["/relationships/anime/data"_json_pointer]);
-	if (!id)
-		return 0;
+	int id = Anime::db.LookupServiceIdOrUnused(Anime::Service::Kitsu, service_id);
 
 	const auto& attributes = json["/attributes"_json_pointer];
 
@@ -235,23 +292,141 @@
 
 	anime.AddToUserList();
 
+	anime.SetId(id);
+	anime.SetServiceId(Anime::Service::Kitsu, service_id);
+
 	anime.SetUserId(library_id);
-	anime.SetUserDateStarted(Date(attributes["/startedAt"_json_pointer].get<std::string>()));
-	anime.SetUserDateCompleted(Date(attributes["/finishedAt"_json_pointer].get<std::string>()));
-	anime.SetUserNotes(attributes["/notes"_json_pointer].get<std::string>());
-	anime.SetUserProgress(attributes["/progress"_json_pointer].get<int>());
-	anime.SetUserScore(attributes["/ratingTwenty"_json_pointer].get<int>() * 5);
-	anime.SetUserIsPrivate(attributes["/private"_json_pointer].get<bool>());
-	anime.SetUserRewatchedTimes(attributes["/reconsumeCount"_json_pointer].get<int>());
-	anime.SetUserIsRewatching(attributes["/reconsuming"_json_pointer].get<bool>()); /* "reconsuming". really? */
-	// anime.SetUserStatus();
-	// anime.SetUserLastUpdated();
+
+	if (attributes.contains("/startedAt"_json_pointer) && attributes["/startedAt"_json_pointer].is_string())
+		anime.SetUserDateStarted(Date(Time::ParseISO8601Time(attributes["/startedAt"_json_pointer].get<std::string>())));
+
+	if (attributes.contains("/finishedAt"_json_pointer) && attributes["/finishedAt"_json_pointer].is_string())
+		anime.SetUserDateCompleted(Date(Time::ParseISO8601Time(attributes["/finishedAt"_json_pointer].get<std::string>())));
+
+	if (attributes.contains("/notes"_json_pointer) && attributes["/notes"_json_pointer].is_string())
+		anime.SetUserNotes(attributes["/notes"_json_pointer].get<std::string>());
+
+	if (attributes.contains("/progress"_json_pointer) && attributes["/progress"_json_pointer].is_number())
+		anime.SetUserProgress(attributes["/progress"_json_pointer].get<int>());
+
+	if (attributes.contains("/ratingTwenty"_json_pointer) && attributes["/ratingTwenty"_json_pointer].is_number())
+		anime.SetUserScore(attributes["/ratingTwenty"_json_pointer].get<int>() * 5);
+
+	if (attributes.contains("/private"_json_pointer) && attributes["/private"_json_pointer].is_boolean())
+		anime.SetUserIsPrivate(attributes["/private"_json_pointer].get<bool>());
+
+	if (attributes.contains("/reconsumeCount"_json_pointer) && attributes["/reconsumeCount"_json_pointer].is_number())
+		anime.SetUserRewatchedTimes(attributes["/reconsumeCount"_json_pointer].get<int>());
+
+	if (attributes.contains("/reconsuming"_json_pointer) && attributes["/reconsuming"_json_pointer].is_boolean())
+		anime.SetUserIsRewatching(attributes["/reconsuming"_json_pointer].get<bool>()); /* lmfao "reconsuming" */
+
+	if (attributes.contains("/status"_json_pointer) && attributes["/status"_json_pointer].is_string())
+		ParseListStatus(anime, attributes["/status"_json_pointer].get<std::string>());
+
+	if (attributes.contains("/progressedAt"_json_pointer) && attributes["/progressedAt"_json_pointer].is_string())
+		anime.SetUserTimeUpdated(Time::ParseISO8601Time(attributes["/progressedAt"_json_pointer].get<std::string>()));
 
 	return id;
 }
 
+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);
+
+	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;
+	}
+}
+
 int GetAnimeList() {
-	return 0;
+	static constexpr int LIBRARY_MAX_SIZE = 500;
+
+	const auto& auth = session.config.auth.kitsu;
+
+	if (auth.user_id.empty()) {
+		session.SetStatusBar("Kitsu: User ID is unavailable!");
+		return 0;
+	}
+
+	int page = 0;
+	bool have_next_page = true;
+
+	std::map<std::string, std::string> params = {
+		{"filter[user_id]", auth.user_id},
+		{"filter[kind]", "anime"},
+		{"include", "anime"},
+		{"page[offset]", Strings::ToUtf8String(page)},
+		{"page[limit]", Strings::ToUtf8String(LIBRARY_MAX_SIZE)}
+	};
+
+	Anime::db.RemoveAllUserData();
+
+	bool success = true;
+
+	while (have_next_page) {
+		std::optional<nlohmann::json> response = SendJSONAPIRequest("/library-entries", params);
+		if (!response)
+			return 0;
+
+		const nlohmann::json& root = response.value();
+
+		if (root.contains("/next"_json_pointer) && root["/next"_json_pointer].is_number()) {
+			page += root["/next"_json_pointer].get<int>();
+			if (page <= 0)
+				have_next_page = false;
+		} else have_next_page = false;
+
+		for (const auto& item : root["/data"_json_pointer])
+			if (!ParseLibraryJson(item))
+				success = false;
+
+		for (const auto& variant : root["/included"_json_pointer])
+			if (!ParseAnyJson(variant))
+				success = false;
+
+		params["page[offset]"] = Strings::ToUtf8String(page);
+	}
+
+	if (success)
+		session.SetStatusBar("Kitsu: Successfully received library data!");
+
+	return 1;
 }
 
 /* unimplemented for now */
@@ -281,24 +456,18 @@
 		{"filter[self]", "true"}
 	};
 
-	std::optional<std::string> response = SendRequest("/users", params);
+	std::optional<nlohmann::json> response = SendJSONAPIRequest("/users", params);
 	if (!response)
 		return false; // whuh?
 
-	nlohmann::json json;
-	try {
-		json = nlohmann::json::parse(response.value());
-	} catch (const std::exception& ex) {
-		session.SetStatusBar(std::string("Kitsu: Failed to parse user data with error \"") + ex.what() + "\"!");
-		return false;
-	}
+	const nlohmann::json& json = response.value();
 
 	if (!json.contains("/data/0/id"_json_pointer)) {
 		session.SetStatusBar("Kitsu: Failed to retrieve user ID!");
 		return false;
 	}
 
-	session.SetStatusBar("Kitsu: Successfully retrieved user data!");
+	session.SetStatusBar("Kitsu: Successfully authorized user!");
 	session.config.auth.kitsu.user_id = json["/data/0/id"_json_pointer].get<std::string>();
 
 	return true;