From 7c40643d8e652adf47cd0ad66fd98b4d808dfade Mon Sep 17 00:00:00 2001 From: dec05eba Date: Tue, 17 Aug 2021 01:53:55 +0200 Subject: Add AniList (WIP) --- src/QuickMedia.cpp | 11 +- src/plugins/AniList.cpp | 514 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 520 insertions(+), 5 deletions(-) create mode 100644 src/plugins/AniList.cpp (limited to 'src') diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index e6c642d..7f83cb9 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -15,6 +15,7 @@ #include "../plugins/Info.hpp" #include "../plugins/HotExamples.hpp" #include "../plugins/MyAnimeList.hpp" +#include "../plugins/AniList.hpp" #include "../include/Scale.hpp" #include "../include/Program.hpp" #include "../include/VideoPlayer.hpp" @@ -78,7 +79,7 @@ static const std::pair valid_plugins[] = { std::make_pair("4chan", "4chan_logo.png"), std::make_pair("nyaa.si", "nyaa_si_logo.png"), std::make_pair("matrix", "matrix_logo.png"), - std::make_pair("mal", "mal_logo.png"), + std::make_pair("anilist", "anilist_logo.png"), std::make_pair("hotexamples", nullptr), std::make_pair("file-manager", nullptr), std::make_pair("stdin", nullptr), @@ -351,7 +352,7 @@ namespace QuickMedia { static void usage() { fprintf(stderr, "usage: quickmedia [plugin] [--no-video] [--use-system-mpv-config] [--dir ] [-e ] [youtube-url]\n"); fprintf(stderr, "OPTIONS:\n"); - fprintf(stderr, " plugin The plugin to use. Should be either launcher, 4chan, manga, manganelo, manganelos, mangatown, mangakatana, mangadex, readm, onimanga, youtube, soundcloud, nyaa.si, matrix, saucenao, hotexamples, mal, file-manager, stdin, pornhub, spankbang, xvideos or xhamster\n"); + fprintf(stderr, " plugin The plugin to use. Should be either launcher, 4chan, manga, manganelo, manganelos, mangatown, mangakatana, mangadex, readm, onimanga, youtube, soundcloud, nyaa.si, matrix, saucenao, hotexamples, anilist, file-manager, stdin, pornhub, spankbang, xvideos or xhamster\n"); fprintf(stderr, " --no-video Only play audio when playing a video. Disabled by default\n"); fprintf(stderr, " --use-system-mpv-config Use system mpv config instead of no config. Disabled by default\n"); fprintf(stderr, " --upscale-images Upscale low-resolution manga pages using waifu2x-ncnn-vulkan. Disabled by default\n"); @@ -1097,8 +1098,8 @@ namespace QuickMedia { auto pipe_body = create_body(true); pipe_body->set_items({ create_launcher_body_item("4chan", "4chan", resources_root + "icons/4chan_launcher.png"), + create_launcher_body_item("AniList", "anilist", resources_root + "images/anilist_logo.png"), create_launcher_body_item("Hot Examples", "hotexamples", ""), - create_launcher_body_item("MyAnimeList", "mal", resources_root + "images/mal_logo.png"), create_launcher_body_item("Manga (all)", "manga", ""), create_launcher_body_item("Mangadex", "mangadex", resources_root + "icons/mangadex_launcher.png"), create_launcher_body_item("Mangakatana", "mangakatana", resources_root + "icons/mangakatana_launcher.png"), @@ -1212,8 +1213,8 @@ namespace QuickMedia { hot_examples_front_page_fill(body_items); body->set_items(std::move(body_items)); tabs.push_back(Tab{std::move(body), std::make_unique(this), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); - } else if(strcmp(plugin_name, "mal") == 0) { - tabs.push_back(Tab{create_body(), std::make_unique(this), create_search_bar("Search...", 400)}); + } else if(strcmp(plugin_name, "anilist") == 0) { + tabs.push_back(Tab{create_body(), std::make_unique(this), create_search_bar("Search...", 400)}); } else if(strcmp(plugin_name, "file-manager") == 0) { auto file_manager_page = std::make_unique(this, fm_mime_type, file_selection_handler); if(!file_manager_page->set_current_directory(file_manager_start_dir)) diff --git a/src/plugins/AniList.cpp b/src/plugins/AniList.cpp new file mode 100644 index 0000000..5bf069c --- /dev/null +++ b/src/plugins/AniList.cpp @@ -0,0 +1,514 @@ +#include "../../plugins/AniList.hpp" +#include "../../include/Theme.hpp" +#include "../../include/StringUtils.hpp" +#include "../../include/Notification.hpp" +#include + +// TODO: Use get_scale() to fetch correctly sized thumbnail + +namespace QuickMedia { + static const std::string search_query_graphql = R"END( +query ($page: Int, $perPage: Int, $search: String) { + Page (page: $page, perPage: $perPage) { + media (search: $search) { + id + type + format + status (version: 2) + startDate { + year + month + day + } + endDate { + year + month + day + } + averageScore + episodes + chapters + genres + coverImage { + medium + } + title { + romaji + } + synonyms + } + } +} + )END"; + + // TODO: description asHtml seems to be broken, so for now we manually remove html from the description text. + // Remove that part when anilist api is fixed. + static const std::string details_query_graphql = R"END( +query ($id: Int) { + Media (id: $id) { + type + format + status (version: 2) + startDate { + year + month + day + } + endDate { + year + month + day + } + averageScore + episodes + chapters + genres + description (asHtml: false) + coverImage { + large + } + title { + romaji + } + synonyms + } +} + )END"; + + enum class AniListMediaType { + ANIME, + MANGA + }; + + static const char *month_names[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + static bool in_range(int value, int min, int max) { + return value >= min && value <= max; + } + + static std::string fuzzy_date_to_str(const Json::Value &fuzzy_date_json) { + std::string result; + if(!fuzzy_date_json.isObject()) + return result; + + const Json::Value &year_json = fuzzy_date_json["year"]; + const Json::Value &month_json = fuzzy_date_json["month"]; + const Json::Value &day_json = fuzzy_date_json["day"]; + if(!year_json.isInt()) + return result; + + if(!month_json.isInt() || !in_range(month_json.asInt(), 1, 12)) { + result = std::to_string(year_json.asInt()); + return result; + } + + result += month_names[month_json.asInt() - 1]; + if(day_json.isInt()) { + result += " "; + result += std::to_string(day_json.asInt()); + } + result += ", "; + result += std::to_string(year_json.asInt()); + return result; + } + + static const char* media_status_to_readable(const char *media_status, AniListMediaType media_type) { + if(strcmp(media_status, "FINISHED") == 0) + return media_type == AniListMediaType::ANIME ? "Finished airing" : "Finished"; + if(strcmp(media_status, "RELEASING") == 0) + return media_type == AniListMediaType::ANIME ? "Publishing" : "Airing"; + if(strcmp(media_status, "NOT_YET_RELEASED") == 0) + return "Not yet released"; + if(strcmp(media_status, "CANCELLED") == 0) + return "Cancelled"; + if(strcmp(media_status, "HIATUS") == 0) + return "Hiatus"; + return media_status; + } + + static const char* media_format_to_readable(const char *media_format) { + if(strcmp(media_format, "TV") == 0) + return "TV"; + if(strcmp(media_format, "TV_SHORT") == 0) + return "TV short"; + if(strcmp(media_format, "MOVIE") == 0) + return "Movie"; + if(strcmp(media_format, "SPECIAL") == 0) + return "Special"; + if(strcmp(media_format, "OVA") == 0) + return "OVA"; + if(strcmp(media_format, "ONA") == 0) + return "ONA"; + if(strcmp(media_format, "MUSIC") == 0) + return "Music"; + if(strcmp(media_format, "MANGA") == 0) + return "Manga"; + if(strcmp(media_format, "NOVEL") == 0) + return "Novel"; + if(strcmp(media_format, "ONE_SHOT") == 0) + return "One shot"; + return media_format; + } + + static std::string json_string_array_to_string(const Json::Value &json_arr) { + std::string result; + if(!json_arr.isArray()) + return result; + + for(const Json::Value &json_item : json_arr) { + if(!json_item.isString()) + continue; + if(!result.empty()) + result += ", "; + result += json_item.asString(); + } + + return result; + } + + static void description_remove_html(std::string &description) { + string_replace_all(description, "", ""); + string_replace_all(description, "", ""); + string_replace_all(description, "", ""); + string_replace_all(description, "", ""); + string_replace_all(description, "
", ""); + html_unescape_sequences(description); + } + + enum class ThumbnailSize { + MEDIUM, + LARGE + }; + + static const char* thumbnail_size_to_cover_image_type(ThumbnailSize thumbnail_size) { + switch(thumbnail_size) { + case ThumbnailSize::MEDIUM: return "medium"; + case ThumbnailSize::LARGE: return "large"; + } + return ""; + } + + // TODO: Somehow get the correct thumbnail size? + static sf::Vector2i thumbnail_size_get_prediced_size(ThumbnailSize thumbnail_size) { + switch(thumbnail_size) { + case ThumbnailSize::MEDIUM: return {100, 158}; + case ThumbnailSize::LARGE: return {215, 304}; + } + return {0, 0}; + } + + // Returns nullptr on error + static std::shared_ptr media_json_to_body_item(const Json::Value &media_json, ThumbnailSize thumbnail_size, AniListMediaType &media_type) { + if(!media_json.isObject()) + return nullptr; + + const Json::Value &type_json = media_json["type"]; + const Json::Value &title_json = media_json["title"]; + if(!type_json.isString() || !title_json.isObject()) + return nullptr; + + const Json::Value &id_json = media_json["id"]; + const Json::Value &cover_image_json = media_json["coverImage"]; + const Json::Value &synonyms_json = media_json["synonyms"]; + const Json::Value &start_date_json = media_json["startDate"]; + const Json::Value &end_date_json = media_json["endDate"]; + const Json::Value &average_score_json = media_json["averageScore"]; + const Json::Value &format_json = media_json["format"]; + const Json::Value &status_json = media_json["status"]; + const Json::Value &episodes_json = media_json["episodes"]; + const Json::Value &chapters_json = media_json["chapters"]; + const Json::Value &genres_json = media_json["genres"]; + const Json::Value &description_json = media_json["description"]; + + const Json::Value &romaji_title_json = title_json["romaji"]; + if(!romaji_title_json.isString()) + return nullptr; + + if(strcmp(type_json.asCString(), "ANIME") == 0) + media_type = AniListMediaType::ANIME; + else if(strcmp(type_json.asCString(), "MANGA") == 0) + media_type = AniListMediaType::MANGA; + else + return nullptr; + + std::string title = romaji_title_json.asString(); + if(format_json.isString()) { + title += " ("; + title += media_format_to_readable(format_json.asCString()); + title += ")"; + } + + std::string description; + if(synonyms_json.isArray() && synonyms_json.size() > 0) + description += "Synonyms: " + json_string_array_to_string(synonyms_json); + + std::string start_date_str = fuzzy_date_to_str(start_date_json); + std::string end_date_str = fuzzy_date_to_str(end_date_json); + const char *aired_prefix = nullptr; + if(media_type == AniListMediaType::ANIME) + aired_prefix = "Aired"; + else if(media_type == AniListMediaType::MANGA) + aired_prefix = "Published"; + + if(!start_date_str.empty()) { + if(!description.empty()) + description += '\n'; + description += aired_prefix; + description += ": " + std::move(start_date_str); + + if(end_date_str.empty()) + end_date_str = "?"; + + description += " to " + std::move(end_date_str); + } + + if(average_score_json.isInt()) { + if(!description.empty()) + description += '\n'; + description += "Score: "; + + char buffer[32]; + int len = snprintf(buffer, sizeof(buffer), "%.2f", (double)average_score_json.asInt() / 10.0); + if(len > 0) + description.append(buffer, len); + } + + if(episodes_json.isInt()) { + if(!description.empty()) + description += '\n'; + description += "Episodes: " + std::to_string(episodes_json.asInt()); + } + + if(chapters_json.isInt()) { + if(!description.empty()) + description += '\n'; + description += "Chapters: " + std::to_string(chapters_json.asInt()); + } + + if(status_json.isString()) { + if(!description.empty()) + description += '\n'; + description += "Status: "; + description += media_status_to_readable(status_json.asCString(), media_type); + } + + if(genres_json.isArray() && genres_json.size() > 0) { + if(!description.empty()) + description += '\n'; + description += "Genres: " + json_string_array_to_string(genres_json); + } + + if(description_json.isString()) { + if(!description.empty()) + description += "\n\n"; + description += "Synopsis: "; + + std::string synopsis = description_json.asString(); + description_remove_html(synopsis); + description += std::move(synopsis); + } + + auto body_item = BodyItem::create(""); + if(id_json.isInt()) + body_item->url = std::to_string(id_json.asInt()); + body_item->set_author(std::move(title)); + if(!description.empty()) { + body_item->set_description(std::move(description)); + body_item->set_description_color(get_current_theme().faded_text_color); + } + if(cover_image_json.isObject()) { + const Json::Value &cover_img_sized_json = cover_image_json[thumbnail_size_to_cover_image_type(thumbnail_size)]; + if(cover_img_sized_json.isString()) + body_item->thumbnail_url = cover_img_sized_json.asString(); + } + body_item->thumbnail_size = thumbnail_size_get_prediced_size(thumbnail_size); + + return body_item; + } + + SearchResult AniListSearchPage::search_page(const std::string &str, int page, BodyItems &result_items) { + Json::Value variables_json(Json::objectValue); + variables_json["page"] = page; + variables_json["perPage"] = 20; + variables_json["search"] = str; + + Json::Value request_json(Json::objectValue); + request_json["query"] = search_query_graphql; + request_json["variables"] = std::move(variables_json); + + Json::StreamWriterBuilder json_builder; + json_builder["commentStyle"] = "None"; + json_builder["indentation"] = ""; + + std::vector additional_args = { + { "-X", "POST" }, + { "-H", "content-type: application/json" }, + { "--data-binary", Json::writeString(json_builder, request_json) } + }; + + Json::Value json_root; + std::string err_msg; + DownloadResult download_result = download_json(json_root, "https://graphql.anilist.co", std::move(additional_args), true, &err_msg); + if(download_result != DownloadResult::OK) return download_result_to_search_result(download_result); + + if(!json_root.isObject()) + return SearchResult::ERR; + + const Json::Value &errors_json = json_root["errors"]; + if(errors_json.isArray() && errors_json.size() > 0) { + const Json::Value &error_json = errors_json[0]; + if(error_json.isObject()) { + const Json::Value &error_message_json = error_json["message"]; + if(error_message_json.isString()) { + show_notification("QuickMedia", "AniList search failed, error: " + error_message_json.asString(), Urgency::CRITICAL); + return SearchResult::ERR; + } + } + } + + const Json::Value &data_json = json_root["data"]; + if(!data_json.isObject()) + return SearchResult::ERR; + + const Json::Value &page_json = data_json["Page"]; + if(!page_json.isObject()) + return SearchResult::ERR; + + const Json::Value &media_list_json = page_json["media"]; + if(!media_list_json.isArray()) + return SearchResult::ERR; + + BodyItems anime_items; + BodyItems manga_items; + for(const Json::Value &media_json : media_list_json) { + AniListMediaType media_type; + auto body_item = media_json_to_body_item(media_json, ThumbnailSize::MEDIUM, media_type); + if(!body_item) + continue; + + if(media_type == AniListMediaType::ANIME) + anime_items.push_back(std::move(body_item)); + else if(media_type == AniListMediaType::MANGA) + manga_items.push_back(std::move(body_item)); + } + + if(anime_items.empty() && manga_items.empty()) + return SearchResult::OK; + + auto anime_title_item = BodyItem::create(""); + anime_title_item->set_author("------------------------ Anime ------------------------"); + result_items.push_back(std::move(anime_title_item)); + result_items.insert(result_items.end(), std::move_iterator(anime_items.begin()), std::move_iterator(anime_items.end())); + + auto manga_title_item = BodyItem::create(""); + manga_title_item->set_author("------------------------ Manga ------------------------"); + result_items.push_back(std::move(manga_title_item)); + result_items.insert(result_items.end(), std::move_iterator(manga_items.begin()), std::move_iterator(manga_items.end())); + + return SearchResult::OK; + } + + SearchResult AniListSearchPage::search(const std::string &str, BodyItems &result_items) { + return search_page(str, 1, result_items); + } + + PluginResult AniListSearchPage::submit(const std::string&, const std::string &url, std::vector &result_tabs) { + if(url.empty()) + return PluginResult::OK; + + result_tabs.push_back({ create_body(), std::make_unique(program, url), nullptr }); + result_tabs.push_back({ create_body(), std::make_unique(program, url), nullptr }); + result_tabs.push_back({ create_body(), std::make_unique(program, url), nullptr }); + return PluginResult::OK; + } + + PluginResult AniListSearchPage::get_page(const std::string &str, int page, BodyItems &result_items) { + return search_result_to_plugin_result(search_page(str, 1 + page, result_items)); + } + + PluginResult AniListRelatedPage::submit(const std::string&, const std::string &url, std::vector &result_tabs) { + if(url.empty()) + return PluginResult::OK; + + result_tabs.push_back({ create_body(), std::make_unique(program, url), nullptr }); + result_tabs.push_back({ create_body(), std::make_unique(program, url), nullptr }); + result_tabs.push_back({ create_body(), std::make_unique(program, url), nullptr }); + return PluginResult::OK; + } + + PluginResult AniListRelatedPage::lazy_fetch(BodyItems &result_items) { + return PluginResult::OK; + } + + PluginResult AniListDetailsPage::submit(const std::string&, const std::string&, std::vector&) { + return PluginResult::OK; + } + + PluginResult AniListDetailsPage::lazy_fetch(BodyItems &result_items) { + Json::Value variables_json(Json::objectValue); + variables_json["id"] = atoi(id.c_str()); + + Json::Value request_json(Json::objectValue); + request_json["query"] = details_query_graphql; + request_json["variables"] = std::move(variables_json); + + Json::StreamWriterBuilder json_builder; + json_builder["commentStyle"] = "None"; + json_builder["indentation"] = ""; + + std::vector additional_args = { + { "-X", "POST" }, + { "-H", "content-type: application/json" }, + { "--data-binary", Json::writeString(json_builder, request_json) } + }; + + Json::Value json_root; + std::string err_msg; + DownloadResult download_result = download_json(json_root, "https://graphql.anilist.co", std::move(additional_args), true, &err_msg); + if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); + + if(!json_root.isObject()) + return PluginResult::ERR; + + const Json::Value &errors_json = json_root["errors"]; + if(errors_json.isArray() && errors_json.size() > 0) { + const Json::Value &error_json = errors_json[0]; + if(error_json.isObject()) { + const Json::Value &error_message_json = error_json["message"]; + if(error_message_json.isString()) { + show_notification("QuickMedia", "AniList search failed, error: " + error_message_json.asString(), Urgency::CRITICAL); + return PluginResult::ERR; + } + } + } + + const Json::Value &data_json = json_root["data"]; + if(!data_json.isObject()) + return PluginResult::ERR; + + const Json::Value &media_json = data_json["Media"]; + if(!media_json.isObject()) + return PluginResult::ERR; + + AniListMediaType media_type; + auto body_item = media_json_to_body_item(media_json, ThumbnailSize::LARGE, media_type); + if(!body_item) + return PluginResult::ERR; + + result_items.push_back(std::move(body_item)); + return PluginResult::OK; + } + + PluginResult AniListRecommendationsPage::submit(const std::string&, const std::string &url, std::vector &result_tabs) { + if(url.empty()) + return PluginResult::OK; + + result_tabs.push_back({ create_body(), std::make_unique(program, url), nullptr }); + result_tabs.push_back({ create_body(), std::make_unique(program, url), nullptr }); + result_tabs.push_back({ create_body(), std::make_unique(program, url), nullptr }); + return PluginResult::OK; + } + + PluginResult AniListRecommendationsPage::lazy_fetch(BodyItems &result_items) { + return PluginResult::OK; + } +} \ No newline at end of file -- cgit v1.2.3