view src/services/anilist.cc @ 284:e66ffc338d82

anime: refactor title structure to a map
author Paper <paper@paper.us.eu.org>
date Wed, 08 May 2024 16:21:05 -0400
parents 657fda1b9cac
children 53e3c015a973
line wrap: on
line source

#include "services/anilist.h"
#include "core/anime.h"
#include "core/anime_db.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 <iostream>

using namespace nlohmann::literals::json_literals;

namespace Services {
namespace AniList {

constexpr int CLIENT_ID = 13706;

class Account {
public:
	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(std::string const& auth_token) { session.config.auth.anilist.auth_token = auth_token; }

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

static Account account;

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

nlohmann::json SendJSONRequest(nlohmann::json data) {
	std::string request = SendRequest(data.dump());
	if (request.empty()) {
		std::cerr << "[AniList] JSON Request returned an empty result!" << std::endl;
		return {};
	}

	auto ret = nlohmann::json::parse(request, nullptr, false);
	if (ret.is_discarded()) {
		std::cerr << "[AniList] Failed to parse request JSON!" << std::endl;
		return {};
	}

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

		return {};
	}

	return ret;
}

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

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

void ParseTitle(const nlohmann::json& json, Anime::Anime& anime) {
	nlohmann::json::json_pointer g = "/native"_json_pointer;
	if (json.contains(g) && json[g].is_string())
		anime.SetTitle(Anime::TitleLanguage::Native, json[g]);

	g = "/english"_json_pointer;
	if (json.contains(g) && json[g].is_string())
		anime.SetTitle(Anime::TitleLanguage::English, json[g]);

	g = "/romaji"_json_pointer;
	if (json.contains(g) && json[g].is_string())
		anime.SetTitle(Anime::TitleLanguage::Romaji, json[g]);
}

int ParseMediaJson(const nlohmann::json& json) {
	int id = JSON::GetNumber(json, "/id"_json_pointer);
	if (!id)
		return 0;

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

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

int ParseListItem(const nlohmann::json& json) {
	int id = ParseMediaJson(json["media"]);

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

int ParseList(const nlohmann::json& json) {
	for (const auto& entry : json["entries"].items()) {
		ParseListItem(entry.value());
	}
	return 1;
}

int GetAnimeList() {
	if (!account.IsValid()) {
		std::cerr << "AniList: Account isn't valid!" << std::endl;
		return 0;
	}

	/* 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"
	                                   "          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"
	                                   "        }\n"
	                                   "      }\n"
	                                   "    }\n"
	                                   "  }\n"
	                                   "}\n";
	// clang-format off
	nlohmann::json json = {
		{"query", query},
		{"variables", {
			{"id", account.UserId()}
		}}
	};
	// clang-format on

	auto res = SendJSONRequest(json);

	for (const auto& list : res["data"]["MediaListCollection"]["lists"].items()) {
		ParseList(list.value());
	}
	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"
	                                   "      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"
	                                   "    }\n"
	                                   "  }\n"
	                                   "}\n";

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

	auto res = SendJSONRequest(json);

	std::vector<int> ret;
	ret.reserve(res["data"]["Page"]["media"].size());

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

	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;

	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", anime.GetId()},
			{"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

	auto ret = SendJSONRequest(json);

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

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=" +
	                                                  Strings::ToUtf8String(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));

	constexpr std::string_view query = "query {\n"
	                                   "  Viewer {\n"
	                                   "    id\n"
	                                   "    name\n"
	                                   "    mediaListOptions {\n"
	                                   "      scoreFormat\n" // this will be used... eventually
	                                   "    }\n"
	                                   "  }\n"
	                                   "}\n";
	nlohmann::json json = {
	    {"query", query}
    };

	auto ret = SendJSONRequest(json);

	ParseUser(ret["data"]["Viewer"]);
	return true;
}

} // namespace AniList
} // namespace Services