view src/services/anilist.cc @ 323:1686fac290c5

services/anilist: refactor HTTP requests...
author Paper <paper@paper.us.eu.org>
date Wed, 12 Jun 2024 22:48:16 -0400
parents 8141f409d52c
children 5d3c9b31aa6e
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" \
	"studios {\n" \
	"  edges {\n" \
	"    node {\n" \
	"      name\n" \
	"    }\n" \
	"  }\n" \
	"}\n" \
	"genres\n" \
	"episodes\n" \
	"duration\n" \
	"synonyms\n" \
	"description(asHtml: false)\n"

/* FIXME: why is this here */

static bool AccountIsValid() {
	const auto& auth = session.config.auth.anilist;
	return (auth.user_id && !auth.auth_token.empty());
}

static std::optional<nlohmann::json> SendJSONRequest(const nlohmann::json& data) {
	if (!AccountIsValid()) {
		session.SetStatusBar("AniList: Account isn't valid! (unauthorized?)");
		return std::nullopt;
	}

	const auto& auth = session.config.auth.anilist;

	const std::vector<std::string> headers = {
		"Authorization: Bearer " + auth.auth_token,
		"Accept: application/json",
		"Content-Type: application/json",
	};

	const std::string response = Strings::ToUtf8String(HTTP::Request("https://graphql.anilist.co", headers, data.dump(), HTTP::Type::Post));
	if (response.empty()) {
		session.SetStatusBar("AniList: JSON request returned an empty result!");
		return std::nullopt;
	}

	nlohmann::json out;

	try {
		out = nlohmann::json::parse(response);
	} catch (const std::exception& ex) {
		session.SetStatusBar("AniList: Failed to parse request JSON with error!");
		return std::nullopt;
	}

	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 std::nullopt;
	}

	return out;
}

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("/idMal"_json_pointer) && json["/idMal"_json_pointer].is_number())
		anime.SetServiceId(Anime::Service::MyAnimeList, Strings::ToUtf8String(json["/idMal"_json_pointer].get<int>()));

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

	{
		std::vector<std::string> producers;

		if (json.contains("/studios/edges"_json_pointer) && json["/studios/edges"_json_pointer].is_array())
			for (const auto& edge : json["/studios/edges"_json_pointer])
				if (edge.contains("/node/name"_json_pointer) && edge["/node/name"_json_pointer].is_string())
					producers.push_back(edge["/node/name"_json_pointer].get<std::string>());

		anime.SetProducers(producers);
	}

	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() {
	auto& auth = session.config.auth.anilist;

	/* 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 request = {
		{"query", query},
		{"variables", {
			{"id", auth.user_id}
		}}
	};
	// clang-format on

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

	const std::optional<nlohmann::json> response = SendJSONRequest(request);
	if (!response)
		return 0;

	Anime::db.RemoveAllUserData();

	const nlohmann::json& json = response.value();

	bool success = true;

	for (const auto& list : json["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

	const std::optional<nlohmann::json> response = SendJSONRequest(json);
	if (!response)
		return {};

	const nlohmann::json& result = response.value();

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

		const std::optional<nlohmann::json> res = SendJSONRequest(json);
		if (!res)
			return {};

		const nlohmann::json& result = res.value();

		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

	const std::optional<nlohmann::json> res = SendJSONRequest(json);
	if (!res)
		return 0;

	const nlohmann::json& result = res.value();

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

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

static int ParseUser(const nlohmann::json& json) {
	auto& auth = session.config.auth.anilist;

	return auth.user_id = JSON::GetNumber(json, "/id"_json_pointer, 0);
}

bool AuthorizeUser() {
	auto& auth = session.config.auth.anilist;

	/* 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;

	auth.auth_token = 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 */
	const std::optional<nlohmann::json> ret = SendJSONRequest(json);
	if (!ret)
		return 0;

	const nlohmann::json& result = ret.value();

	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