diff src/services/kitsu.cc @ 317:b1f4d1867ab1

services: VERY initial Kitsu support it only supports user authentication for now, but it's definitely a start.
author Paper <paper@paper.us.eu.org>
date Wed, 12 Jun 2024 04:07:10 -0400
parents
children d928ec7b6a0d
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/services/kitsu.cc	Wed Jun 12 04:07:10 2024 -0400
@@ -0,0 +1,308 @@
+#include "services/anilist.h"
+#include "core/anime.h"
+#include "core/anime_db.h"
+#include "core/date.h"
+#include "core/config.h"
+#include "core/http.h"
+#include "core/json.h"
+#include "core/session.h"
+#include "core/strings.h"
+#include "core/time.h"
+#include "gui/translate/anilist.h"
+
+#include <QByteArray>
+#include <QDate>
+#include <QDesktopServices>
+#include <QInputDialog>
+#include <QLineEdit>
+#include <QMessageBox>
+#include <QUrl>
+
+#include <chrono>
+#include <exception>
+#include <string_view>
+
+#include <iostream>
+
+using namespace nlohmann::literals::json_literals;
+
+static constexpr std::string_view CLIENT_ID = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd";
+static constexpr std::string_view CLIENT_SECRET = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151";
+
+static constexpr std::string_view BASE_API_PATH = "https://kitsu.io/api/edge";
+static constexpr std::string_view OAUTH_PATH = "https://kitsu.io/api/oauth/token";
+
+namespace Services {
+namespace Kitsu {
+
+/* This nifty little function basically handles authentication AND reauthentication. */
+static bool SendAuthRequest(const nlohmann::json& data) {
+	static const std::vector<std::string> headers = {
+		{"Content-Type: application/json"}
+	};
+
+	const std::string ret = Strings::ToUtf8String(HTTP::Request(std::string(OAUTH_PATH), headers, data.dump(), HTTP::Type::Post));
+	if (ret.empty()) {
+		session.SetStatusBar("Kitsu: Request returned empty data!");
+		return false;
+	}
+
+	nlohmann::json result;
+	try {
+		result = nlohmann::json::parse(ret, nullptr, false);
+	} catch (const std::exception& ex) {
+		session.SetStatusBar(std::string("Kitsu: Failed to parse authorization data with error \"") + ex.what() + "\"!");
+		return false;
+	}
+
+	if (result.contains("/error"_json_pointer)) {
+		std::string status = "Kitsu: Failed with error \"";
+		status += result["/error"_json_pointer].get<std::string>();
+
+		if (result.contains("/error_description"_json_pointer)) {
+			status += "\" and description \"";
+			status += result["/error_description"_json_pointer].get<std::string>();
+		}
+
+		status += "\"!";
+
+		session.SetStatusBar(status);
+		return false;
+	}
+
+	const std::vector<nlohmann::json::json_pointer> required = {
+		"/access_token"_json_pointer,
+		"/created_at"_json_pointer,
+		"/expires_in"_json_pointer,
+		"/refresh_token"_json_pointer,
+		"/scope"_json_pointer,
+		"/token_type"_json_pointer
+	};
+
+	for (const auto& ptr : required) {
+		if (!result.contains(ptr)) {
+			session.SetStatusBar("Kitsu: Authorization request returned bad data!");
+			return false;
+		}
+	}
+
+	session.config.auth.kitsu.access_token = result["/access_token"_json_pointer].get<std::string>();
+	session.config.auth.kitsu.access_token_expiration
+		= result["/created_at"_json_pointer].get<Time::Timestamp>();
+		  + result["/expires_in"_json_pointer].get<Time::Timestamp>();
+	session.config.auth.kitsu.refresh_token = result["/refresh_token"_json_pointer].get<std::string>();
+
+	/* the next two are not that important */
+
+	return true;
+}
+
+static bool RefreshAccessToken(std::string& access_token, const std::string& refresh_token) {
+	const nlohmann::json request = {
+		{"grant_type", "refresh_token"},
+		{"refresh_token", refresh_token}
+	};
+
+	if (!SendAuthRequest(request))
+		return false;
+
+	return true;
+}
+
+/* ----------------------------------------------------------------------------- */
+
+static std::optional<std::string> AccountAccessToken() {
+	auto& auth = session.config.auth.kitsu;
+
+	if (Time::GetSystemTime() >= session.config.auth.kitsu.access_token_expiration)
+		if (!RefreshAccessToken(auth.access_token, auth.refresh_token))
+			return std::nullopt;
+
+	return auth.access_token;
+}
+
+/* ----------------------------------------------------------------------------- */
+
+static std::optional<std::string> SendRequest(const std::string& path, const std::map<std::string, std::string>& params) {
+	std::optional<std::string> token = AccountAccessToken();
+	if (!token)
+		return std::nullopt;
+
+	const std::vector<std::string> headers = {
+		"Accept: application/vnd.api+json",
+		"Authorization: Bearer " + token.value(),
+		"Content-Type: application/vnd.api+json"
+	};
+
+	const std::string url = HTTP::EncodeParamsList(std::string(BASE_API_PATH) + path, params);
+
+	return Strings::ToUtf8String(HTTP::Request(url, headers, "", HTTP::Type::Get));
+}
+
+static void ParseTitleJson(Anime::Anime& anime, const nlohmann::json& json) {
+	static const std::map<std::string, Anime::TitleLanguage> lookup = {
+		{"en", Anime::TitleLanguage::English},
+		{"en_jp", Anime::TitleLanguage::Romaji},
+		{"ja_jp", Anime::TitleLanguage::Native}
+	};
+
+	for (const auto& [string, title] : lookup)
+		if (json.contains(string))
+			anime.SetTitle(title, json[string].get<std::string>());
+}
+
+static void ParseSubtype(Anime::Anime& anime, const std::string& str) {
+	static const std::map<std::string, Anime::SeriesFormat> lookup = {
+		{"ONA", Anime::SeriesFormat::Ona},
+		{"OVA", Anime::SeriesFormat::Ova},
+		{"TV", Anime::SeriesFormat::Tv},
+		{"movie", Anime::SeriesFormat::Movie},
+		{"music", Anime::SeriesFormat::Music},
+		{"special", Anime::SeriesFormat::Special}
+	};
+
+	if (lookup.find(str) == lookup.end())
+		return;
+
+	anime.SetFormat(lookup.at(str));
+}
+
+static const std::string FAILED_TO_PARSE = "Kitsu: Failed to parse anime object!";
+
+static int ParseAnimeJson(const nlohmann::json& json) {
+	const std::string service_id = json["/id"_json_pointer].get<std::string>();
+	if (service_id.empty()) {
+		session.SetStatusBar(FAILED_TO_PARSE);
+		return 0;
+	}
+
+	if (!json.contains("/attributes"_json_pointer)) {
+		session.SetStatusBar(FAILED_TO_PARSE);
+		return 0;
+	}
+
+	const auto& attributes = json["/attributes"_json_pointer];
+
+	int id = Anime::db.LookupServiceIdOrUnused(Anime::Service::Kitsu, service_id);
+	if (!id) {
+		session.SetStatusBar(FAILED_TO_PARSE);
+		return 0;
+	}
+
+	Anime::Anime& anime = Anime::db.items[id];
+
+	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]);
+
+	// FIXME: parse abbreviatedTitles for synonyms??
+
+	anime.SetAudienceScore(JSON::GetNumber<double>(attributes, "/averageRating"_json_pointer));
+
+	if (attributes.contains("/startDate"_json_pointer))
+		anime.SetAirDate(attributes["/startDate"_json_pointer].get<std::string>());
+
+	// TODO: endDate
+
+	if (attributes.contains("/subtype"_json_pointer))
+		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>());
+
+	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)) {
+		session.SetStatusBar("Kitsu: Failed to parse library object!");
+		return 0;
+	}
+
+	int id = ParseAnimeJson(json["/relationships/anime/data"_json_pointer]);
+	if (!id)
+		return 0;
+
+	const auto& attributes = json["/attributes"_json_pointer];
+
+	const std::string library_id = json["/id"_json_pointer].get<std::string>();
+
+	Anime::Anime& anime = Anime::db.items[id];
+
+	anime.AddToUserList();
+
+	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();
+
+	return id;
+}
+
+int GetAnimeList() {
+	return 0;
+}
+
+/* unimplemented for now */
+std::vector<int> Search(const std::string& search) {
+	return {};
+}
+
+std::vector<int> GetSeason(Anime::SeriesSeason season, Date::Year year) {
+	return {};
+}
+
+int UpdateAnimeEntry(int id) {
+	return 0;
+}
+
+bool AuthorizeUser(const std::string& email, const std::string& password) {
+	const nlohmann::json body = {
+		{"grant_type", "password"},
+		{"username", email},
+		{"password", HTTP::UrlEncode(password)}
+	};
+
+	if (!SendAuthRequest(body))
+		return false;
+
+	static const std::map<std::string, std::string> params = {
+		{"filter[self]", "true"}
+	};
+
+	std::optional<std::string> response = SendRequest("/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;
+	}
+
+	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.config.auth.kitsu.user_id = json["/data/0/id"_json_pointer].get<std::string>();
+
+	return true;
+}
+
+} // namespace Kitsu
+} // namespace Services