changeset 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 3b355fa948c7
children 1b5c04268d6a
files include/core/anime_db.h include/core/date.h include/core/session.h include/core/time.h include/services/services.h src/core/anime_db.cc src/core/date.cc src/core/http.cc src/core/session.cc src/core/time.cc src/gui/dialog/settings/services.cc src/gui/window.cc src/library/library.cc src/services/anilist.cc src/services/kitsu.cc
diffstat 15 files changed, 303 insertions(+), 99 deletions(-) [+]
line wrap: on
line diff
--- a/include/core/anime_db.h	Wed Jun 12 05:25:41 2024 -0400
+++ b/include/core/anime_db.h	Wed Jun 12 17:52:26 2024 -0400
@@ -11,26 +11,29 @@
 class Database {
 public:
 	std::unordered_map<int, Anime> items;
-	size_t GetTotalAnimeAmount();
-	size_t GetTotalEpisodeAmount();
-	size_t GetTotalWatchedAmount();
-	size_t GetTotalPlannedAmount();
-	double GetAverageScore();
-	double GetScoreDeviation();
-	size_t GetListsAnimeAmount(ListStatus status);
-	int GetAnimeFromTitle(const std::string& title);
+	size_t GetTotalAnimeAmount() const;
+	size_t GetTotalEpisodeAmount() const;
+	size_t GetTotalWatchedAmount() const;
+	size_t GetTotalPlannedAmount() const;
+	double GetAverageScore() const;
+	double GetScoreDeviation() const;
+	size_t GetListsAnimeAmount(ListStatus status) const;
+	int LookupAnimeTitle(const std::string& title) const;
 
-	bool GetDatabaseAsJSON(nlohmann::json& json);
-	bool SaveDatabaseToDisk();
+	bool GetDatabaseAsJSON(nlohmann::json& json) const;
+	bool SaveDatabaseToDisk() const;
 
 	bool ParseDatabaseJSON(const nlohmann::json& json);
 	bool LoadDatabaseFromDisk();
 
 	/* These are here to make sure that our service IDs don't collide
 	 * and make the whole thing go boom. */
-	int GetUnusedId();
-	int LookupServiceId(Service service, const std::string& id_to_find);
-	int LookupServiceIdOrUnused(Service service, const std::string& id_to_find);
+	int GetUnusedId() const;
+	int LookupServiceId(Service service, const std::string& id_to_find) const;
+	int LookupServiceIdOrUnused(Service service, const std::string& id_to_find) const;
+
+	/* when syncing we don't want to keep deleted anime */
+	void RemoveAllUserData();
 };
 
 extern Database db;
--- a/include/core/date.h	Wed Jun 12 05:25:41 2024 -0400
+++ b/include/core/date.h	Wed Jun 12 17:52:26 2024 -0400
@@ -3,6 +3,8 @@
 
 #include "json/json_fwd.hpp"
 
+#include "core/time.h"
+
 #include <optional>
 #include <string>
 
@@ -37,6 +39,7 @@
 	Date(const std::string& str);
 	Date(const QDate& date);
 	Date(const nlohmann::json& json);
+	Date(Time::Timestamp timestamp);
 	bool IsValid() const;
 	void SetYear(Year y);
 	void SetMonth(Month m);
--- a/include/core/session.h	Wed Jun 12 05:25:41 2024 -0400
+++ b/include/core/session.h	Wed Jun 12 17:52:26 2024 -0400
@@ -1,6 +1,7 @@
 #ifndef MINORI_CORE_SESSION_H_
 #define MINORI_CORE_SESSION_H_
 
+#include "core/time.h"
 #include "core/config.h"
 #include "gui/locale.h"
 
@@ -11,6 +12,7 @@
 
 #include <atomic>
 #include <string>
+#include <random>
 
 class MainWindow;
 
@@ -31,6 +33,7 @@
 
 	Config config;
 	static constexpr semver::version version{PACKAGE_VERSION};
+	std::mt19937 gen;
 
 signals:
 	void StatusBarChange(const std::string& message);
--- a/include/core/time.h	Wed Jun 12 05:25:41 2024 -0400
+++ b/include/core/time.h	Wed Jun 12 17:52:26 2024 -0400
@@ -19,6 +19,8 @@
 /* in UTC */
 Timestamp GetSystemTime();
 
+Timestamp ParseISO8601Time(const std::string& str);
+
 }; // namespace Time
 
 #endif // MINORI_CORE_TIME_H_
\ No newline at end of file
--- a/include/services/services.h	Wed Jun 12 05:25:41 2024 -0400
+++ b/include/services/services.h	Wed Jun 12 17:52:26 2024 -0400
@@ -9,6 +9,8 @@
 
 namespace Services {
 
+/* TODO: need to limit these to one thread (or put a mutex on the anime db) */
+
 void Synchronize();
 std::vector<int> Search(const std::string& search);
 std::vector<int> GetSeason(Anime::SeriesSeason season, Date::Year year);
--- a/src/core/anime_db.cc	Wed Jun 12 05:25:41 2024 -0400
+++ b/src/core/anime_db.cc	Wed Jun 12 17:52:26 2024 -0400
@@ -2,6 +2,7 @@
 #include "core/anime.h"
 #include "core/filesystem.h"
 #include "core/json.h"
+#include "core/session.h"
 #include "core/strings.h"
 
 #include "gui/translate/anilist.h"
@@ -18,7 +19,7 @@
 
 namespace Anime {
 
-size_t Database::GetTotalAnimeAmount() {
+size_t Database::GetTotalAnimeAmount() const {
 	size_t total = 0;
 
 	for (const auto& [id, anime] : items)
@@ -28,7 +29,7 @@
 	return total;
 }
 
-size_t Database::GetListsAnimeAmount(ListStatus status) {
+size_t Database::GetListsAnimeAmount(ListStatus status) const {
 	if (status == ListStatus::NotInList)
 		return 0;
 
@@ -41,7 +42,7 @@
 	return total;
 }
 
-size_t Database::GetTotalEpisodeAmount() {
+size_t Database::GetTotalEpisodeAmount() const {
 	size_t total = 0;
 
 	for (const auto& [id, anime] : items)
@@ -52,7 +53,7 @@
 }
 
 /* Returns the total watched amount in minutes. */
-size_t Database::GetTotalWatchedAmount() {
+size_t Database::GetTotalWatchedAmount() const {
 	size_t total = 0;
 
 	for (const auto& [id, anime] : items)
@@ -68,7 +69,7 @@
    amount of episodes, as AniList will let you
    set episode counts up to 32768. But that should
    rather be handled elsewhere. */
-size_t Database::GetTotalPlannedAmount() {
+size_t Database::GetTotalPlannedAmount() const {
 	size_t total = 0;
 
 	for (const auto& [id, anime] : items)
@@ -81,7 +82,7 @@
 /* In Taiga this is called the mean, but "average" is
    what's primarily used in conversation, at least
    in the U.S. */
-double Database::GetAverageScore() {
+double Database::GetAverageScore() const {
 	double avg = 0;
 	size_t amt = 0;
 
@@ -94,7 +95,7 @@
 	return avg / amt;
 }
 
-double Database::GetScoreDeviation() {
+double Database::GetScoreDeviation() const {
 	double squares_sum = 0, avg = GetAverageScore();
 	size_t amt = 0;
 
@@ -108,11 +109,7 @@
 	return (amt > 0) ? std::sqrt(squares_sum / amt) : 0;
 }
 
-/*
- * TODO: separate this from the anime DB,
- * provide *some* sort of normalization
- */
-int Database::GetAnimeFromTitle(const std::string& title) {
+int Database::LookupAnimeTitle(const std::string& title) const {
 	if (title.empty())
 		return 0;
 
@@ -194,7 +191,7 @@
 	return true;
 }
 
-bool Database::GetDatabaseAsJSON(nlohmann::json& json) {
+bool Database::GetDatabaseAsJSON(nlohmann::json& json) const {
 	for (const auto& [id, anime] : items) {
 		nlohmann::json anime_json = {};
 		GetAnimeAsJSON(anime, anime_json);
@@ -204,7 +201,7 @@
 	return true;
 }
 
-bool Database::SaveDatabaseToDisk() {
+bool Database::SaveDatabaseToDisk() const {
 	std::filesystem::path db_path = Filesystem::GetAnimeDBPath();
 	Filesystem::CreateDirectories(db_path);
 
@@ -302,22 +299,18 @@
 	return true;
 }
 
-int Database::GetUnusedId() {
-	/* TODO: move these out of here */
-
-	std::random_device rd;
-	std::mt19937 gen(rd());
+int Database::GetUnusedId() const {
 	std::uniform_int_distribution<int> distrib(1, INT_MAX);
 	int res;
 
 	do {
-		res = distrib(gen);
-	} while (items.count(res));
+		res = distrib(session.gen);
+	} while (items.count(res) && !res);
 
 	return res;
 }
 
-int Database::LookupServiceId(Service service, const std::string& id_to_find) {
+int Database::LookupServiceId(Service service, const std::string& id_to_find) const {
 	for (const auto& [id, anime] : items) {
 		std::optional<std::string> service_id = anime.GetServiceId(service);
 		if (!service_id)
@@ -330,7 +323,7 @@
 	return 0;
 }
 
-int Database::LookupServiceIdOrUnused(Service service, const std::string& id_to_find) {
+int Database::LookupServiceIdOrUnused(Service service, const std::string& id_to_find) const {
 	int id = LookupServiceId(service, id_to_find);
 	if (id)
 		return id;
@@ -338,6 +331,13 @@
 	return GetUnusedId();
 }
 
+void Database::RemoveAllUserData() {
+	for (auto& [id, anime] : items) {
+		if (anime.IsInUserList())
+			anime.RemoveFromUserList();
+	}
+}
+
 Database db;
 
 } // namespace Anime
--- a/src/core/date.cc	Wed Jun 12 05:25:41 2024 -0400
+++ b/src/core/date.cc	Wed Jun 12 17:52:26 2024 -0400
@@ -1,4 +1,5 @@
 #include "core/date.h"
+#include "core/time.h"
 #include "core/json.h"
 
 #include <QDate>
@@ -64,6 +65,10 @@
 		SetDay(json.at("/day"_json_pointer).get<unsigned char>());
 }
 
+Date::Date(Time::Timestamp timestamp) {
+	Date(QDateTime::fromSecsSinceEpoch(timestamp).date());
+}
+
 void Date::VoidYear() {
 	year.reset();
 }
--- a/src/core/http.cc	Wed Jun 12 05:25:41 2024 -0400
+++ b/src/core/http.cc	Wed Jun 12 17:52:26 2024 -0400
@@ -123,10 +123,7 @@
 }
 
 RequestThread::~RequestThread() {
-	/* block until the function can safely exit.
-	 *
-	 * this sucks. find out a better way to do this, which will probably
-	 * be to put all of the threads in a pool */
+	/* block until the function can safely exit */
 	Stop();
 	wait();
 }
--- a/src/core/session.cc	Wed Jun 12 05:25:41 2024 -0400
+++ b/src/core/session.cc	Wed Jun 12 17:52:26 2024 -0400
@@ -13,7 +13,7 @@
 
 Session session;
 
-Session::Session() {
+Session::Session() : gen(Time::GetSystemTime()) {
 	timer_.start();
 }
 
--- a/src/core/time.cc	Wed Jun 12 05:25:41 2024 -0400
+++ b/src/core/time.cc	Wed Jun 12 17:52:26 2024 -0400
@@ -98,4 +98,8 @@
 	return QDateTime::currentDateTime().toUTC().toSecsSinceEpoch();
 }
 
+Timestamp ParseISO8601Time(const std::string& str) {
+	return QDateTime::fromString(Strings::ToQString(str), Qt::ISODateWithMs).toUTC().toSecsSinceEpoch();
+}
+
 } // namespace Time
--- a/src/gui/dialog/settings/services.cc	Wed Jun 12 05:25:41 2024 -0400
+++ b/src/gui/dialog/settings/services.cc	Wed Jun 12 17:52:26 2024 -0400
@@ -93,8 +93,11 @@
 
 			{
 				QPushButton* auth_button = new QPushButton(credentials_grid);
-				connect(auth_button, &QPushButton::clicked, this, [email, password] {
-					Services::Kitsu::AuthorizeUser(Strings::ToUtf8String(email->text()), Strings::ToUtf8String(password->text()));
+				connect(auth_button, &QPushButton::clicked, this, [auth_button, email, password] {
+					if (Services::Kitsu::AuthorizeUser(Strings::ToUtf8String(email->text()), Strings::ToUtf8String(password->text())))
+						auth_button->setText(tr("Re-authorize..."));
+					else
+						auth_button->setText(tr("Authorize..."));
 				});
 				auth_button->setText(session.config.auth.kitsu.access_token.empty() ? tr("Authorize...")
 				                                                                    : tr("Re-authorize..."));
--- a/src/gui/window.cc	Wed Jun 12 05:25:41 2024 -0400
+++ b/src/gui/window.cc	Wed Jun 12 17:52:26 2024 -0400
@@ -111,7 +111,7 @@
 			const auto& elements = anitomy.elements();
 			const std::string title = Strings::ToUtf8String(elements.get(anitomy::kElementAnimeTitle));
 
-			int id = Anime::db.GetAnimeFromTitle(title);
+			int id = Anime::db.LookupAnimeTitle(title);
 			if (id <= 0)
 				continue;
 
--- a/src/library/library.cc	Wed Jun 12 05:25:41 2024 -0400
+++ b/src/library/library.cc	Wed Jun 12 17:52:26 2024 -0400
@@ -34,7 +34,7 @@
 
 			const std::string title = Strings::ToUtf8String(elements.get(anitomy::kElementAnimeTitle));
 
-			const int id = Anime::db.GetAnimeFromTitle(title);
+			const int id = Anime::db.LookupAnimeTitle(title);
 			if (id <= 0)
 				continue;
 
--- a/src/services/anilist.cc	Wed Jun 12 05:25:41 2024 -0400
+++ b/src/services/anilist.cc	Wed Jun 12 17:52:26 2024 -0400
@@ -150,12 +150,18 @@
 }
 
 static int ParseMediaJson(const nlohmann::json& json) {
-	if (!json.contains("/id"_json_pointer) || !json["/id"_json_pointer].is_number())
+	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);
@@ -191,7 +197,7 @@
 }
 
 static int ParseListItem(const nlohmann::json& json) {
-	int id = ParseMediaJson(json);
+	int id = ParseMediaJson(json["/media"_json_pointer]);
 	if (!id)
 		return 0;
 
@@ -212,11 +218,14 @@
 	return id;
 }
 
-static int ParseList(const nlohmann::json& json) {
+static bool ParseList(const nlohmann::json& json) {
+	bool success = true;
+
 	for (const auto& entry : json["entries"].items())
-		ParseListItem(entry.value());
+		if (!ParseListItem(entry.value()))
+			success = false;
 
-	return 1;
+	return success;
 }
 
 int GetAnimeList() {
@@ -271,10 +280,16 @@
 	if (!res)
 		return 0;
 
+	bool success = true;
+
+	Anime::db.RemoveAllUserData();
+
 	for (const auto& list : result["data"]["MediaListCollection"]["lists"].items())
-		ParseList(list.value());
+		if (!ParseList(list.value()))
+			success = false;
 
-	session.SetStatusBar("AniList: Retrieved anime list successfully!");
+	if (success)
+		session.SetStatusBar("AniList: Retrieved anime list successfully!");
 
 	return 1;
 }
@@ -449,10 +464,6 @@
 	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 = {
@@ -465,9 +476,11 @@
 	if (!ret)
 		return 0;
 
-	session.SetStatusBar("AniList: Successfully retrieved user data!");
-
-	ParseUser(ret["data"]["Viewer"]);
+	if (ParseUser(result["data"]["Viewer"]))
+		session.SetStatusBar("AniList: Successfully retrieved user data!");
+	else
+		session.SetStatusBar("AniList: Failed to retrieve user ID!");
+	
 	return true;
 }
 
--- a/src/services/kitsu.cc	Wed Jun 12 05:25:41 2024 -0400
+++ b/src/services/kitsu.cc	Wed Jun 12 17:52:26 2024 -0400
@@ -123,7 +123,7 @@
 
 /* ----------------------------------------------------------------------------- */
 
-static std::optional<std::string> SendRequest(const std::string& path, const std::map<std::string, std::string>& params) {
+static std::optional<nlohmann::json> SendJSONAPIRequest(const std::string& path, const std::map<std::string, std::string>& params) {
 	std::optional<std::string> token = AccountAccessToken();
 	if (!token)
 		return std::nullopt;
@@ -136,7 +136,29 @@
 
 	const std::string url = HTTP::EncodeParamsList(std::string(BASE_API_PATH) + path, params);
 
-	return Strings::ToUtf8String(HTTP::Request(url, headers, "", HTTP::Type::Get));
+	const std::string response = Strings::ToUtf8String(HTTP::Request(url, headers, "", HTTP::Type::Get));
+	if (response.empty())
+		return std::nullopt;
+
+	std::optional<nlohmann::json> result;
+	try {
+		result = nlohmann::json::parse(response);
+	} catch (const std::exception& ex) {
+		session.SetStatusBar(std::string("Kitsu: Failed to parse response with error \"") + ex.what() + "\"!");
+		return std::nullopt;
+	}
+
+	const nlohmann::json& json = result.value();
+
+	if (json.contains("/errors"_json_pointer)) {
+		for (const auto& item : json["/errors"])
+			std::cerr << "Kitsu: API returned error \"" << json["/errors/title"_json_pointer] << "\" with detail \"" << json["/errors/detail"] << std::endl;
+
+		session.SetStatusBar("Kitsu: Request failed with errors!");
+		return std::nullopt;
+	}
+
+	return result;
 }
 
 static void ParseTitleJson(Anime::Anime& anime, const nlohmann::json& json) {
@@ -167,17 +189,32 @@
 	anime.SetFormat(lookup.at(str));
 }
 
-static const std::string FAILED_TO_PARSE = "Kitsu: Failed to parse anime object!";
+static void ParseListStatus(Anime::Anime& anime, const std::string& str) {
+	static const std::map<std::string, Anime::ListStatus> lookup = {
+		{"completed", Anime::ListStatus::Completed},
+		{"current", Anime::ListStatus::Current},
+		{"dropped", Anime::ListStatus::Dropped},
+		{"on_hold", Anime::ListStatus::Paused},
+		{"planned", Anime::ListStatus::Planning}
+	};
+
+	if (lookup.find(str) == lookup.end())
+		return;
+
+	anime.SetUserStatus(lookup.at(str));
+}
 
 static int ParseAnimeJson(const nlohmann::json& json) {
+	static const std::string FAILED_TO_PARSE = "Kitsu: Failed to parse anime object!";
+
 	const std::string service_id = json["/id"_json_pointer].get<std::string>();
 	if (service_id.empty()) {
-		session.SetStatusBar(FAILED_TO_PARSE);
+		session.SetStatusBar(FAILED_TO_PARSE + " (/id)");
 		return 0;
 	}
 
 	if (!json.contains("/attributes"_json_pointer)) {
-		session.SetStatusBar(FAILED_TO_PARSE);
+		session.SetStatusBar(FAILED_TO_PARSE + " (/attributes)");
 		return 0;
 	}
 
@@ -185,7 +222,7 @@
 
 	int id = Anime::db.LookupServiceIdOrUnused(Anime::Service::Kitsu, service_id);
 	if (!id) {
-		session.SetStatusBar(FAILED_TO_PARSE);
+		session.SetStatusBar(FAILED_TO_PARSE + " getting unused ID");
 		return 0;
 	}
 
@@ -193,39 +230,59 @@
 
 	anime.SetId(id);
 	anime.SetServiceId(Anime::Service::Kitsu, service_id);
-	anime.SetSynopsis(attributes["/synopsis"_json_pointer].get<std::string>());
-	ParseTitleJson(anime, attributes["/titles"_json_pointer]);
+
+	if (attributes.contains("/synopsis"_json_pointer) && attributes["/synopsis"_json_pointer].is_string())
+		anime.SetSynopsis(attributes["/synopsis"_json_pointer].get<std::string>());
+
+	if (attributes.contains("/titles"_json_pointer) && attributes["/titles"_json_pointer].is_object())
+		ParseTitleJson(anime, attributes["/titles"_json_pointer]);
 
 	// FIXME: parse abbreviatedTitles for synonyms??
 
-	anime.SetAudienceScore(JSON::GetNumber<double>(attributes, "/averageRating"_json_pointer));
+	if (attributes.contains("/averageRating"_json_pointer) && attributes["/averageRating"_json_pointer].is_number())
+		anime.SetAudienceScore(JSON::GetNumber<double>(attributes, "/averageRating"_json_pointer));
 
-	if (attributes.contains("/startDate"_json_pointer))
+	if (attributes.contains("/startDate"_json_pointer) && attributes["/startDate"_json_pointer].is_string())
 		anime.SetAirDate(attributes["/startDate"_json_pointer].get<std::string>());
 
 	// TODO: endDate
 
-	if (attributes.contains("/subtype"_json_pointer))
+	if (attributes.contains("/subtype"_json_pointer) && attributes["/subtype"_json_pointer].is_string())
 		ParseSubtype(anime, attributes["/subtype"_json_pointer].get<std::string>());
 
-	anime.SetPosterUrl(attributes["/posterImage/original"_json_pointer].get<std::string>());
-	anime.SetEpisodes(attributes["/episodeCount"_json_pointer].get<int>());
-	anime.SetDuration(attributes["/episodeLength"_json_pointer].get<int>());
+	if (attributes.contains("/posterImage/original"_json_pointer) && attributes["/posterImage/original"_json_pointer].is_string())
+		anime.SetPosterUrl(attributes["/posterImage/original"_json_pointer].get<std::string>());
+
+	if (attributes.contains("/episodeCount"_json_pointer) && attributes["/episodeCount"_json_pointer].is_number())
+		anime.SetEpisodes(attributes["/episodeCount"_json_pointer].get<int>());
+
+	if (attributes.contains("/episodeLength"_json_pointer) && attributes["/episodeLength"_json_pointer].is_number())
+		anime.SetDuration(attributes["/episodeLength"_json_pointer].get<int>());
 
 	return id;
 }
 
 static int ParseLibraryJson(const nlohmann::json& json) {
-	if (!json.contains("/relationships/anime/data"_json_pointer)
-		|| !json.contains("/attributes"_json_pointer)
-		|| !json.contains("/id"_json_pointer)) {
+	static const std::vector<nlohmann::json::json_pointer> required = {
+		"/id"_json_pointer,
+		"/relationships/anime/data/id"_json_pointer,
+		"/attributes"_json_pointer,
+	};
+
+	for (const auto& ptr : required) {
+		if (!json.contains(ptr)) {
+			session.SetStatusBar(std::string("Kitsu: Failed to parse library object! (missing ") + ptr.to_string() + ")");
+			return 0;
+		}
+	}
+
+	std::string service_id = json["/relationships/anime/data/id"_json_pointer].get<std::string>();
+	if (service_id.empty()) {
 		session.SetStatusBar("Kitsu: Failed to parse library object!");
 		return 0;
 	}
 
-	int id = ParseAnimeJson(json["/relationships/anime/data"_json_pointer]);
-	if (!id)
-		return 0;
+	int id = Anime::db.LookupServiceIdOrUnused(Anime::Service::Kitsu, service_id);
 
 	const auto& attributes = json["/attributes"_json_pointer];
 
@@ -235,23 +292,141 @@
 
 	anime.AddToUserList();
 
+	anime.SetId(id);
+	anime.SetServiceId(Anime::Service::Kitsu, service_id);
+
 	anime.SetUserId(library_id);
-	anime.SetUserDateStarted(Date(attributes["/startedAt"_json_pointer].get<std::string>()));
-	anime.SetUserDateCompleted(Date(attributes["/finishedAt"_json_pointer].get<std::string>()));
-	anime.SetUserNotes(attributes["/notes"_json_pointer].get<std::string>());
-	anime.SetUserProgress(attributes["/progress"_json_pointer].get<int>());
-	anime.SetUserScore(attributes["/ratingTwenty"_json_pointer].get<int>() * 5);
-	anime.SetUserIsPrivate(attributes["/private"_json_pointer].get<bool>());
-	anime.SetUserRewatchedTimes(attributes["/reconsumeCount"_json_pointer].get<int>());
-	anime.SetUserIsRewatching(attributes["/reconsuming"_json_pointer].get<bool>()); /* "reconsuming". really? */
-	// anime.SetUserStatus();
-	// anime.SetUserLastUpdated();
+
+	if (attributes.contains("/startedAt"_json_pointer) && attributes["/startedAt"_json_pointer].is_string())
+		anime.SetUserDateStarted(Date(Time::ParseISO8601Time(attributes["/startedAt"_json_pointer].get<std::string>())));
+
+	if (attributes.contains("/finishedAt"_json_pointer) && attributes["/finishedAt"_json_pointer].is_string())
+		anime.SetUserDateCompleted(Date(Time::ParseISO8601Time(attributes["/finishedAt"_json_pointer].get<std::string>())));
+
+	if (attributes.contains("/notes"_json_pointer) && attributes["/notes"_json_pointer].is_string())
+		anime.SetUserNotes(attributes["/notes"_json_pointer].get<std::string>());
+
+	if (attributes.contains("/progress"_json_pointer) && attributes["/progress"_json_pointer].is_number())
+		anime.SetUserProgress(attributes["/progress"_json_pointer].get<int>());
+
+	if (attributes.contains("/ratingTwenty"_json_pointer) && attributes["/ratingTwenty"_json_pointer].is_number())
+		anime.SetUserScore(attributes["/ratingTwenty"_json_pointer].get<int>() * 5);
+
+	if (attributes.contains("/private"_json_pointer) && attributes["/private"_json_pointer].is_boolean())
+		anime.SetUserIsPrivate(attributes["/private"_json_pointer].get<bool>());
+
+	if (attributes.contains("/reconsumeCount"_json_pointer) && attributes["/reconsumeCount"_json_pointer].is_number())
+		anime.SetUserRewatchedTimes(attributes["/reconsumeCount"_json_pointer].get<int>());
+
+	if (attributes.contains("/reconsuming"_json_pointer) && attributes["/reconsuming"_json_pointer].is_boolean())
+		anime.SetUserIsRewatching(attributes["/reconsuming"_json_pointer].get<bool>()); /* lmfao "reconsuming" */
+
+	if (attributes.contains("/status"_json_pointer) && attributes["/status"_json_pointer].is_string())
+		ParseListStatus(anime, attributes["/status"_json_pointer].get<std::string>());
+
+	if (attributes.contains("/progressedAt"_json_pointer) && attributes["/progressedAt"_json_pointer].is_string())
+		anime.SetUserTimeUpdated(Time::ParseISO8601Time(attributes["/progressedAt"_json_pointer].get<std::string>()));
 
 	return id;
 }
 
+static bool ParseAnyJson(const nlohmann::json& json) {
+	enum class Variant {
+		Unknown,
+		Anime,
+		LibraryEntry,
+		Category,
+		Producer,
+	};
+
+	static const std::map<std::string, Variant> lookup = {
+		{"anime", Variant::Anime},
+		{"libraryEntries", Variant::LibraryEntry},
+		{"category", Variant::Category},
+		{"producers", Variant::Producer}
+	};
+
+	static const nlohmann::json::json_pointer required = "/type"_json_pointer;
+	if (!json.contains(required) && !json[required].is_string()) {
+		session.SetStatusBar(std::string("Kitsu: Failed to parse generic object! (missing ") + required.to_string() + ")");
+		return 0;
+	}
+
+	Variant variant = Variant::Unknown;
+
+	std::string json_type = json["/type"_json_pointer].get<std::string>();
+
+	if (lookup.find(json_type) != lookup.end())
+		variant = lookup.at(json_type);
+
+	switch (variant) {
+		case Variant::Anime:
+			return !!ParseAnimeJson(json);
+		case Variant::LibraryEntry:
+			return !!ParseLibraryJson(json);
+		/* ... */
+		case Variant::Category:
+		case Variant::Producer:
+			return true;
+		default:
+			std::cerr << "Kitsu: received unknown type " << json_type << std::endl;
+			return true;
+	}
+}
+
 int GetAnimeList() {
-	return 0;
+	static constexpr int LIBRARY_MAX_SIZE = 500;
+
+	const auto& auth = session.config.auth.kitsu;
+
+	if (auth.user_id.empty()) {
+		session.SetStatusBar("Kitsu: User ID is unavailable!");
+		return 0;
+	}
+
+	int page = 0;
+	bool have_next_page = true;
+
+	std::map<std::string, std::string> params = {
+		{"filter[user_id]", auth.user_id},
+		{"filter[kind]", "anime"},
+		{"include", "anime"},
+		{"page[offset]", Strings::ToUtf8String(page)},
+		{"page[limit]", Strings::ToUtf8String(LIBRARY_MAX_SIZE)}
+	};
+
+	Anime::db.RemoveAllUserData();
+
+	bool success = true;
+
+	while (have_next_page) {
+		std::optional<nlohmann::json> response = SendJSONAPIRequest("/library-entries", params);
+		if (!response)
+			return 0;
+
+		const nlohmann::json& root = response.value();
+
+		if (root.contains("/next"_json_pointer) && root["/next"_json_pointer].is_number()) {
+			page += root["/next"_json_pointer].get<int>();
+			if (page <= 0)
+				have_next_page = false;
+		} else have_next_page = false;
+
+		for (const auto& item : root["/data"_json_pointer])
+			if (!ParseLibraryJson(item))
+				success = false;
+
+		for (const auto& variant : root["/included"_json_pointer])
+			if (!ParseAnyJson(variant))
+				success = false;
+
+		params["page[offset]"] = Strings::ToUtf8String(page);
+	}
+
+	if (success)
+		session.SetStatusBar("Kitsu: Successfully received library data!");
+
+	return 1;
 }
 
 /* unimplemented for now */
@@ -281,24 +456,18 @@
 		{"filter[self]", "true"}
 	};
 
-	std::optional<std::string> response = SendRequest("/users", params);
+	std::optional<nlohmann::json> response = SendJSONAPIRequest("/users", params);
 	if (!response)
 		return false; // whuh?
 
-	nlohmann::json json;
-	try {
-		json = nlohmann::json::parse(response.value());
-	} catch (const std::exception& ex) {
-		session.SetStatusBar(std::string("Kitsu: Failed to parse user data with error \"") + ex.what() + "\"!");
-		return false;
-	}
+	const nlohmann::json& json = response.value();
 
 	if (!json.contains("/data/0/id"_json_pointer)) {
 		session.SetStatusBar("Kitsu: Failed to retrieve user ID!");
 		return false;
 	}
 
-	session.SetStatusBar("Kitsu: Successfully retrieved user data!");
+	session.SetStatusBar("Kitsu: Successfully authorized user!");
 	session.config.auth.kitsu.user_id = json["/data/0/id"_json_pointer].get<std::string>();
 
 	return true;