view src/services/anilist.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 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 "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>

/* This file really sucks because it was made when I was first
 * really "learning" C++ */

using namespace nlohmann::literals::json_literals;

namespace Services {
namespace AniList {

static constexpr std::string_view CLIENT_ID = "13706";
#define MEDIA_FIELDS \
	"coverImage {\n" \
	"  large\n" \
	"}\n" \
	"id\n" \
	"title {\n" \
	"  romaji\n" \
	"  english\n" \
	"  native\n" \
	"}\n" \
	"format\n" \
	"status\n" \
	"averageScore\n" \
	"season\n" \
	"startDate {\n" \
	"  year\n" \
	"  month\n" \
	"  day\n" \
	"}\n" \
	"genres\n" \
	"episodes\n" \
	"duration\n" \
	"synonyms\n" \
	"description(asHtml: false)\n"

/* FIXME: why is this here */

static struct {
	int UserId() const { return session.config.auth.anilist.user_id; }
	void SetUserId(const int id) { session.config.auth.anilist.user_id = id; }

	std::string AuthToken() const { return session.config.auth.anilist.auth_token; }
	void SetAuthToken(const std::string& auth_token) { session.config.auth.anilist.auth_token = auth_token; }

	bool IsValid() const { return UserId() && !AuthToken().empty(); }
} account;

static std::string SendRequest(const std::string& data) {
	std::vector<std::string> headers = {"Authorization: Bearer " + account.AuthToken(), "Accept: application/json",
	                                    "Content-Type: application/json"};
	return Strings::ToUtf8String(HTTP::Request("https://graphql.anilist.co", headers, data, HTTP::Type::Post));
}

static bool SendJSONRequest(const nlohmann::json& data, nlohmann::json& out) {
	std::string request = SendRequest(data.dump());
	if (request.empty()) {
		session.SetStatusBar("AniList: JSON request returned an empty result!");
		return false;
	}

	out = nlohmann::json::parse(request, nullptr, false);
	if (out.is_discarded()) {
		session.SetStatusBar("AniList: Failed to parse request JSON!");
		return false;
	}

	if (out.contains("/errors"_json_pointer) && out.at("/errors"_json_pointer).is_array()) {
		for (const auto& error : out.at("/errors"_json_pointer))
			std::cerr << "AniList: Received an error in response: "
			          << JSON::GetString<std::string>(error, "/message"_json_pointer, "") << std::endl;

		session.SetStatusBar("AniList: Received an error in response!");
		return false;
	}

	return true;
}

static void ParseListStatus(std::string status, Anime::Anime& anime) {
	static const std::unordered_map<std::string, Anime::ListStatus> map = {
	    {"CURRENT",   Anime::ListStatus::Current  },
	    {"PLANNING",  Anime::ListStatus::Planning },
	    {"COMPLETED", Anime::ListStatus::Completed},
	    {"DROPPED",   Anime::ListStatus::Dropped  },
	    {"PAUSED",    Anime::ListStatus::Paused   }
    };

	if (status == "REPEATING") {
		anime.SetUserIsRewatching(true);
		anime.SetUserStatus(Anime::ListStatus::Current);
		return;
	}

	if (map.find(status) == map.end()) {
		anime.SetUserStatus(Anime::ListStatus::NotInList);
		return;
	}

	anime.SetUserStatus(map.at(status));
}

static std::string ListStatusToString(const Anime::Anime& anime) {
	if (anime.GetUserIsRewatching() && anime.GetUserStatus() == Anime::ListStatus::Current)
		return "REWATCHING";

	switch (anime.GetUserStatus()) {
		case Anime::ListStatus::Planning: return "PLANNING";
		case Anime::ListStatus::Completed: return "COMPLETED";
		case Anime::ListStatus::Dropped: return "DROPPED";
		case Anime::ListStatus::Paused: return "PAUSED";
		default: break;
	}
	return "CURRENT";
}

static void ParseTitle(const nlohmann::json& json, Anime::Anime& anime) {
	static const std::unordered_map<Anime::TitleLanguage, nlohmann::json::json_pointer> map = {
		{Anime::TitleLanguage::Native, "/native"_json_pointer},
		{Anime::TitleLanguage::English, "/english"_json_pointer},
		{Anime::TitleLanguage::Romaji, "/romaji"_json_pointer},
	};

	for (const auto& [language, ptr] : map)
		if (json.contains(ptr) && json[ptr].is_string())
			anime.SetTitle(language, json[ptr]);
}

static int ParseMediaJson(const nlohmann::json& json) {
	if (!json.contains("/id"_json_pointer) || !json["/id"_json_pointer].is_number()) {
		session.SetStatusBar("AniList: Failed to parse anime object!");
		return 0;
	}

	std::string service_id = Strings::ToUtf8String(json["/id"_json_pointer].get<int>());

	int id = Anime::db.LookupServiceIdOrUnused(Anime::Service::AniList, service_id);
	if (!id) {
		session.SetStatusBar("AniList: Failed to parse anime object!");
		return 0;
	}

	Anime::Anime& anime = Anime::db.items[id];
	anime.SetId(id);
	anime.SetServiceId(Anime::Service::AniList, service_id);

	if (json.contains("/id_mal"_json_pointer))
		anime.SetServiceId(Anime::Service::MyAnimeList, json["/id_mal"_json_pointer].get<std::string>());

	ParseTitle(json.at("/title"_json_pointer), anime);

	anime.SetEpisodes(JSON::GetNumber(json, "/episodes"_json_pointer, 0));
	anime.SetFormat(Translate::AniList::ToSeriesFormat(JSON::GetString<std::string>(json, "/format"_json_pointer, "")));

	anime.SetAiringStatus(
	    Translate::AniList::ToSeriesStatus(JSON::GetString<std::string>(json, "/status"_json_pointer, "")));

	anime.SetAirDate(Date(json["/startDate"_json_pointer]));

	anime.SetPosterUrl(JSON::GetString<std::string>(json, "/coverImage/large"_json_pointer, ""));

	anime.SetAudienceScore(JSON::GetNumber(json, "/averageScore"_json_pointer, 0));
	// anime.SetSeason(Translate::AniList::ToSeriesSeason(JSON::GetString<std::string>(json, "/season"_json_pointer, "")));
	anime.SetDuration(JSON::GetNumber(json, "/duration"_json_pointer, 0));

	std::string synopsis = JSON::GetString<std::string>(json, "/description"_json_pointer, "");
	Strings::TextifySynopsis(synopsis);
	anime.SetSynopsis(synopsis);

	anime.SetGenres(JSON::GetArray<std::vector<std::string>>(json, "/genres"_json_pointer, {}));
	anime.SetTitleSynonyms(JSON::GetArray<std::vector<std::string>>(json, "/synonyms"_json_pointer, {}));

	return id;
}

static int ParseListItem(const nlohmann::json& json) {
	int id = ParseMediaJson(json["/media"_json_pointer]);
	if (!id)
		return 0;

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

	anime.AddToUserList();

	anime.SetUserScore(JSON::GetNumber(json, "/score"_json_pointer, 0));
	anime.SetUserProgress(JSON::GetNumber(json, "/progress"_json_pointer, 0));
	ParseListStatus(JSON::GetString<std::string>(json, "/status"_json_pointer, ""), anime);
	anime.SetUserNotes(JSON::GetString<std::string>(json, "/notes"_json_pointer, ""));

	anime.SetUserDateStarted(Date(json["/startedAt"_json_pointer]));
	anime.SetUserDateCompleted(Date(json["/completedAt"_json_pointer]));

	anime.SetUserTimeUpdated(JSON::GetNumber(json, "/updatedAt"_json_pointer, 0));

	return id;
}

static bool ParseList(const nlohmann::json& json) {
	bool success = true;

	for (const auto& entry : json["entries"].items())
		if (!ParseListItem(entry.value()))
			success = false;

	return success;
}

int GetAnimeList() {
	if (!account.IsValid()) {
		session.SetStatusBar("AniList: Account isn't valid! (unauthorized?)");
		return 0;
	}

	session.SetStatusBar("AniList: Retrieving anime list...");

	/* NOTE: these really ought to be in the qrc file */
	constexpr std::string_view query = "query ($id: Int) {\n"
	                                   "  MediaListCollection (userId: $id, type: ANIME) {\n"
	                                   "    lists {\n"
	                                   "      name\n"
	                                   "      entries {\n"
	                                   "        score\n"
	                                   "        notes\n"
	                                   "        status\n"
	                                   "        progress\n"
	                                   "        startedAt {\n"
	                                   "          year\n"
	                                   "          month\n"
	                                   "          day\n"
	                                   "        }\n"
	                                   "        completedAt {\n"
	                                   "          year\n"
	                                   "          month\n"
	                                   "          day\n"
	                                   "        }\n"
	                                   "        updatedAt\n"
	                                   "        media {\n"
	                                   MEDIA_FIELDS
	                                   "        }\n"
	                                   "      }\n"
	                                   "    }\n"
	                                   "  }\n"
	                                   "}\n";
	// clang-format off
	nlohmann::json json = {
		{"query", query},
		{"variables", {
			{"id", account.UserId()}
		}}
	};
	// clang-format on

	session.SetStatusBar("AniList: Parsing anime list...");

	nlohmann::json result;
	const bool res = SendJSONRequest(json, result);
	if (!res)
		return 0;

	bool success = true;

	Anime::db.RemoveAllUserData();

	for (const auto& list : result["data"]["MediaListCollection"]["lists"].items())
		if (!ParseList(list.value()))
			success = false;

	if (success)
		session.SetStatusBar("AniList: Retrieved anime list successfully!");

	return 1;
}

/* return is a vector of anime ids */
std::vector<int> Search(const std::string& search) {
	constexpr std::string_view query = "query ($search: String) {\n"
	                                   "  Page (page: 1, perPage: 50) {\n"
	                                   "    media (search: $search, type: ANIME) {\n"
	                                   MEDIA_FIELDS
	                                   "    }\n"
	                                   "  }\n"
	                                   "}\n";

	// clang-format off
	nlohmann::json json = {
		{"query", query},
		{"variables", {
			{"search", search}
		}}
	};
	// clang-format on

	nlohmann::json result;
	const bool res = SendJSONRequest(json, result);
	if (!res)
		return {};

	/* FIXME: error handling here */
	std::vector<int> ret;
	ret.reserve(result["/data/Page/media"_json_pointer].size());

	for (const auto& media : result["/data/Page/media"_json_pointer].items())
		ret.push_back(ParseMediaJson(media.value()));

	return ret;
}

std::vector<int> GetSeason(Anime::SeriesSeason season, Date::Year year) {
	constexpr std::string_view query = "query ($season: MediaSeason!, $season_year: Int!, $page: Int) {\n"
	                                   "  Page(page: $page) {\n"
	                                   "    media(season: $season, seasonYear: $season_year, type: ANIME, sort: START_DATE) {\n"
	                                   MEDIA_FIELDS
	                                   "    }\n"
	                                   "    pageInfo {\n"
	                                   "      total\n"
	                                   "      perPage\n"
	                                   "      currentPage\n"
	                                   "      lastPage\n"
	                                   "      hasNextPage\n"
	                                   "    }\n"
	                                   "  }\n"
	                                   "}\n";
	std::vector<int> ret;

	int page = 0;
	bool has_next_page = true;
	while (has_next_page) {
		nlohmann::json json = {
			{"query", query},
			{"variables", {
				{"season", Translate::AniList::ToString(season)},
				{"season_year", Strings::ToUtf8String(year)},
				{"page", page}
			}}
		};

		nlohmann::json result;
		const bool res = SendJSONRequest(json, result);
		if (!res)
			return {};

		ret.reserve(ret.capacity() + result["data"]["Page"]["media"].size());

		for (const auto& media : result["data"]["Page"]["media"].items())
			ret.push_back(ParseMediaJson(media.value()));

		has_next_page = JSON::GetBoolean(result, "/data/Page/pageInfo/hasNextPage"_json_pointer, false);
		if (has_next_page)
			page++;
	}

	return ret;
}

int UpdateAnimeEntry(int id) {
	/**
	 * possible values:
	 *
	 * int mediaId,
	 * MediaListStatus status,
	 * float score,
	 * int scoreRaw,
	 * int progress,
	 * int progressVolumes, // manga-specific.
	 * int repeat, // rewatch
	 * int priority,
	 * bool private,
	 * string notes,
	 * bool hiddenFromStatusLists,
	 * string[] customLists,
	 * float[] advancedScores,
	 * Date startedAt,
	 * Date completedAt
	 **/
	Anime::Anime& anime = Anime::db.items[id];
	if (!anime.IsInUserList())
		return 0;

	std::optional<std::string> service_id = anime.GetServiceId(Anime::Service::AniList);
	if (!service_id)
		return 0;

	session.SetStatusBar("AniList: Updating anime entry...");

	constexpr std::string_view query =
	    "mutation ($media_id: Int, $progress: Int, $status: MediaListStatus, $score: Int, $notes: String, $start: "
	    "FuzzyDateInput, $comp: FuzzyDateInput, $repeat: Int) {\n"
	    "  SaveMediaListEntry (mediaId: $media_id, progress: $progress, status: $status, scoreRaw: $score, notes: "
	    "$notes, startedAt: $start, completedAt: $comp, repeat: $repeat) {\n"
	    "    id\n"
	    "  }\n"
	    "}\n";
	// clang-format off
	nlohmann::json json = {
		{"query", query},
		{"variables", {
			{"media_id", Strings::ToInt<int64_t>(service_id.value())},
			{"progress", anime.GetUserProgress()},
			{"status",   ListStatusToString(anime)},
			{"score",    anime.GetUserScore()},
			{"notes",    anime.GetUserNotes()},
			{"start",    anime.GetUserDateStarted().GetAsAniListJson()},
			{"comp",     anime.GetUserDateCompleted().GetAsAniListJson()},
			{"repeat",   anime.GetUserRewatchedTimes()}
		}}
	};
	// clang-format on

	nlohmann::json result;
	const bool ret = SendJSONRequest(json, result);
	if (!ret)
		return 0;

	session.SetStatusBar("AniList: Anime entry updated successfully!");

	return JSON::GetNumber(result, "/data/SaveMediaListEntry/id"_json_pointer, 0);
}

static int ParseUser(const nlohmann::json& json) {
	account.SetUserId(JSON::GetNumber(json, "/id"_json_pointer, 0));
	return account.UserId();
}

bool AuthorizeUser() {
	/* Prompt for PIN */
	QDesktopServices::openUrl(QUrl(Strings::ToQString("https://anilist.co/api/v2/oauth/authorize?client_id=" +
	                                                  std::string(CLIENT_ID) + "&response_type=token")));

	bool ok;
	QString token = QInputDialog::getText(
	    0, "Credentials needed!", "Please enter the code given to you after logging in to AniList:", QLineEdit::Normal,
	    "", &ok);

	if (!ok || token.isEmpty())
		return false;

	account.SetAuthToken(Strings::ToUtf8String(token));

	session.SetStatusBar("AniList: Requesting user ID...");

	constexpr std::string_view query = "query {\n"
	                                   "  Viewer {\n"
	                                   "    id\n"
	                                   "  }\n"
	                                   "}\n";
	nlohmann::json json = {
	    {"query", query}
    };

    /* SendJSONRequest handles status errors */
    nlohmann::json result;
	const bool ret = SendJSONRequest(json, result);
	if (!ret)
		return 0;

	if (ParseUser(result["data"]["Viewer"]))
		session.SetStatusBar("AniList: Successfully retrieved user data!");
	else
		session.SetStatusBar("AniList: Failed to retrieve user ID!");
	
	return true;
}

} // namespace AniList
} // namespace Services