#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 namespace QuickMedia { 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) -> bool { if(get_file_type(filepath) != 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(const Path &directory) { std::vector chapter_list; auto callback = [&chapter_list](const Path &filepath) -> bool { if(get_file_type(filepath) != FileType::DIRECTORY) return true; LocalMangaChapter local_manga_chapter; local_manga_chapter.name = filepath.filename(); local_manga_chapter.pages = get_images_in_manga(filepath); if(local_manga_chapter.pages.empty() || !file_get_last_modified_time_seconds(filepath.data.c_str(), &local_manga_chapter.modified_time_seconds)) return true; chapter_list.push_back(std::move(local_manga_chapter)); return 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) { std::vector manga_list; auto callback = [&manga_list](const Path &filepath) -> bool { if(get_file_type(filepath) != FileType::DIRECTORY) return true; LocalManga local_manga; local_manga.name = filepath.filename(); local_manga.chapters = get_chapters_in_manga(filepath); 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; } 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(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; } Path manga_url = Path(get_config().local_manga_directory).join(manga_name); std::vector chapters = get_chapters_in_manga(manga_url); 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); 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(get_config().local_manga_directory.empty()) { show_notification("QuickMedia", "local_manga_directory config is not set", Urgency::CRITICAL); return PluginResult::OK; } 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 PluginResult::OK; } Path manga_url = Path(get_config().local_manga_directory).join(args.url); std::vector chapters = get_chapters_in_manga(manga_url); 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 - local_manga_chapter.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(get_config().local_manga_directory.empty()) { show_notification("QuickMedia", "local_manga_directory config is not set", Urgency::CRITICAL); return PluginResult::OK; } 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 PluginResult::OK; } manga_list = get_manga_in_directory(get_config().local_manga_directory); 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); } static std::unordered_set get_lines_in_file(const Path &filepath) { std::unordered_set lines; std::string file_content; if(file_get_content(filepath, file_content) != 0) return lines; string_split(file_content, '\n', [&lines](const char *str_part, size_t size) { lines.insert(std::string(str_part, size)); return true; }); return lines; } static bool append_seen_manga_to_automedia_seen(const std::string &manga_chapter_name) { Path automedia_config_dir = get_home_dir().join(".config").join("automedia"); if(create_directory_recursive(automedia_config_dir) != 0) { fprintf(stderr, "Warning: failed to create directory: %s\n", automedia_config_dir.data.c_str()); return false; } Path automedia_seen_filepath = automedia_config_dir; automedia_seen_filepath.join("seen"); std::unordered_set lines = get_lines_in_file(automedia_seen_filepath); if(lines.find(manga_chapter_name) != lines.end()) return true; // Already seen FILE *file = fopen(automedia_seen_filepath.data.c_str(), "ab"); if(!file) { fprintf(stderr, "Warning: failed to open automedia seen file %s\n", automedia_seen_filepath.data.c_str()); return false; } std::string new_line_data = manga_chapter_name + "\n"; fwrite(new_line_data.data(), 1, new_line_data.size(), file); fclose(file); return true; } PluginResult LocalMangaChaptersPage::submit(const SubmitArgs &args, std::vector &result_tabs) { if(get_config().local_manga_directory.empty()) { show_notification("QuickMedia", "local_manga_directory config is not set", Urgency::CRITICAL); return PluginResult::OK; } 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 PluginResult::OK; } result_tabs.push_back(Tab{nullptr, std::make_unique(program, content_title, args.url, args.url, thumbnail_url), nullptr}); if(is_program_executable_by_name("automedia")) append_seen_manga_to_automedia_seen(content_url + "/" + args.url); 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::get_number_of_images(int &num_images) { num_images = 0; ImageResult image_result = get_image_urls_for_chapter(url); if(image_result != ImageResult::OK) return image_result; num_images = chapter_image_urls.size(); return ImageResult::OK; } ImageResult LocalMangaImagesPage::for_each_page_in_chapter(PageCallback callback) { ImageResult image_result = get_image_urls_for_chapter(url); if(image_result != ImageResult::OK) return image_result; for(const std::string &url : chapter_image_urls) { if(!callback(url)) break; } return ImageResult::OK; } ImageResult LocalMangaImagesPage::get_image_urls_for_chapter(const std::string &url) { if(!chapter_image_urls.empty()) return ImageResult::OK; 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); } return ImageResult::OK; } }