view src/core/anime_db.cc @ 175:9b10175be389

dep/json: update to v3.11.3 anime/db: save anime list to database, very much untested and likely won't work as intended
author Paper <mrpapersonic@gmail.com>
date Thu, 30 Nov 2023 13:52:26 -0500
parents f88eda79c60a
children 121c2d5b321f
line wrap: on
line source

#include "core/anime_db.h"
#include "core/anime.h"
#include "core/strings.h"
#include "core/json.h"
#include "core/filesystem.h"

#include "gui/translate/anime.h"
#include "gui/translate/anilist.h"

#include <fstream>

namespace Anime {

int Database::GetTotalAnimeAmount() {
	int total = 0;
	for (const auto& a : items) {
		if (a.second.IsInUserList())
			total++;
	}
	return total;
}

int 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)
			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();
		}
	}
	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();
		}
	}
	return total;
}

/* Returns the total planned amount in minutes.
   Note that we should probably limit progress to the
   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());
	}
	return total;
}

/* In Taiga this is called a 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();
			amt++;
		}
	}
	return avg / amt;
}

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);
			amt++;
		}
	}
	return (amt > 0) ? std::sqrt(squares_sum / amt) : 0;
}

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 = 0;
	U ret = std::numeric_limits<U>::max();
	for (const auto& t : map) {
		if (t.second < ret) {
			ret = t.second;
			id = t.first;
		}
	}
	return id;
}

/* This is really fugly but WHO CARES :P

   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, size_t> map;

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

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

	return true;
}

static bool GetAnimeAsJSON(const Anime& anime, nlohmann::json& json) {
	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()}
	};

	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 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.SetUserDateStarted(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;
}

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

	Anime& anime = 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.at("/list_data").is_array())
		ParseAnimeUserInfoJSON(json, anime);

	return true;
}

bool Database::ParseDatabaseJSON(const nlohmann::json& json) {
	for (const auto& anime_json : json)
		ParseAnimeInfoJSON(anime_json);

	return true;
}

bool Database::LoadDatabaseFromFile() {
	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 = json.parse(db_file, nullptr, false);
	if (json.is_discarded())
		return false; /* Give up */

	if (!ParseDatabaseJSON(json)) /* How */
		return false;

	return true;
}

Database db;

} // namespace Anime