view src/services/kitsu.cc @ 327:b5d6c27c308f

anime: refactor Anime::SeriesSeason to Season class ToLocalString has also been altered to take in both season and year because lots of locales actually treat formatting seasons differently! most notably is Russian which adds a suffix at the end to notate seasons(??)
author Paper <paper@paper.us.eu.org>
date Thu, 13 Jun 2024 01:49:18 -0400
parents 78929794e7d8
children 948955c3ba81
line wrap: on
line source

#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 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;

	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);

	const std::string response = Strings::ToUtf8String(HTTP::Request(url, headers, "", HTTP::Type::Get));
	if (response.empty())
		return std::nullopt;

	nlohmann::json json;
	try {
		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;
	}

	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 json;
}

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 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 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!";

	const std::string service_id = json["/id"_json_pointer].get<std::string>();
	if (service_id.empty()) {
		session.SetStatusBar(FAILED_TO_PARSE + " (/id)");
		return 0;
	}

	if (!json.contains("/attributes"_json_pointer)) {
		session.SetStatusBar(FAILED_TO_PARSE + " (/attributes)");
		return 0;
	}

	const auto& attributes = json["/attributes"_json_pointer];

	int id = Anime::db.LookupServiceIdOrUnused(Anime::Service::Kitsu, service_id);

	Anime::Anime& anime = Anime::db.items[id];

	anime.SetId(id);
	anime.SetServiceId(Anime::Service::Kitsu, service_id);

	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]);

	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_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.SetStartedDate(attributes["/startDate"_json_pointer].get<std::string>());

	anime.SetCompletedDate(attributes.contains("/endDate"_json_pointer) && attributes["/endDate"_json_pointer].is_string()
		? attributes["/endDate"_json_pointer].get<std::string>()
		: anime.GetStartedDate());

	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>());

	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) {
	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 = Anime::db.LookupServiceIdOrUnused(Anime::Service::Kitsu, service_id);

	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.SetId(id);
	anime.SetServiceId(Anime::Service::Kitsu, service_id);

	anime.SetUserId(library_id);

	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 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) {
	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;
	}

	std::string variant = json["/type"_json_pointer].get<std::string>();

	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() {
	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)}
	};
	AddAnimeFilters(params);
	AddLibraryEntryFilters(params);

	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;
}

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 && anime.GetProducers().size())
		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 {};
}

bool GetSeason(Anime::Season season) {
	return false;
}

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<nlohmann::json> response = SendJSONAPIRequest("/users", params);
	if (!response)
		return false; // whuh?

	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 authorized user!");
	session.config.auth.kitsu.user_id = json["/data/0/id"_json_pointer].get<std::string>();

	return true;
}

} // namespace Kitsu
} // namespace Services