view src/services/anilist.cpp @ 11:fc1bf97c528b

*: use C++11 standard I've been meaning to do this for a while, but I didn't want to reimplement the filesystem code. Now we are on C++11 and most compilers from the past 5 centuries should support this now
author Paper <mrpapersonic@gmail.com>
date Sun, 17 Sep 2023 06:14:30 -0400
parents 4b198a111713
children cde8f67a7c7d
line wrap: on
line source

#include "services/anilist.h"
#include "core/anime.h"
#include "core/anime_db.h"
#include "core/config.h"
#include "core/json.h"
#include "core/session.h"
#include "core/strings.h"
#include <QDesktopServices>
#include <QInputDialog>
#include <QLineEdit>
#include <QMessageBox>
#include <QUrl>
#include <chrono>
#include <curl/curl.h>
#include <exception>
#include <format>
#define CLIENT_ID "13706"

using nlohmann::literals::operator "" _json_pointer;

namespace Services::AniList {

class Account {
	public:
		std::string Username() const { return session.config.anilist.username; }
		void SetUsername(std::string const& username) { session.config.anilist.username = username; }

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

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

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

static Account account;

static size_t CurlWriteCallback(void* contents, size_t size, size_t nmemb, void* userdata) {
	((std::string*)userdata)->append((char*)contents, size * nmemb);
	return size * nmemb;
}

/* A wrapper around cURL to send requests to AniList */
std::string SendRequest(std::string data) {
	struct curl_slist* list = NULL;
	std::string userdata;
	CURL* curl = curl_easy_init();
	if (curl) {
		list = curl_slist_append(list, "Accept: application/json");
		list = curl_slist_append(list, "Content-Type: application/json");
		std::string bearer = "Authorization: Bearer " + account.AuthToken();
		list = curl_slist_append(list, bearer.c_str());
		curl_easy_setopt(curl, CURLOPT_URL, "https://graphql.anilist.co");
		curl_easy_setopt(curl, CURLOPT_POSTFIELDS, data.c_str());
		curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list);
		curl_easy_setopt(curl, CURLOPT_WRITEDATA, &userdata);
		curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, &CurlWriteCallback);
		/* Use system certs... useful on Windows. */
		curl_easy_setopt(curl, CURLOPT_SSL_OPTIONS, CURLSSLOPT_NATIVE_CA);
		CURLcode res = curl_easy_perform(curl);
		curl_slist_free_all(list);
		curl_easy_cleanup(curl);
		if (res != CURLE_OK) {
			QMessageBox box(QMessageBox::Icon::Critical, "",
							QString("curl_easy_perform(curl) failed!: ") + QString(curl_easy_strerror(res)));
			box.exec();
			return "";
		}
		return userdata;
	}
	return "";
}

/* TODO: Move to Translate */

std::map<std::string, Anime::ListStatus> AniListStringToAnimeWatchingMap = {
	{"CURRENT",	Anime::ListStatus::CURRENT  },
	  {"PLANNING",  Anime::ListStatus::PLANNING },
	  {"COMPLETED", Anime::ListStatus::COMPLETED},
	{"DROPPED",	Anime::ListStatus::DROPPED  },
	  {"PAUSED",	 Anime::ListStatus::PAUSED   },
	  {"REPEATING", Anime::ListStatus::CURRENT}
};

std::map<Anime::ListStatus, std::string> AniListAnimeWatchingToStringMap = {
	{Anime::ListStatus::CURRENT,	  "CURRENT"  },
	  {Anime::ListStatus::PLANNING,	 "PLANNING" },
	  {Anime::ListStatus::COMPLETED, "COMPLETED"},
	{Anime::ListStatus::DROPPED,	  "DROPPED"  },
	  {Anime::ListStatus::PAUSED,	   "PAUSED"   }
};

std::map<std::string, Anime::SeriesStatus> AniListStringToAnimeAiringMap = {
	{"FINISHED",		 Anime::SeriesStatus::FINISHED		 },
	{"RELEASING",		  Anime::SeriesStatus::RELEASING	   },
	{"NOT_YET_RELEASED", Anime::SeriesStatus::NOT_YET_RELEASED},
	{"CANCELLED",		  Anime::SeriesStatus::CANCELLED	   },
	{"HIATUS",		   Anime::SeriesStatus::HIATUS			 }
};

std::map<std::string, Anime::SeriesSeason> AniListStringToAnimeSeasonMap = {
	{"WINTER", Anime::SeriesSeason::WINTER},
	{"SPRING", Anime::SeriesSeason::SPRING},
	{"SUMMER", Anime::SeriesSeason::SUMMER},
	{"FALL",	 Anime::SeriesSeason::FALL	 }
};

std::map<std::string, enum Anime::SeriesFormat> AniListStringToAnimeFormatMap = {
	{"TV",	   Anime::SeriesFormat::TV		 },
	  {"TV_SHORT", Anime::SeriesFormat::TV_SHORT},
	  {"MOVIE",	Anime::SeriesFormat::MOVIE	 },
	{"SPECIAL",	Anime::SeriesFormat::SPECIAL },
	  {"OVA",	  Anime::SeriesFormat::OVA	 },
	{"ONA",		Anime::SeriesFormat::ONA	   },
	  {"MUSIC",	Anime::SeriesFormat::MUSIC	 },
	  {"MANGA",	Anime::SeriesFormat::MANGA	 },
	{"NOVEL",	  Anime::SeriesFormat::NOVEL   },
	  {"ONE_SHOT", Anime::SeriesFormat::ONE_SHOT}
};

Date ParseDate(const nlohmann::json& json) {
	Date date;
	if (json.contains("/year"_json_pointer) && json.at("/year"_json_pointer).is_number())
		date.SetYear(JSON::GetInt(json, "/year"_json_pointer));
	else
		date.VoidYear();

	if (json.contains("/month"_json_pointer) && json.at("/month"_json_pointer).is_number())
		date.SetMonth(JSON::GetInt(json, "/month"_json_pointer));
	else
		date.VoidMonth();

	if (json.contains("/day"_json_pointer) && json.at("/day"_json_pointer).is_number())
		date.SetDay(JSON::GetInt(json, "/day"_json_pointer));
	else
		date.VoidDay();
	return date;
}

void ParseTitle(const nlohmann::json& json, Anime::Anime& anime) {
	anime.SetNativeTitle(JSON::GetString(json, "/native"_json_pointer));
	anime.SetEnglishTitle(JSON::GetString(json, "/english"_json_pointer));
	anime.SetRomajiTitle(JSON::GetString(json, "/romaji"_json_pointer));
}

int ParseMediaJson(const nlohmann::json& json) {
	int id = JSON::GetInt(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::GetInt(json, "/episodes"_json_pointer));
	anime.SetFormat(AniListStringToAnimeFormatMap[JSON::GetString(json, "/format"_json_pointer)]);

	anime.SetAiringStatus(AniListStringToAnimeAiringMap[JSON::GetString(json, "/status"_json_pointer)]);

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

	anime.SetAudienceScore(JSON::GetInt(json, "/averageScore"_json_pointer));
	anime.SetSeason(AniListStringToAnimeSeasonMap[JSON::GetString(json, "/season"_json_pointer)]);
	anime.SetDuration(JSON::GetInt(json, "/duration"_json_pointer));
	anime.SetSynopsis(Strings::TextifySynopsis(JSON::GetString(json, "/description"_json_pointer)));

	if (json.contains("/genres"_json_pointer) && json["/genres"_json_pointer].is_array())
		anime.SetGenres(json["/genres"_json_pointer].get<std::vector<std::string>>());
	if (json.contains("/synonyms"_json_pointer) && json["/synonyms"_json_pointer].is_array())
		anime.SetTitleSynonyms(json["/synonyms"_json_pointer].get<std::vector<std::string>>());
	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::GetInt(json, "/score"_json_pointer));
	anime.SetUserProgress(JSON::GetInt(json, "/progress"_json_pointer));
	anime.SetUserStatus(AniListStringToAnimeWatchingMap[JSON::GetString(json, "/status"_json_pointer)]);
	anime.SetUserNotes(JSON::GetString(json, "/notes"_json_pointer));

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

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

	return id;
}

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

int GetAnimeList() {
	/* NOTE: these should be in the qrc file */
	const std::string 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"
							  "          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
	/* TODO: do a try catch here, catch any json errors and then call
	   Authorize() if needed */
	auto res = nlohmann::json::parse(SendRequest(json.dump()));
	/* TODO: make sure that we actually need the wstring converter and see
	   if we can just get wide strings back from nlohmann::json */
	for (const auto& list : res["data"]["MediaListCollection"]["lists"].items()) {
		ParseList(list.value());
	}
	return 1;
}

int UpdateAnimeEntry(const Anime::Anime& anime) {
	/**
	 * possible values:
	 * 
	 * int mediaId,
	 * MediaListStatus status,
	 * float score,
	 * int scoreRaw,
	 * int progress,
	 * int progressVolumes,
	 * int repeat,
	 * int priority,
	 * bool private,
	 * string notes,
	 * bool hiddenFromStatusLists,
	 * string[] customLists,
	 * float[] advancedScores,
	 * Date startedAt,
	 * Date completedAt
	**/
	const std::string query =
		"mutation ($media_id: Int, $progress: Int, $status: MediaListStatus, $score: Int, $notes: String) {\n"
		"  SaveMediaListEntry (mediaId: $media_id, progress: $progress, status: $status, scoreRaw: $score, notes: "
		"$notes) {\n"
		"    id\n"
		"  }\n"
		"}\n";
	// clang-format off
	nlohmann::json json = {
		{"query", query},
		{"variables", {
			{"media_id", anime.GetId()},
			{"progress", anime.GetUserProgress()},
			{"status",   AniListAnimeWatchingToStringMap[anime.GetUserStatus()]},
			{"score",    anime.GetUserScore()},
			{"notes",    anime.GetUserNotes()}
		}}
	};
	// clang-format on
	SendRequest(json.dump());
	return 1;
}

int ParseUser(const nlohmann::json& json) {
	account.SetUsername(JSON::GetString(json, "/name"_json_pointer));
	account.SetUserId(JSON::GetInt(json, "/id"_json_pointer));
	return account.UserId();
}

int AuthorizeUser() {
	/* Prompt for PIN */
	QDesktopServices::openUrl(
		QUrl("https://anilist.co/api/v2/oauth/authorize?client_id=" 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())
		account.SetAuthToken(token.toStdString());
	else { // fail
		return 0;
	}
	const std::string query = "query {\n"
							  "  Viewer {\n"
							  "    id\n"
							  "    name\n"
							  "    mediaListOptions {\n"
							  "      scoreFormat\n"
							  "    }\n"
							  "  }\n"
							  "}\n";
	nlohmann::json json = {
		{"query", query}
	};
	auto ret = nlohmann::json::parse(SendRequest(json.dump()));
	ParseUser(json["Viewer"]);
	return 1;
}

} // namespace Services::AniList