#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, type: %TYPE%) { id type format status (version: 2) startDate { year month day } endDate { year month day } averageScore episodes chapters duration 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 duration genres description (asHtml: false) coverImage { large } title { romaji } synonyms } } )END"; static const std::string related_query_graphql = R"END( query ($id: Int) { Media (id: $id) { relations { edges { relationType (version: 2) node { id type format status (version: 2) startDate { year month day } endDate { year month day } averageScore episodes chapters duration genres coverImage { medium } title { romaji } synonyms } } } } } )END"; static const std::string recommendations_query_graphql = R"END( query ($id: Int, $page: Int, $perPage: Int) { Media (id: $id) { recommendations (page: $page, perPage: $perPage, sort: RATING_DESC) { nodes { mediaRecommendation { id type format status (version: 2) startDate { year month day } endDate { year month day } averageScore episodes chapters duration genres coverImage { medium } title { romaji } synonyms } } } } } )END"; 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 add_genres(const Json::Value &genres_json, BodyItem *body_item) { if(!genres_json.isArray()) return; for(const Json::Value &genre_json : genres_json) { if(!genre_json.isString()) continue; body_item->add_reaction(genre_json.asString(), nullptr); } } 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 { SMALLEST, 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 mgl::vec2i 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 &duration_json = media_json["duration"]; 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(duration_json.isInt()) { if(!description.empty()) description += '\n'; description += "Episode duration: " + std::to_string(duration_json.asInt()) + " minute" + (duration_json.asInt() == 1 ? "" : "s"); } if(status_json.isString()) { if(!description.empty()) description += '\n'; description += "Status: "; description += media_status_to_readable(status_json.asCString(), media_type); } if(description_json.isString()) { if(!description.empty()) description += "\n\n"; description += "Synopsis:\n"; std::string synopsis = description_json.asString(); description_remove_html(synopsis); synopsis = strip(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_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); add_genres(genres_json, body_item.get()); return body_item; } static const char* media_relation_to_readable(const char *media_relation) { if(strcmp(media_relation, "ADAPTATION") == 0) return "Adaption"; if(strcmp(media_relation, "PREQUEL") == 0) return "Prequel"; if(strcmp(media_relation, "SEQUEL") == 0) return "Sequel"; if(strcmp(media_relation, "PARENT") == 0) return "Parent"; if(strcmp(media_relation, "SIDE_STORY") == 0) return "Side story"; if(strcmp(media_relation, "CHARACTER") == 0) return "Character"; if(strcmp(media_relation, "SUMMARY") == 0) return "Summary"; if(strcmp(media_relation, "ALTERNATIVE") == 0) return "Alternative"; if(strcmp(media_relation, "SPIN_OFF") == 0) return "Spin-off"; if(strcmp(media_relation, "OTHER") == 0) return "Other"; if(strcmp(media_relation, "SOURCE") == 0) return "Source"; if(strcmp(media_relation, "COMPILATION") == 0) return "Compilation"; if(strcmp(media_relation, "CONTAINS") == 0) return "Contains"; return media_relation; } 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; std::string query = search_query_graphql; string_replace_all(query, "%TYPE%", media_type == AniListMediaType::ANIME ? "ANIME" : "MANGA"); Json::Value request_json(Json::objectValue); request_json["query"] = std::move(query); 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; 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; result_items.push_back(std::move(body_item)); } return SearchResult::OK; } SearchResult AniListSearchPage::search(const std::string &str, BodyItems &result_items) { return search_page(str, 1, result_items); } PluginResult AniListSearchPage::submit(const SubmitArgs &args, std::vector &result_tabs) { if(args.url.empty()) return PluginResult::OK; result_tabs.push_back({ create_body(), std::make_unique(program, args.url), nullptr }); result_tabs.push_back({ create_body(), std::make_unique(program, args.url), nullptr }); result_tabs.push_back({ create_body(), std::make_unique(program, args.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 SubmitArgs &args, std::vector &result_tabs) { if(args.url.empty()) return PluginResult::OK; result_tabs.push_back({ create_body(), std::make_unique(program, args.url), nullptr }); result_tabs.push_back({ create_body(), std::make_unique(program, args.url), nullptr }); result_tabs.push_back({ create_body(), std::make_unique(program, args.url), nullptr }); return PluginResult::OK; } PluginResult AniListRelatedPage::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"] = related_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; const Json::Value &relations_json = media_json["relations"]; if(!relations_json.isObject()) return PluginResult::ERR; const Json::Value &edges_json = relations_json["edges"]; if(!edges_json.isArray()) return PluginResult::ERR; std::map body_items_by_relation_type; for(const Json::Value &edge_json : edges_json) { if(!edge_json.isObject()) continue; const Json::Value &relation_type_json = edge_json["relationType"]; if(!relation_type_json.isString()) continue; AniListMediaType media_type; auto body_item = media_json_to_body_item(edge_json["node"], ThumbnailSize::MEDIUM, media_type); if(!body_item) continue; const char *relation_type_readable = media_relation_to_readable(relation_type_json.asCString()); body_items_by_relation_type[relation_type_readable].push_back(std::move(body_item)); } for(auto &it : body_items_by_relation_type) { if(it.second.empty()) continue; auto anime_title_item = BodyItem::create("", false); anime_title_item->set_author(it.first); result_items.push_back(std::move(anime_title_item)); result_items.insert(result_items.end(), std::move_iterator(it.second.begin()), std::move_iterator(it.second.end())); } return PluginResult::OK; } PluginResult AniListDetailsPage::submit(const SubmitArgs&, 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 SubmitArgs &args, std::vector &result_tabs) { if(args.url.empty()) return PluginResult::OK; result_tabs.push_back({ create_body(), std::make_unique(program, args.url), nullptr }); result_tabs.push_back({ create_body(), std::make_unique(program, args.url), nullptr }); result_tabs.push_back({ create_body(), std::make_unique(program, args.url), nullptr }); return PluginResult::OK; } PluginResult AniListRecommendationsPage::lazy_fetch(BodyItems &result_items) { return get_page("", 0, result_items); } PluginResult AniListRecommendationsPage::get_page(const std::string&, int page, BodyItems &result_items) { Json::Value variables_json(Json::objectValue); variables_json["page"] = 1 + page; variables_json["perPage"] = 20; variables_json["id"] = atoi(id.c_str()); Json::Value request_json(Json::objectValue); request_json["query"] = recommendations_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; const Json::Value &recommendations_json = media_json["recommendations"]; if(!recommendations_json.isObject()) return PluginResult::ERR; const Json::Value &nodes_json = recommendations_json["nodes"]; if(!nodes_json.isArray()) return PluginResult::ERR; BodyItems anime_items; BodyItems manga_items; for(const Json::Value &node_json : nodes_json) { if(!node_json.isObject()) continue; AniListMediaType media_type; auto body_item = media_json_to_body_item(node_json["mediaRecommendation"], 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()) { auto anime_title_item = BodyItem::create("", false); 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())); } if(!manga_items.empty()) { auto manga_title_item = BodyItem::create("", false); 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 PluginResult::OK; } }