view src/services/kitsu.cc @ 337:a7d4e5107531

dep/animone: REFACTOR ALL THE THINGS 1: animone now has its own syntax divergent from anisthesia, making different platforms actually have their own sections 2: process names in animone are now called `comm' (this will probably break things). this is what its called in bsd/linux so I'm just going to use it everywhere 3: the X11 code now checks for the existence of a UTF-8 window title and passes it if available 4: ANYTHING THATS NOT LINUX IS 100% UNTESTED AND CAN AND WILL BREAK! I still actually need to test the bsd code. to be honest I'm probably going to move all of the bsds into separate files because they're all essentially different operating systems at this point
author Paper <paper@paper.us.eu.org>
date Wed, 19 Jun 2024 12:51:15 -0400
parents 5098387a3a46
children 7e97c566cce4
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 <fmt/core.h>

#include <iostream>

using namespace nlohmann::literals::json_literals;

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(Strings::Translate("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(fmt::format(Strings::Translate("Kitsu: Failed to parse authorization data with error \"{}\""), ex.what()));
		return false;
	}

	if (result.contains("/error"_json_pointer)) {
		session.SetStatusBar(fmt::format(Strings::Translate("Kitsu: Failed with error \"{}\"!"), result["/error"_json_pointer].get<std::string>()));
		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(Strings::Translate("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(fmt::format(Strings::Translate("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(Strings::Translate("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) {
	const std::string FAILED_TO_PARSE = Strings::Translate("Kitsu: Failed to parse anime object! {}");

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

	if (!json.contains("/attributes"_json_pointer)) {
		session.SetStatusBar(fmt::format(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(fmt::format(Strings::Translate("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(Strings::Translate("Kitsu: Failed to parse library object (missing service ID)!"));
		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(fmt::format(Strings::Translate("Kitsu: Failed to 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(Strings::Translate("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(Strings::Translate("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(Strings::Translate("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(Strings::Translate("Kitsu: Server returned bad data when trying to retrieve anime metadata!"));
		return false;
	}

	ParseMetadataJson(anime, json["/included"_json_pointer]);

	session.SetStatusBar(Strings::Translate("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(Strings::Translate("Kitsu: Failed to retrieve user ID!"));
		return false;
	}

	session.SetStatusBar(Strings::Translate("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