view src/services/anilist.cc @ 250:c130f47f6f48

*: many many changes e.g. the search page is actually implemented now!
author Paper <paper@paper.us.eu.org>
date Sun, 04 Feb 2024 21:17:17 -0500
parents d030b30526d5
children 862d0d8619f6
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 <QDate>
#include <QByteArray>
#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::NOT_IN_LIST);
		return;
	}

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

std::string ListStatusToString(const Anime::Anime& anime) {
	if (anime.GetUserIsRewatching())
		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) {
	anime.SetNativeTitle(JSON::GetString<std::string>(json, "/native"_json_pointer, ""));
	anime.SetEnglishTitle(JSON::GetString<std::string>(json, "/english"_json_pointer, ""));
	anime.SetRomajiTitle(JSON::GetString<std::string>(json, "/romaji"_json_pointer, ""));
}

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));
	anime.SetSynopsis(Strings::TextifySynopsis(JSON::GetString<std::string>(json, "/description"_json_pointer, "")));

	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