#include "../../plugins/LocalManga.hpp" #include "../../include/Notification.hpp" #include "../../include/Config.hpp" #include "../../include/Theme.hpp" #include "../../include/StringUtils.hpp" #include "../../include/Storage.hpp" #include "../../external/cppcodec/base64_url.hpp" #include "../../include/QuickMedia.hpp" #include #include // TODO: Make thumbnail paths in history and thumbnail-link relative to local_manga.directory namespace QuickMedia { // This is needed because the manga may be stored on NFS. // TODO: Remove once body items can async load when visible on screen class CoverPageLinkCache { public: static CoverPageLinkCache& get_instance() { static CoverPageLinkCache instance; if(instance.initialized) return instance; instance.initialized = true; Path dir = get_cache_dir().join("thumbnail-link"); if(create_directory_recursive(dir) != 0) { show_notification("QuickMedia", "Failed to create directory: " + dir.data, Urgency::CRITICAL); abort(); } instance.cache_filepath = dir.join("local-manga"); std::string file_content; if(get_file_type(instance.cache_filepath) == FileType::REGULAR && file_get_content(instance.cache_filepath, file_content) != 0) { show_notification("QuickMedia", "Failed to load local manga thumbnail link cache", Urgency::CRITICAL); abort(); } std::unordered_map manga_map; string_split(file_content, '\n', [&manga_map](const char *str_part, size_t size) { const void *space_p = memchr(str_part, ' ', size); if(!space_p) return true; std::string manga_name_base64(str_part, (const char*)space_p - str_part); std::string cover_filepath((const char*)space_p + 1, str_part + size - ((const char*)space_p + 1)); manga_map[std::move(manga_name_base64)] = std::move(cover_filepath); return true; }); instance.manga_name_base64_to_cover_filepath_map = std::move(manga_map); return instance; } std::string get_cached_cover_page_link_for_manga(const std::string &manga_name) { std::string manga_name_base64_url = cppcodec::base64_url::encode(manga_name); auto it = manga_name_base64_to_cover_filepath_map.find(manga_name_base64_url); if(it == manga_name_base64_to_cover_filepath_map.end()) return ""; return it->second; } void add_manga_to_thumbnail_link_cache(const std::string &manga_name, const std::string &cover_filepath) { std::string manga_name_base64_url = cppcodec::base64_url::encode(manga_name); manga_name_base64_to_cover_filepath_map[manga_name_base64_url] = cover_filepath; FILE *file = fopen(cache_filepath.data.c_str(), "ab"); if(file) { std::string line = manga_name_base64_url + " " + cover_filepath + "\n"; fwrite(line.data(), 1, line.size(), file); fclose(file); } } private: std::unordered_map manga_name_base64_to_cover_filepath_map; Path cache_filepath; bool initialized = false; }; static const mgl::Color finished_reading_color = mgl::Color(43, 255, 47); // Pages are sorted from 1.png to n.png static std::vector get_images_in_manga(const Path &directory) { std::vector page_list; for_files_in_dir(directory, [&page_list](const Path &filepath, FileType file_type) -> bool { if(file_type != FileType::REGULAR) return true; std::string filname_no_ext = filepath.filename_no_ext(); int page_number = 0; if(!to_num(filname_no_ext.c_str(), filname_no_ext.size(), page_number) || filepath.ext()[0] == '\0') return true; LocalMangaPage local_manga_page; local_manga_page.path = filepath; local_manga_page.number = page_number; page_list.push_back(std::move(local_manga_page)); return true; }); std::sort(page_list.begin(), page_list.end(), [](const LocalMangaPage &manga_page1, const LocalMangaPage &manga_page2) { return manga_page1.number < manga_page2.number; }); return page_list; } static std::vector get_chapters_in_manga(std::string manga_name, const Path &directory, bool only_include_latest, bool include_pages, bool only_get_coverpage = false) { std::vector chapter_list; auto callback = [&chapter_list, &manga_name, only_include_latest, include_pages, only_get_coverpage](const Path &filepath, FileType file_type) -> bool { if(file_type != FileType::DIRECTORY) return true; LocalMangaChapter local_manga_chapter; local_manga_chapter.name = filepath.filename(); if(include_pages) { local_manga_chapter.pages = get_images_in_manga(filepath); if(local_manga_chapter.pages.empty()) return true; } else if(only_get_coverpage) { std::string cover_page = CoverPageLinkCache::get_instance().get_cached_cover_page_link_for_manga(manga_name); if(!cover_page.empty()) { local_manga_chapter.pages.push_back({ cover_page, 1 }); } else { local_manga_chapter.pages = get_images_in_manga(filepath); if(local_manga_chapter.pages.empty()) return true; CoverPageLinkCache::get_instance().add_manga_to_thumbnail_link_cache(manga_name, local_manga_chapter.pages.front().path.data); } } chapter_list.push_back(std::move(local_manga_chapter)); return only_include_latest ? false : true; }; if(get_config().local_manga.sort_chapters_by_name) for_files_in_dir_sort_name(directory, std::move(callback), FileSortDirection::DESC); else for_files_in_dir_sort_last_modified(directory, std::move(callback)); return chapter_list; } static std::vector get_manga_in_directory(const Path &directory, bool only_get_coverpage) { std::vector manga_list; auto callback = [&manga_list, only_get_coverpage](const Path &filepath, FileType file_type) -> bool { if(file_type != FileType::DIRECTORY) return true; LocalManga local_manga; local_manga.name = filepath.filename(); local_manga.chapters = get_chapters_in_manga(local_manga.name, filepath, true, false, only_get_coverpage); if(local_manga.chapters.empty() || !file_get_last_modified_time_seconds(filepath.data.c_str(), &local_manga.modified_time_seconds)) return true; manga_list.push_back(std::move(local_manga)); return true; }; if(get_config().local_manga.sort_by_name) for_files_in_dir_sort_name(directory, std::move(callback), FileSortDirection::ASC); else for_files_in_dir_sort_last_modified(directory, std::move(callback)); return manga_list; } static bool validate_local_manga_dir_config_is_set() { if(get_config().local_manga.directory.empty()) { show_notification("QuickMedia", "local_manga.directory config is not set", Urgency::CRITICAL); return false; } if(get_file_type(get_config().local_manga.directory) != FileType::DIRECTORY) { show_notification("QuickMedia", "local_manga.directory config is not set to a valid directory", Urgency::CRITICAL); return false; } return true; } enum class ReadStatus { READ, UNREAD }; // Returns the new read status static bool toggle_read_save_to_file(Program *program, const std::string &manga_name, const std::string &thumbnail_url, ReadStatus &read_status) { if(!validate_local_manga_dir_config_is_set()) return false; Path manga_url = Path(get_config().local_manga.directory).join(manga_name); std::vector chapters = get_chapters_in_manga(manga_name, manga_url, true, true); if(chapters.empty() || chapters.front().pages.empty()) return false; Path content_storage_dir = get_storage_dir().join("local-manga"); if(create_directory_recursive(content_storage_dir) != 0) { show_notification("QuickMedia", "Failed to create directory: " + content_storage_dir.data, Urgency::CRITICAL); return false; } Path content_storage_file = content_storage_dir; content_storage_file.join(cppcodec::base64_url::encode(manga_name)); Json::Value content_storage_json; bool result = true; FileType file_type = get_file_type(content_storage_file); if(file_type == FileType::REGULAR) { result = read_file_as_json(content_storage_file, content_storage_json) && content_storage_json.isObject(); if(!result) { show_notification("QuickMedia", "Failed to read " + content_storage_file.data, Urgency::CRITICAL); return false; } } else { result = true; } if(!content_storage_json.isObject()) content_storage_json = Json::Value(Json::objectValue); content_storage_json["name"] = manga_name; content_storage_json["url"] = manga_name; Json::Value *chapters_json = &content_storage_json["chapters"]; if(!chapters_json->isObject()) { content_storage_json["chapters"] = Json::Value(Json::objectValue); chapters_json = &content_storage_json["chapters"]; } const LocalMangaChapter &latest_chapter = chapters.front(); Json::Value *chapter_json = &(*chapters_json)[latest_chapter.name]; if(!chapter_json->isObject()) { (*chapters_json)[latest_chapter.name] = Json::Value(Json::objectValue); chapters_json = &(*chapters_json)[latest_chapter.name]; } bool read = false; const Json::Value ¤t_json = (*chapter_json)["current"]; const Json::Value &total_json = (*chapter_json)["total"]; if(current_json.isInt() && total_json.isInt() && current_json.asInt() >= total_json.asInt()) { chapters_json->removeMember(latest_chapter.name); read = true; } else { (*chapter_json)["current"] = (int)latest_chapter.pages.size(); (*chapter_json)["total"] = (int)latest_chapter.pages.size(); (*chapter_json)["url"] = latest_chapter.name; read = false; } if(!save_json_to_file_atomic(content_storage_file, content_storage_json)) { show_notification("QuickMedia", std::string("Failed to mark manga as ") + (read ? "unread" : "read"), Urgency::CRITICAL); return false; } read_status = (read ? ReadStatus::UNREAD : ReadStatus::READ); if(read_status == ReadStatus::READ) program->update_manga_history(manga_name, thumbnail_url); return true; } static bool has_finished_reading_latest_chapter(const LocalManga &manga, const Json::Value &chapters_json) { if(manga.chapters.empty()) return false; const Json::Value &chapter_json = chapters_json[manga.chapters.front().name]; if(!chapter_json.isObject()) return false; const Json::Value ¤t_json = chapter_json["current"]; const Json::Value &total_json = chapter_json["total"]; if(!current_json.isInt() || !total_json.isInt()) return false; return current_json.asInt() >= total_json.asInt(); } // TODO: Check if this is too slow with a lot of manga. // In that case, only save latest read chapter (or newest chapter read) // into one file with a list of all manga. static std::unordered_set get_manga_finished_reading(const std::vector &manga_list) { Path local_manga_config_dir = get_storage_dir().join("local-manga"); std::unordered_set finished_reading; for(const LocalManga &local_manga : manga_list) { std::string manga_name_base64_url = cppcodec::base64_url::encode(local_manga.name); Path manga_progress_filepath = local_manga_config_dir; manga_progress_filepath.join(manga_name_base64_url); Json::Value json_root; if(!read_file_as_json(manga_progress_filepath, json_root)) continue; if(!json_root.isObject()) continue; const Json::Value &chapters_json = json_root["chapters"]; if(!chapters_json.isObject()) continue; if(has_finished_reading_latest_chapter(local_manga, chapters_json)) finished_reading.insert(local_manga.name); } return finished_reading; } static std::shared_ptr local_manga_to_body_item(const LocalManga &local_manga, time_t time_now, bool has_finished_reading) { std::string title; if(has_finished_reading) title = "[Finished reading] "; title += local_manga.name; auto body_item = BodyItem::create(std::move(title)); if(has_finished_reading) body_item->set_title_color(finished_reading_color); body_item->url = local_manga.name; body_item->set_description("Latest chapter: " + local_manga.chapters.front().name + "\nUpdated " + seconds_to_relative_time_str(time_now - local_manga.modified_time_seconds)); body_item->set_description_color(get_theme().faded_text_color); if(!local_manga.chapters.back().pages.empty()) body_item->thumbnail_url = local_manga.chapters.back().pages.front().path.data; body_item->thumbnail_is_local = true; body_item->thumbnail_size = {101, 141}; return body_item; } SearchResult LocalMangaSearchPage::search(const std::string &str, BodyItems &result_items) { time_t time_now = time(nullptr); for(const LocalManga &local_manga : manga_list) { if(string_find_fuzzy_case_insensitive(local_manga.name, str)) { const bool has_finished_reading = finished_reading_manga.find(local_manga.name) != finished_reading_manga.end(); result_items.push_back(local_manga_to_body_item(local_manga, time_now, has_finished_reading)); } } return SearchResult::OK; } PluginResult LocalMangaSearchPage::submit(const SubmitArgs &args, std::vector &result_tabs) { if(!validate_local_manga_dir_config_is_set()) return PluginResult::OK; Path manga_url = Path(get_config().local_manga.directory).join(args.url); std::vector chapters = get_chapters_in_manga(args.url, manga_url, false, false); auto manga_it = std::find_if(manga_list.begin(), manga_list.end(), [&args](const LocalManga &local_manga) { return local_manga.name == args.url; }); if(manga_it == manga_list.end()) { show_notification("QuickMedia", "The selected manga seems to have been removed?", Urgency::CRITICAL); return PluginResult::OK; } const time_t time_now = time(nullptr); BodyItems chapters_items; for(const LocalMangaChapter &local_manga_chapter : chapters) { auto body_item = BodyItem::create(local_manga_chapter.name); body_item->url = local_manga_chapter.name; body_item->set_description("Updated " + seconds_to_relative_time_str(time_now - manga_it->modified_time_seconds)); body_item->set_description_color(get_theme().faded_text_color); chapters_items.push_back(std::move(body_item)); } auto chapters_body = create_body(); chapters_body->set_items(std::move(chapters_items)); auto chapters_page = std::make_unique(program, args.url, args.url, args.thumbnail_url); result_tabs.push_back(Tab{std::move(chapters_body), std::move(chapters_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); return PluginResult::OK; } PluginResult LocalMangaSearchPage::lazy_fetch(BodyItems &result_items) { manga_list.clear(); finished_reading_manga.clear(); if(!validate_local_manga_dir_config_is_set()) return PluginResult::OK; manga_list = get_manga_in_directory(get_config().local_manga.directory, true); if(standalone) finished_reading_manga = get_manga_finished_reading(manga_list); const time_t time_now = time(nullptr); for(const LocalManga &local_manga : manga_list) { const bool has_finished_reading = finished_reading_manga.find(local_manga.name) != finished_reading_manga.end(); result_items.push_back(local_manga_to_body_item(local_manga, time_now, has_finished_reading)); } return PluginResult::OK; } std::shared_ptr LocalMangaSearchPage::get_bookmark_body_item(BodyItem *selected_item) { if(!selected_item) return nullptr; auto body_item = BodyItem::create(selected_item->url); body_item->url = selected_item->url; body_item->thumbnail_url = selected_item->thumbnail_url; return body_item; } void LocalMangaSearchPage::toggle_read(BodyItem *selected_item) { if(!selected_item) return; ReadStatus read_status; if(!toggle_read_save_to_file(program, selected_item->url, selected_item->thumbnail_url, read_status)) return; mgl::Color color = get_theme().text_color; std::string title; if(read_status == ReadStatus::READ) { title = "[Finished reading] "; color = finished_reading_color; } title += selected_item->url; selected_item->set_title(std::move(title)); selected_item->set_title_color(color); } PluginResult LocalMangaChaptersPage::submit(const SubmitArgs &args, std::vector &result_tabs) { if(!validate_local_manga_dir_config_is_set()) return PluginResult::OK; result_tabs.push_back(Tab{nullptr, std::make_unique(program, content_title, args.url, args.url, thumbnail_url), nullptr}); return PluginResult::OK; } bool LocalMangaChaptersPage::extract_id_from_url(const std::string &url, std::string &manga_id) const { manga_id = url; return true; } ImageResult LocalMangaImagesPage::update_image_urls(int &num_images) { num_images = 0; Path chapter_url = Path(get_config().local_manga.directory).join(manga_name).join(url); std::vector pages = get_images_in_manga(chapter_url); if(pages.empty()) return ImageResult::ERR; for(const LocalMangaPage &local_manga_page : pages) { chapter_image_urls.push_back(local_manga_page.path.data); } num_images = chapter_image_urls.size(); return ImageResult::OK; } ImageResult LocalMangaImagesPage::for_each_page_in_chapter(PageCallback callback) { for(const std::string &url : chapter_image_urls) { if(!callback(url)) break; } return ImageResult::OK; } }