diff src/core/anime_db.cc @ 202:71832ffe425a

animia: re-add kvm fd source this is all being merged from my wildly out-of-date laptop. SORRY! in other news, I edited the CI file to install the wayland client as well, so the linux CI build might finally get wayland stuff.
author Paper <paper@paper.us.eu.org>
date Tue, 02 Jan 2024 06:05:06 -0500
parents bc1ae1810855
children 4d461ef7d424
line wrap: on
line diff
--- a/src/core/anime_db.cc	Sun Nov 19 19:13:28 2023 -0500
+++ b/src/core/anime_db.cc	Tue Jan 02 06:05:06 2024 -0500
@@ -1,50 +1,63 @@
 #include "core/anime_db.h"
 #include "core/anime.h"
 #include "core/strings.h"
-#include <QDebug>
+#include "core/json.h"
+#include "core/filesystem.h"
+
+#include "gui/translate/anime.h"
+#include "gui/translate/anilist.h"
+
+#include <QDate>
+
+#include <fstream>
+
+#include <iostream>
+#include <exception>
 
 namespace Anime {
 
-int Database::GetTotalAnimeAmount() {
-	int total = 0;
-	for (const auto& a : items) {
-		if (a.second.IsInUserList())
+size_t Database::GetTotalAnimeAmount() {
+	size_t total = 0;
+
+	for (const auto& [id, anime] : items)
+		if (anime.IsInUserList())
 			total++;
-	}
+
 	return total;
 }
 
-int Database::GetListsAnimeAmount(ListStatus status) {
+size_t Database::GetListsAnimeAmount(ListStatus status) {
 	if (status == ListStatus::NOT_IN_LIST)
 		return 0;
-	int total = 0;
-	for (const auto& a : items) {
-		if (a.second.IsInUserList() && a.second.GetUserStatus() == status)
+
+	size_t total = 0;
+
+	for (const auto& [id, anime] : items)
+		if (anime.IsInUserList() && anime.GetUserStatus() == status)
 			total++;
-	}
+
 	return total;
 }
 
-int Database::GetTotalEpisodeAmount() {
-	int total = 0;
-	for (const auto& a : items) {
-		if (a.second.IsInUserList()) {
-			total += a.second.GetUserRewatchedTimes() * a.second.GetEpisodes();
-			total += a.second.GetUserProgress();
-		}
-	}
+size_t Database::GetTotalEpisodeAmount() {
+	size_t total = 0;
+
+	for (const auto& [id, anime] : items)
+		if (anime.IsInUserList())
+			total += anime.GetUserRewatchedTimes() * anime.GetEpisodes() + anime.GetUserProgress();
+
 	return total;
 }
 
 /* Returns the total watched amount in minutes. */
-int Database::GetTotalWatchedAmount() {
-	int total = 0;
-	for (const auto& a : items) {
-		if (a.second.IsInUserList()) {
-			total += a.second.GetDuration() * a.second.GetUserProgress();
-			total += a.second.GetEpisodes() * a.second.GetDuration() * a.second.GetUserRewatchedTimes();
-		}
-	}
+size_t Database::GetTotalWatchedAmount() {
+	size_t total = 0;
+
+	for (const auto& [id, anime] : items)
+		if (anime.IsInUserList())
+			total += anime.GetDuration() * anime.GetUserProgress()
+			         + anime.GetEpisodes() * anime.GetDuration() * anime.GetUserRewatchedTimes();
+
 	return total;
 }
 
@@ -53,24 +66,26 @@
    amount of episodes, as AniList will let you
    set episode counts up to 32768. But that should
    rather be handled elsewhere. */
-int Database::GetTotalPlannedAmount() {
-	int total = 0;
-	for (const auto& a : items) {
-		if (a.second.IsInUserList())
-			total += a.second.GetDuration() * (a.second.GetEpisodes() - a.second.GetUserProgress());
-	}
+size_t Database::GetTotalPlannedAmount() {
+	size_t total = 0;
+
+	for (const auto& [id, anime] : items)
+		if (anime.IsInUserList())
+			total += anime.GetDuration() * (anime.GetEpisodes() - anime.GetUserProgress());
+
 	return total;
 }
 
-/* In Taiga this is called a mean, but "average" is
+/* 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 avg = 0;
-	int amt = 0;
-	for (const auto& a : items) {
-		if (a.second.IsInUserList() && a.second.GetUserScore()) {
-			avg += a.second.GetUserScore();
+	size_t amt = 0;
+
+	for (const auto& [id, anime] : items) {
+		if (anime.IsInUserList() && anime.GetUserScore()) {
+			avg += anime.GetUserScore();
 			amt++;
 		}
 	}
@@ -79,21 +94,24 @@
 
 double Database::GetScoreDeviation() {
 	double squares_sum = 0, avg = GetAverageScore();
-	int amt = 0;
-	for (const auto& a : items) {
-		if (a.second.IsInUserList() && a.second.GetUserScore()) {
-			squares_sum += std::pow(static_cast<double>(a.second.GetUserScore()) - avg, 2);
+	size_t amt = 0;
+
+	for (const auto& [id, anime] : items) {
+		if (anime.IsInUserList() && anime.GetUserScore()) {
+			squares_sum += std::pow(static_cast<double>(anime.GetUserScore()) - avg, 2);
 			amt++;
 		}
 	}
+
 	return (amt > 0) ? std::sqrt(squares_sum / amt) : 0;
 }
 
-template <typename T, typename U>
+template<typename T, typename U>
 static T get_lowest_in_map(const std::unordered_map<T, U>& map) {
 	if (map.size() <= 0)
 		return 0;
-	T id;
+
+	T id = 0;
 	U ret = std::numeric_limits<U>::max();
 	for (const auto& t : map) {
 		if (t.second < ret) {
@@ -104,40 +122,204 @@
 	return id;
 }
 
-/* This is really fugly but WHO CARES :P
-
-   This sort of ""advanced"" algorithm is only in effect because
-   there are some special cases, e.g. Another and Re:ZERO, where 
-   we get the wrong match, so we have to create Advanced Techniques
-   to solve this
-
-   This algorithm:
-     1. searches each anime item for a match to the preferred title
-        AND all synonyms and marks those matches with
-          `synonym.length() - (synonym.find(needle) + needle.length());`
-        which, on a title that exactly matches, will be 0
-     2. returns the id of the match that is the lowest, which will most
-        definitely match anything that exactly matches the title of the
-        filename */
+/* 
+ * This fairly basic algorithm is only in effect because
+ * there are some special cases, e.g. Another and Re:ZERO, where 
+ * we get the wrong match, so we have to create Advanced Techniques
+ * to solve this
+ *
+ * This algorithm:
+ *   1. searches each anime item for a match to the preferred title
+ *      AND all synonyms and marks those matches with
+ *        `synonym.length() - (synonym.find(needle) + needle.length());`
+ *      which should never be less than zero and will be zero if, and only if
+ *      the titles match exactly.
+ *   2. returns the id of the match that is the lowest, which will most
+ *      definitely match anything that exactly matches the title of the
+ *      filename
+*/
 int Database::GetAnimeFromTitle(const std::string& title) {
 	if (title.empty())
 		return 0;
-	std::unordered_map<int, long long> map;
-	for (const auto& a : items) {
-		long long ret = a.second.GetUserPreferredTitle().find(title);
-		if (ret != static_cast<long long>(std::string::npos)) {
-			map[a.second.GetId()] = a.second.GetUserPreferredTitle().length() - (ret + title.length());
+
+	std::unordered_map<int, size_t> map;
+
+	static const auto process_title = [&map](const Anime& anime, const std::string& title, const std::string& needle) -> bool {
+		size_t ret = title.find(needle);
+		if (ret == std::string::npos)
+			return false;
+
+		map[anime.GetId()] = title.length() - (ret + needle.length());
+		return true;
+	};
+
+	for (const auto& [id, anime] : items) {
+		if (process_title(anime, anime.GetUserPreferredTitle(), title))
 			continue;
-		}
-		for (const auto& synonym : a.second.GetTitleSynonyms()) {
-			ret = synonym.find(title);
-			if (ret != static_cast<long long>(std::string::npos)) {
-				map[a.second.GetId()] = synonym.length() - (ret + title.length());
+
+		for (const auto& synonym : anime.GetTitleSynonyms())
+			if (process_title(anime, synonym, title))
 				continue;
-			}
-		}
+	}
+
+	return get_lowest_in_map(map);
+}
+
+static bool GetListDataAsJSON(const Anime& anime, nlohmann::json& json) {
+	if (!anime.IsInUserList())
+		return false;
+
+	// clang-format off
+	json = {
+		{"status", Translate::ToString(anime.GetUserStatus())},
+		{"progress", anime.GetUserProgress()},
+		{"score", anime.GetUserScore()},
+		{"started", anime.GetUserDateStarted().GetAsAniListJson()},
+		{"completed", anime.GetUserDateCompleted().GetAsAniListJson()},
+		{"private", anime.GetUserIsPrivate()},
+		{"rewatched_times", anime.GetUserRewatchedTimes()},
+		{"rewatching", anime.GetUserIsRewatching()},
+		{"updated", anime.GetUserTimeUpdated()},
+		{"notes", anime.GetUserNotes()}
+	};
+	// clang-format on
+
+	return true;
+}
+
+static bool GetAnimeAsJSON(const Anime& anime, nlohmann::json& json) {
+	// clang-format off
+	json = {
+		{"id", anime.GetId()},
+		{"title", {
+			{"native", anime.GetNativeTitle()},
+			{"romaji", anime.GetRomajiTitle()},
+			{"english", anime.GetEnglishTitle()}
+		}},
+		{"synonyms", anime.GetTitleSynonyms()},
+		{"episodes", anime.GetEpisodes()},
+		{"airing_status", Translate::ToString(anime.GetAiringStatus())},
+		{"air_date", anime.GetAirDate().GetAsAniListJson()},
+		{"genres", anime.GetGenres()},
+		{"producers", anime.GetProducers()},
+		{"format", Translate::ToString(anime.GetFormat())},
+		{"season", Translate::ToString(anime.GetSeason())},
+		{"audience_score", anime.GetAudienceScore()},
+		{"synopsis", anime.GetSynopsis()},
+		{"duration", anime.GetDuration()},
+		{"poster_url", anime.GetPosterUrl()}
+	};
+	// clang-format on
+
+	nlohmann::json user;
+	if (GetListDataAsJSON(anime, user))
+		json.push_back({"list_data", user});
+
+	return true;
+}
+
+bool Database::GetDatabaseAsJSON(nlohmann::json& json) {
+	for (const auto& [id, anime] : items) {
+		nlohmann::json anime_json = {};
+		GetAnimeAsJSON(anime, anime_json);
+		json.push_back(anime_json);
 	}
-	return get_lowest_in_map(map);
+
+	return true;
+}
+
+bool Database::SaveDatabaseToDisk() {
+	std::filesystem::path db_path = Filesystem::GetAnimeDBPath();
+	Filesystem::CreateDirectories(db_path);
+
+	std::ofstream db_file(db_path);
+	if (!db_file)
+		return false;
+
+	nlohmann::json json = {};
+	if (!GetDatabaseAsJSON(json))
+		return false;
+
+	db_file << std::setw(4) << json << std::endl;
+	return true;
+}
+
+static bool ParseAnimeUserInfoJSON(const nlohmann::json& json, Anime& anime) {
+	if (!anime.IsInUserList())
+		anime.AddToUserList();
+
+	anime.SetUserStatus(Translate::ToListStatus(JSON::GetString<std::string>(json, "/status"_json_pointer, "")));
+	anime.SetUserProgress(JSON::GetNumber(json, "/progress"_json_pointer, 0));
+	anime.SetUserScore(JSON::GetNumber(json, "/score"_json_pointer, 0));
+	anime.SetUserDateStarted(Date(JSON::GetValue(json, "/started"_json_pointer)));
+	anime.SetUserDateCompleted(Date(JSON::GetValue(json, "/completed"_json_pointer)));
+	anime.SetUserIsPrivate(JSON::GetBoolean(json, "/private"_json_pointer, false));
+	anime.SetUserRewatchedTimes(JSON::GetNumber(json, "/rewatched_times"_json_pointer, 0));
+	anime.SetUserIsRewatching(JSON::GetBoolean(json, "/rewatching"_json_pointer, false));
+	anime.SetUserTimeUpdated(JSON::GetNumber(json, "/updated"_json_pointer, 0));
+	anime.SetUserNotes(JSON::GetString<std::string>(json, "/notes"_json_pointer, ""));
+
+	return true;
+}
+
+static bool ParseAnimeInfoJSON(const nlohmann::json& json, Database& database) {
+	int id = JSON::GetNumber(json, "/id"_json_pointer, 0);
+	if (!id)
+		return false;
+
+	Anime& anime = database.items[id];
+
+	anime.SetId(id);
+	anime.SetNativeTitle(JSON::GetString<std::string>(json, "/title/native"_json_pointer, ""));
+	anime.SetRomajiTitle(JSON::GetString<std::string>(json, "/title/romaji"_json_pointer, ""));
+	anime.SetEnglishTitle(JSON::GetString<std::string>(json, "/title/english"_json_pointer, ""));
+	anime.SetTitleSynonyms(JSON::GetArray<std::vector<std::string>>(json, "/synonyms"_json_pointer, {}));
+	anime.SetEpisodes(JSON::GetNumber(json, "/episodes"_json_pointer, 0));
+	anime.SetAiringStatus(Translate::ToSeriesStatus(JSON::GetString<std::string>(json, "/airing_status"_json_pointer, "")));
+	anime.SetAirDate(Date(JSON::GetValue(json, "/air_date"_json_pointer)));
+	anime.SetGenres(JSON::GetArray<std::vector<std::string>>(json, "/genres"_json_pointer, {}));
+	anime.SetProducers(JSON::GetArray<std::vector<std::string>>(json, "/producers"_json_pointer, {}));
+	anime.SetFormat(Translate::ToSeriesFormat(JSON::GetString<std::string>(json, "/format"_json_pointer, "")));
+	anime.SetSeason(Translate::ToSeriesSeason(JSON::GetString<std::string>(json, "/season"_json_pointer, "")));
+	anime.SetAudienceScore(JSON::GetNumber(json, "/audience_score"_json_pointer, 0));
+	anime.SetSynopsis(JSON::GetString<std::string>(json, "/synopsis"_json_pointer, ""));
+	anime.SetDuration(JSON::GetNumber(json, "/duration"_json_pointer, 0));
+	anime.SetPosterUrl(JSON::GetString<std::string>(json, "/poster_url"_json_pointer, ""));
+
+	if (json.contains("/list_data"_json_pointer) && json.at("/list_data"_json_pointer).is_object())
+		ParseAnimeUserInfoJSON(json.at("/list_data"_json_pointer), anime);
+
+	return true;
+}
+
+bool Database::ParseDatabaseJSON(const nlohmann::json& json) {
+	for (const auto& anime_json : json)
+		ParseAnimeInfoJSON(anime_json, *this);
+
+	return true;
+}
+
+bool Database::LoadDatabaseFromDisk() {
+	std::filesystem::path db_path = Filesystem::GetAnimeDBPath();
+	Filesystem::CreateDirectories(db_path);
+
+	std::ifstream db_file(db_path);
+	if (!db_file)
+		return false;
+
+	/* When parsing, do NOT throw exceptions */
+	nlohmann::json json;
+	try {
+		json = json.parse(db_file);
+	} catch (std::exception const& ex) {
+		std::cerr << "[anime/db] Failed to parse JSON! " << ex.what() << std::endl;
+		return false;
+	}
+
+	if (!ParseDatabaseJSON(json)) /* How */
+		return false;
+
+	return true;
 }
 
 Database db;