Mercurial > minori
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