diff options
author | dec05eba <dec05eba@protonmail.com> | 2022-02-11 00:42:21 +0100 |
---|---|---|
committer | dec05eba <dec05eba@protonmail.com> | 2022-02-11 00:42:21 +0100 |
commit | 1f74222bf4cfadead768b095c6b3f8d422ebf84c (patch) | |
tree | 39035288edb79852cef6237f0d7ab8ea146cf218 | |
parent | 404ac476a213164a041f0f53be30855df815aa6a (diff) |
Add local-manga plugin to read local manga
-rw-r--r-- | README.md | 3 | ||||
-rw-r--r-- | TODO | 3 | ||||
-rw-r--r-- | example-config.json | 3 | ||||
-rw-r--r-- | include/Config.hpp | 1 | ||||
-rw-r--r-- | include/Path.hpp | 10 | ||||
-rw-r--r-- | include/QuickMedia.hpp | 2 | ||||
-rw-r--r-- | include/Storage.hpp | 8 | ||||
-rw-r--r-- | include/StringUtils.hpp | 1 | ||||
-rw-r--r-- | plugins/LocalManga.hpp | 61 | ||||
-rw-r--r-- | plugins/Manga.hpp | 2 | ||||
-rw-r--r-- | plugins/MangaCombined.hpp | 5 | ||||
-rw-r--r-- | plugins/Page.hpp | 3 | ||||
-rw-r--r-- | src/AsyncImageLoader.cpp | 2 | ||||
-rw-r--r-- | src/Body.cpp | 23 | ||||
-rw-r--r-- | src/Config.cpp | 14 | ||||
-rw-r--r-- | src/QuickMedia.cpp | 63 | ||||
-rw-r--r-- | src/Storage.cpp | 41 | ||||
-rw-r--r-- | src/StringUtils.cpp | 34 | ||||
-rw-r--r-- | src/Theme.cpp | 9 | ||||
-rw-r--r-- | src/plugins/LocalManga.cpp | 195 | ||||
-rw-r--r-- | src/plugins/MangaCombined.cpp | 15 | ||||
-rw-r--r-- | src/plugins/Page.cpp | 1 |
22 files changed, 448 insertions, 51 deletions
@@ -1,11 +1,12 @@ # QuickMedia A rofi inspired native client for web services. Currently supported web services: `youtube`, `peertube`, `lbry`, `soundcloud`, `nyaa.si`, `manganelo`, `manganelos`, `mangatown`, `mangakatana`, `mangadex`, `readm`, `onimanga`, `4chan`, `matrix`, `saucenao`, `hotexamples`, `anilist` and _others_. +QuickMedia also supports reading local manga (add "local_manga_directory" config to ~/.config/quickmedia/config.json). ## Usage ``` usage: quickmedia [plugin] [--dir <directory>] [-e <window>] [youtube-url] OPTIONS: - plugin The plugin to use. Should be either launcher, 4chan, manga, manganelo, manganelos, mangatown, mangakatana, mangadex, readm, onimanga, youtube, peertube, lbry, soundcloud, nyaa.si, matrix, saucenao, hotexamples, anilist, file-manager or stdin + plugin The plugin to use. Should be either launcher, 4chan, manga, manganelo, manganelos, mangatown, mangakatana, mangadex, readm, onimanga, local-manga, youtube, peertube, lbry, soundcloud, nyaa.si, matrix, saucenao, hotexamples, anilist, file-manager or stdin --no-video Only play audio when playing a video. Disabled by default --upscale-images Upscale low-resolution manga pages using waifu2x-ncnn-vulkan. Disabled by default --upscale-images-always Upscale manga pages using waifu2x-ncnn-vulkan, no matter what the original image resolution is. Disabled by default @@ -213,4 +213,5 @@ Very large resolutions, such as 7680x2160 (id 272) for video https://www.youtube Use std::move(string) for all places where text.set_string is called. Use reference for text.get_string() in all places. Cleanup font sizes that are not visible (or not used for a while). Also do the same for character ranges font texture. -Show video upload date in video description on youtube.
\ No newline at end of file +Show video upload date in video description on youtube. +Add latest read chapter name to manga progress, to easily see from manga list page how we have progressed in the manga.
\ No newline at end of file diff --git a/example-config.json b/example-config.json index 72cfffe..0d76348 100644 --- a/example-config.json +++ b/example-config.json @@ -26,5 +26,6 @@ "theme": "default", "scale": 1.0, "font_scale": 1.0, - "spacing_scale": 1.0 + "spacing_scale": 1.0, + "local_manga_directory": "" }
\ No newline at end of file diff --git a/include/Config.hpp b/include/Config.hpp index 1a5ef10..8430e73 100644 --- a/include/Config.hpp +++ b/include/Config.hpp @@ -46,6 +46,7 @@ namespace QuickMedia { float scale = 1.0f; float font_scale = 1.0f; float spacing_scale = 1.0f; + std::string local_manga_directory; }; const Config& get_config(); diff --git a/include/Path.hpp b/include/Path.hpp index fe49265..bb08cc6 100644 --- a/include/Path.hpp +++ b/include/Path.hpp @@ -21,6 +21,7 @@ namespace QuickMedia { return *this; } + // Includes extension const char* filename() const { size_t index = data.rfind('/'); if(index == std::string::npos) @@ -28,6 +29,15 @@ namespace QuickMedia { return data.c_str() + index + 1; } + std::string filename_no_ext() const { + const char *name = filename(); + const char *extension = ext(); + if(extension[0] == '\0') + return name; + else + return data.substr(name - data.data(), extension - name); + } + // Returns empty string if no extension const char* ext() const { size_t slash_index = data.rfind('/'); diff --git a/include/QuickMedia.hpp b/include/QuickMedia.hpp index ce63fbb..27c6bb2 100644 --- a/include/QuickMedia.hpp +++ b/include/QuickMedia.hpp @@ -96,7 +96,7 @@ namespace QuickMedia { TaskResult run_task_with_loading_screen(std::function<bool()> callback); const char* get_plugin_name() const; - void manga_get_watch_history(const char *plugin_name, BodyItems &history_items); + void manga_get_watch_history(const char *plugin_name, BodyItems &history_items, bool local_thumbnail); void youtube_get_watch_history(BodyItems &history_items); Json::Value load_history_json(); diff --git a/include/Storage.hpp b/include/Storage.hpp index 1bf44a9..1e38906 100644 --- a/include/Storage.hpp +++ b/include/Storage.hpp @@ -18,6 +18,11 @@ namespace QuickMedia { DIRECTORY }; + enum FileSortDirection { + ASC, + DESC + }; + Path get_home_dir(); Path get_storage_dir(); Path get_cache_dir(); @@ -30,7 +35,8 @@ namespace QuickMedia { int file_overwrite(const Path &path, const std::string &data); int file_overwrite_atomic(const Path &path, const std::string &data); void for_files_in_dir(const Path &path, FileIteratorCallback callback); - void for_files_in_dir_sort_last_modified(const Path &path, FileIteratorCallback callback); + void for_files_in_dir_sort_last_modified(const Path &path, FileIteratorCallback callback, FileSortDirection sort_dir = FileSortDirection::ASC); + void for_files_in_dir_sort_name(const Path &path, FileIteratorCallback callback, FileSortDirection sort_dir = FileSortDirection::ASC); bool read_file_as_json(const Path &filepath, Json::Value &result); bool save_json_to_file_atomic(const Path &path, const Json::Value &json); diff --git a/include/StringUtils.hpp b/include/StringUtils.hpp index d31713d..fb16d7a 100644 --- a/include/StringUtils.hpp +++ b/include/StringUtils.hpp @@ -20,6 +20,7 @@ namespace QuickMedia { bool string_starts_with(const std::string &str, const char *sub); bool string_ends_with(const std::string &str, const std::string &ends_with_str); size_t str_find_case_insensitive(const std::string &str, size_t start_index, const char *substr, size_t substr_len); + bool string_find_fuzzy_case_insensitive(const std::string &str, const std::string &substr); char to_upper(char c); bool strncase_equals(const char *str1, const char *str2, size_t length); bool strcase_equals(const char *str1, const char *str2); diff --git a/plugins/LocalManga.hpp b/plugins/LocalManga.hpp new file mode 100644 index 0000000..27ee89d --- /dev/null +++ b/plugins/LocalManga.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include "Manga.hpp" + +namespace QuickMedia { + struct LocalMangaPage { + Path path; + int number; + }; + + struct LocalMangaChapter { + std::string name; + std::vector<LocalMangaPage> pages; + time_t modified_time_seconds; + }; + + struct LocalManga { + std::string name; + std::vector<LocalMangaChapter> chapters; + time_t modified_time_seconds; + }; + + class LocalMangaSearchPage : public LazyFetchPage { + public: + LocalMangaSearchPage(Program *program, bool standalone) : LazyFetchPage(program), standalone(standalone) {} + const char* get_title() const override { return "Search"; } + bool search_is_filter() override { return standalone; } + SearchResult search(const std::string &str, BodyItems &result_items) override; + PluginResult submit(const SubmitArgs &args, std::vector<Tab> &result_tabs) override; + PluginResult lazy_fetch(BodyItems &result_items) override; + const char* get_bookmark_name() const override { return "local-manga"; } + private: + std::vector<LocalManga> manga_list; + bool standalone; + }; + + class LocalMangaChaptersPage : public MangaChaptersPage { + public: + LocalMangaChaptersPage(Program *program, std::string manga_name, std::string manga_url, const std::string &thumbnail_url) : MangaChaptersPage(program, std::move(manga_name), std::move(manga_url), thumbnail_url) {} + PluginResult submit(const SubmitArgs &args, std::vector<Tab> &result_tabs) override; + const char* get_bookmark_name() const override { return "local-manga"; } + protected: + bool extract_id_from_url(const std::string &url, std::string &manga_id) const override; + const char* get_service_name() const override { return "local-manga"; } + }; + + class LocalMangaImagesPage : public MangaImagesPage { + public: + LocalMangaImagesPage(Program *program, std::string manga_name, std::string chapter_name, std::string url, std::string thumbnail_url, std::vector<LocalMangaPage> pages) : + MangaImagesPage(program, std::move(manga_name), std::move(chapter_name), std::move(url), std::move(thumbnail_url)), pages(std::move(pages)) {} + ImageResult get_number_of_images(int &num_images) override; + ImageResult for_each_page_in_chapter(PageCallback callback) override; + const char* get_service_name() const override { return "local-manga"; } + const char* get_website_url() const override { return "localhost"; } + bool is_local() override { return true; } + private: + ImageResult get_image_urls_for_chapter(const std::string &url); + private: + std::vector<LocalMangaPage> pages; + }; +}
\ No newline at end of file diff --git a/plugins/Manga.hpp b/plugins/Manga.hpp index 7b67e13..bc6b415 100644 --- a/plugins/Manga.hpp +++ b/plugins/Manga.hpp @@ -46,6 +46,8 @@ namespace QuickMedia { virtual const char* get_website_url() const = 0; + virtual bool is_local() { return false; } + const std::string manga_name; const std::string thumbnail_url; protected: diff --git a/plugins/MangaCombined.hpp b/plugins/MangaCombined.hpp index 1348b1b..670055f 100644 --- a/plugins/MangaCombined.hpp +++ b/plugins/MangaCombined.hpp @@ -10,11 +10,12 @@ namespace QuickMedia { std::unique_ptr<Page> page; std::string title; std::string service_name; + bool local_manga = false; }; using MangaCombinedSearchThread = std::pair<MangaPlugin*, AsyncTask<BodyItems>>; - class MangaCombinedSearchPage : public Page { + class MangaCombinedSearchPage : public LazyFetchPage { public: MangaCombinedSearchPage(Program *program, std::vector<MangaPlugin> search_pages); const char* get_title() const override { return "Search"; } @@ -22,6 +23,8 @@ namespace QuickMedia { SearchResult search(const std::string &str, BodyItems &result_items) override; PluginResult get_page(const std::string &str, int page, BodyItems &result_items) override; PluginResult submit(const SubmitArgs &args, std::vector<Tab> &result_tabs) override; + PluginResult lazy_fetch(BodyItems &result_items) override; + bool lazy_fetch_is_loader() override { return true; } void cancel_operation() override; private: std::vector<MangaPlugin> search_pages; diff --git a/plugins/Page.hpp b/plugins/Page.hpp index 0e904c2..a803725 100644 --- a/plugins/Page.hpp +++ b/plugins/Page.hpp @@ -176,7 +176,7 @@ namespace QuickMedia { class BookmarksPage : public LazyFetchPage { public: - BookmarksPage(Program *program, Page *redirect_page) : LazyFetchPage(program), redirect_page(redirect_page) {} + BookmarksPage(Program *program, Page *redirect_page, bool local_thumbnail = false) : LazyFetchPage(program), redirect_page(redirect_page), local_thumbnail(local_thumbnail) {} const char* get_title() const override { return "Bookmarks"; } PluginResult submit(const SubmitArgs &args, std::vector<Tab> &result_tabs) override; PluginResult lazy_fetch(BodyItems &result_items) override; @@ -185,5 +185,6 @@ namespace QuickMedia { bool is_bookmark_page() const override { return true; } private: Page *redirect_page; + bool local_thumbnail; }; }
\ No newline at end of file diff --git a/src/AsyncImageLoader.cpp b/src/AsyncImageLoader.cpp index 741ff01..ddcb604 100644 --- a/src/AsyncImageLoader.cpp +++ b/src/AsyncImageLoader.cpp @@ -79,7 +79,7 @@ namespace QuickMedia { } else if(symlink_if_no_resize) { int res = symlink(thumbnail_path.data.c_str(), result_path_tmp.data.c_str()); if(res == -1 && errno != EEXIST) { - fprintf(stderr, "Failed to symlink %s to %s\n", thumbnail_path_resized.data.c_str(), thumbnail_path.data.c_str()); + fprintf(stderr, "Failed to symlink %s to %s\n", result_path_tmp.data.c_str(), thumbnail_path.data.c_str()); _exit(1); } } else { diff --git a/src/Body.cpp b/src/Body.cpp index dbc13af..4681410 100644 --- a/src/Body.cpp +++ b/src/Body.cpp @@ -1713,29 +1713,6 @@ namespace QuickMedia { return item_height; } - // TODO: Support utf-8 case insensitive find - static bool string_find_fuzzy_case_insensitive(const std::string &str, const std::string &substr) { - if(substr.empty()) return true; - - size_t str_index = 0; - bool full_match = true; - - string_split(substr, ' ', [&str, &str_index, &full_match](const char *str_part, size_t size) { - if(size == 0) return true; - - size_t found_index = str_find_case_insensitive(str, str_index, str_part, size); - if(found_index == std::string::npos) { - full_match = false; - return false; - } - - str_index = found_index + size; - return true; - }); - - return full_match; - } - void Body::filter_search_fuzzy(const std::string &text) { current_filter = text; diff --git a/src/Config.cpp b/src/Config.cpp index 159836f..7f71f21 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -22,8 +22,11 @@ namespace QuickMedia { } char *dpi = XGetDefault(display, "Xft", "dpi"); - if(dpi) + if(dpi) { xft_dpi = strtol(dpi, nullptr, 10); + if(xft_dpi == 0) + xft_dpi = XFT_DPI_DEFAULT; + } XCloseDisplay(display); return xft_dpi; @@ -37,12 +40,13 @@ namespace QuickMedia { if(gdk_scale) { setlocale(LC_ALL, "C"); // Sigh... stupid C scale = atof(gdk_scale); - if(scale < 0.0001f) - scale = 1.0f; } else { scale = (float)xrdb_get_dpi() / (float)XFT_DPI_DEFAULT; } + if(scale < 0.0001f) + scale = 1.0f; + scale_set = true; return scale; } @@ -148,6 +152,10 @@ namespace QuickMedia { const Json::Value &spacing_scale = json_root["spacing_scale"]; if(spacing_scale.isNumeric()) config->spacing_scale = spacing_scale.asFloat(); + + const Json::Value &local_manga_directory_json = json_root["local_manga_directory"]; + if(local_manga_directory_json.isString()) + config->local_manga_directory = local_manga_directory_json.asString(); } const Config& get_config() { diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index 1f7629e..cfe4e15 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -1,6 +1,7 @@ #include "../include/QuickMedia.hpp" #include "../plugins/Manganelo.hpp" #include "../plugins/Mangadex.hpp" +#include "../plugins/LocalManga.hpp" #include "../plugins/MangaGeneric.hpp" #include "../plugins/MangaCombined.hpp" #include "../plugins/MediaGeneric.hpp" @@ -79,6 +80,7 @@ static const std::pair<const char*, const char*> valid_plugins[] = { std::make_pair("mangadex", "mangadex_logo.png"), std::make_pair("onimanga", nullptr), std::make_pair("readm", "readm_logo.png"), + std::make_pair("local-manga", nullptr), std::make_pair("manga", nullptr), std::make_pair("youtube", "yt_logo_rgb_dark_small.png"), std::make_pair("peertube", "peertube_logo.png"), @@ -200,8 +202,8 @@ namespace QuickMedia { class HistoryPage : public LazyFetchPage { public: - HistoryPage(Program *program, Page *search_page, HistoryType history_type) : - LazyFetchPage(program), search_page(search_page), history_type(history_type) {} + HistoryPage(Program *program, Page *search_page, HistoryType history_type, bool local_thumbnail = false) : + LazyFetchPage(program), search_page(search_page), history_type(history_type), local_thumbnail(local_thumbnail) {} const char* get_title() const override { return "History"; } PluginResult submit(const SubmitArgs &args, std::vector<Tab> &result_tabs) override { return search_page->submit(args, result_tabs); @@ -212,7 +214,7 @@ namespace QuickMedia { program->youtube_get_watch_history(result_items); break; case HistoryType::MANGA: - program->manga_get_watch_history(program->get_plugin_name(), result_items); + program->manga_get_watch_history(program->get_plugin_name(), result_items, local_thumbnail); break; } return PluginResult::OK; @@ -222,6 +224,7 @@ namespace QuickMedia { private: Page *search_page; HistoryType history_type; + bool local_thumbnail; }; using OptionsPageHandler = std::function<void()>; @@ -279,7 +282,7 @@ namespace QuickMedia { static void usage() { fprintf(stderr, "usage: quickmedia [plugin] [--no-video] [--dir <directory>] [-e <window>] [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, peertube, lbry, soundcloud, nyaa.si, matrix, saucenao, hotexamples, anilist, 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, local-manga, youtube, peertube, lbry, 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, " --upscale-images Upscale low-resolution manga pages using waifu2x-ncnn-vulkan. Disabled by default\n"); fprintf(stderr, " --upscale-images-always Upscale manga pages using waifu2x-ncnn-vulkan, no matter what the original image resolution is. Disabled by default\n"); @@ -296,7 +299,15 @@ namespace QuickMedia { } static bool is_manga_plugin(const char *plugin_name) { - return strcmp(plugin_name, "manga") == 0 || strcmp(plugin_name, "manganelo") == 0 || strcmp(plugin_name, "manganelos") == 0 || strcmp(plugin_name, "mangatown") == 0 || strcmp(plugin_name, "mangakatana") == 0 || strcmp(plugin_name, "mangadex") == 0 || strcmp(plugin_name, "readm") == 0 || strcmp(plugin_name, "onimanga") == 0; + return strcmp(plugin_name, "manga") == 0 + || strcmp(plugin_name, "manganelo") == 0 + || strcmp(plugin_name, "manganelos") == 0 + || strcmp(plugin_name, "mangatown") == 0 + || strcmp(plugin_name, "mangakatana") == 0 + || strcmp(plugin_name, "mangadex") == 0 + || strcmp(plugin_name, "readm") == 0 + || strcmp(plugin_name, "onimanga") == 0 + || strcmp(plugin_name, "local-manga") == 0; } static std::shared_ptr<BodyItem> create_launcher_body_item(const char *title, const char *plugin_name, const std::string &thumbnail_url) { @@ -617,6 +628,10 @@ namespace QuickMedia { resources_root = program_path + "../../../"; } + // Initialize config and theme early to prevent possible race condition on initialize + get_config(); + get_theme(); + set_resource_loader_root_path(resources_root.c_str()); set_use_system_fonts(get_config().use_system_fonts); init_body_themes(); @@ -1041,6 +1056,7 @@ namespace QuickMedia { create_launcher_body_item("AniList", "anilist", resources_root + "images/anilist_logo.png"), create_launcher_body_item("Hot Examples", "hotexamples", ""), create_launcher_body_item("Lbry", "lbry", resources_root + "icons/lbry_launcher.png"), + create_launcher_body_item("Local manga", "local-manga", ""), 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"), @@ -1134,6 +1150,16 @@ namespace QuickMedia { tabs.push_back(Tab{create_body(), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); start_tab_index = 1; + } else if(strcmp(plugin_name, "local-manga") == 0) { + auto search_page = std::make_unique<LocalMangaSearchPage>(this, true); + + tabs.push_back(Tab{create_body(), std::make_unique<BookmarksPage>(this, search_page.get(), true), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); + tabs.push_back(Tab{create_body(), std::move(search_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); + + auto history_page = std::make_unique<HistoryPage>(this, tabs.back().page.get(), HistoryType::MANGA, true); + tabs.push_back(Tab{create_body(), std::move(history_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); + + start_tab_index = 1; } else if(strcmp(plugin_name, "manga") == 0) { auto mangadex = std::make_unique<MangadexSearchPage>(this); upgrade_legacy_mangadex_ids(this, mangadex.get()); @@ -1149,6 +1175,7 @@ namespace QuickMedia { add_onimanga_handlers(onimanga.get()); auto readm = std::make_unique<MangaGenericSearchPage>(this, "readm", "https://readm.org/"); add_readm_handlers(readm.get()); + auto local_manga = std::make_unique<LocalMangaSearchPage>(this, false); // TODO: Use async task pool std::vector<MangaPlugin> pages; @@ -1160,6 +1187,9 @@ namespace QuickMedia { pages.push_back({std::move(readm), "Readm", "readm"}); pages.push_back({std::move(mangadex), "Mangadex", "mangadex"}); + if(!get_config().local_manga_directory.empty()) + pages.push_back({std::move(local_manga), "Local manga", "local-manga", true}); + tabs.push_back(Tab{create_body(), std::make_unique<MangaCombinedSearchPage>(this, std::move(pages)), create_search_bar("Search...", 400)}); } else if(strcmp(plugin_name, "nyaa.si") == 0) { auto categories_nyaa_si_body = create_body(); @@ -1408,7 +1438,7 @@ namespace QuickMedia { window.set_clipboard(str); } - void Program::manga_get_watch_history(const char *plugin_name, BodyItems &history_items) { + void Program::manga_get_watch_history(const char *plugin_name, BodyItems &history_items, bool local_thumbnail) { // TOOD: Make generic, instead of checking for plugin Path content_storage_dir = get_storage_dir().join(plugin_name); if(create_directory_recursive(content_storage_dir) != 0) { @@ -1430,7 +1460,7 @@ namespace QuickMedia { // TODO: Remove this once manga history file has been in use for a few months and is filled with history time_t now = time(NULL); - for_files_in_dir_sort_last_modified(content_storage_dir, [&history_items, plugin_name, &manga_id_to_thumbnail_url_map, now](const Path &filepath) { + for_files_in_dir_sort_last_modified(content_storage_dir, [&history_items, plugin_name, &manga_id_to_thumbnail_url_map, now, local_thumbnail](const Path &filepath) { // This can happen when QuickMedia crashes/is killed while writing to storage. // In that case, the storage wont be corrupt but there will be .tmp files. // TODO: Remove these .tmp files if they exist during startup @@ -1464,6 +1494,7 @@ namespace QuickMedia { if(thumbnail_it != manga_id_to_thumbnail_url_map.end()) { body_item->thumbnail_url = thumbnail_it->second; body_item->thumbnail_size = {101, 141}; + body_item->thumbnail_is_local = local_thumbnail; } if(strcmp(plugin_name, "manganelo") == 0) @@ -1480,6 +1511,8 @@ namespace QuickMedia { body_item->url = "https://onimanga.com/" + manga_id; else if(strcmp(plugin_name, "readm") == 0) body_item->url = "https://readm.org/manga/" + manga_id; + else if(strcmp(plugin_name, "local-manga") == 0) + body_item->url = manga_id; else fprintf(stderr, "Error: Not implemented: filename to manga chapter list\n"); @@ -3434,10 +3467,18 @@ namespace QuickMedia { Path image_filepath_tmp(image_filepath.data + ".tmpz"); // TODO: Move to page - size_t file_size = 0; - if(download_to_file(url, image_filepath_tmp.data, extra_args, true, cloudflare_bypass) != DownloadResult::OK || (is_manganelo && file_get_size(image_filepath_tmp, &file_size) == 0 && file_size < 255)) { - if(!image_download_cancel) show_notification("QuickMedia", "Failed to download image: " + url, Urgency::CRITICAL); - return true; + if(images_page->is_local()) { + int res = symlink(url.c_str(), image_filepath_tmp.data.c_str()); + if(res == -1 && errno != EEXIST) { + show_notification("QuickMedia", "Failed to symlink " + image_filepath_tmp.data + " to " + url); + return true; + } + } else { + size_t file_size = 0; + if(download_to_file(url, image_filepath_tmp.data, extra_args, true, cloudflare_bypass) != DownloadResult::OK || (is_manganelo && file_get_size(image_filepath_tmp, &file_size) == 0 && file_size < 255)) { + if(!image_download_cancel) show_notification("QuickMedia", "Failed to download image: " + url, Urgency::CRITICAL); + return true; + } } bool rename_immediately = true; diff --git a/src/Storage.cpp b/src/Storage.cpp index 2754bc8..a0f20e4 100644 --- a/src/Storage.cpp +++ b/src/Storage.cpp @@ -256,7 +256,7 @@ namespace QuickMedia { } } - void for_files_in_dir_sort_last_modified(const Path &path, FileIteratorCallback callback) { + void for_files_in_dir_sort_last_modified(const Path &path, FileIteratorCallback callback, FileSortDirection sort_dir) { std::vector<std::filesystem::directory_entry> paths; try { for(auto &p : std::filesystem::directory_iterator(path.data)) { @@ -267,9 +267,42 @@ namespace QuickMedia { return; } - std::sort(paths.begin(), paths.end(), [](const std::filesystem::directory_entry &path1, std::filesystem::directory_entry &path2) { - return file_get_filetime_or(path1, std::filesystem::file_time_type::min()) > file_get_filetime_or(path2, std::filesystem::file_time_type::min()); - }); + if(sort_dir == FileSortDirection::ASC) { + std::sort(paths.begin(), paths.end(), [](const std::filesystem::directory_entry &path1, std::filesystem::directory_entry &path2) { + return file_get_filetime_or(path1, std::filesystem::file_time_type::min()) > file_get_filetime_or(path2, std::filesystem::file_time_type::min()); + }); + } else { + std::sort(paths.begin(), paths.end(), [](const std::filesystem::directory_entry &path1, std::filesystem::directory_entry &path2) { + return file_get_filetime_or(path1, std::filesystem::file_time_type::min()) < file_get_filetime_or(path2, std::filesystem::file_time_type::min()); + }); + } + + for(auto &p : paths) { + if(!callback(p.path().string())) + break; + } + } + + void for_files_in_dir_sort_name(const Path &path, FileIteratorCallback callback, FileSortDirection sort_dir) { + std::vector<std::filesystem::directory_entry> paths; + try { + for(auto &p : std::filesystem::directory_iterator(path.data)) { + paths.push_back(p); + } + } catch(const std::filesystem::filesystem_error &err) { + fprintf(stderr, "Failed to list files in directory %s, error: %s\n", path.data.c_str(), err.what()); + return; + } + + if(sort_dir == FileSortDirection::ASC) { + std::sort(paths.begin(), paths.end(), [](const std::filesystem::directory_entry &path1, std::filesystem::directory_entry &path2) { + return path1.path().filename() < path2.path().filename(); + }); + } else { + std::sort(paths.begin(), paths.end(), [](const std::filesystem::directory_entry &path1, std::filesystem::directory_entry &path2) { + return path1.path().filename() > path2.path().filename(); + }); + } for(auto &p : paths) { if(!callback(p.path().string())) diff --git a/src/StringUtils.cpp b/src/StringUtils.cpp index 81ea1eb..927c6e1 100644 --- a/src/StringUtils.cpp +++ b/src/StringUtils.cpp @@ -122,15 +122,43 @@ namespace QuickMedia { } size_t str_find_case_insensitive(const std::string &str, size_t start_index, const char *substr, size_t substr_len) { + if(substr_len == 0) + return 0; + auto it = std::search(str.begin() + start_index, str.end(), substr, substr + substr_len, [](char c1, char c2) { return to_upper(c1) == to_upper(c2); }); + if(it == str.end()) return std::string::npos; + return it - str.begin(); } + // TODO: Support utf-8 case insensitive find + bool string_find_fuzzy_case_insensitive(const std::string &str, const std::string &substr) { + if(substr.empty()) return true; + + size_t str_index = 0; + bool full_match = true; + + string_split(substr, ' ', [&str, &str_index, &full_match](const char *str_part, size_t size) { + if(size == 0) return true; + + size_t found_index = str_find_case_insensitive(str, str_index, str_part, size); + if(found_index == std::string::npos) { + full_match = false; + return false; + } + + str_index = found_index + size; + return true; + }); + + return full_match; + } + char to_upper(char c) { if(c >= 'a' && c <= 'z') return c - 32; @@ -173,6 +201,9 @@ namespace QuickMedia { } bool to_num(const char *str, size_t size, int &num) { + if(size == 0) + return false; + size_t i = 0; const bool is_negative = size > 0 && str[0] == '-'; if(is_negative) @@ -193,6 +224,9 @@ namespace QuickMedia { } bool to_num_hex(const char *str, size_t size, int &num) { + if(size == 0) + return false; + size_t i = 0; const bool is_negative = size > 0 && str[0] == '-'; if(is_negative) diff --git a/src/Theme.cpp b/src/Theme.cpp index 36c8ff7..8320054 100644 --- a/src/Theme.cpp +++ b/src/Theme.cpp @@ -34,6 +34,9 @@ namespace QuickMedia { static void parse_hex_set_color(const Json::Value &json_obj, const char *field_name, mgl::Color &color) { const Json::Value &json_val = json_obj[field_name]; + if(json_val.isNull()) + return; + if(!json_val.isString()) { fprintf(stderr, "Warning: theme variable \"%s\" does not exists or is not a string\n", field_name); return; @@ -69,10 +72,14 @@ namespace QuickMedia { static void get_bool_value(const Json::Value &json_obj, const char *field_name, bool &val) { const Json::Value &json_val = json_obj[field_name]; + if(json_val.isNull()) + return; + if(!json_val.isBool()) { - fprintf(stderr, "Warning: theme variable \"%s\" does not exists or is not a boolean\n", field_name); + fprintf(stderr, "Warning: theme variable \"%s\" is not a boolean\n", field_name); return; } + val = json_val.asBool(); } diff --git a/src/plugins/LocalManga.cpp b/src/plugins/LocalManga.cpp new file mode 100644 index 0000000..06b5cf0 --- /dev/null +++ b/src/plugins/LocalManga.cpp @@ -0,0 +1,195 @@ +#include "../../plugins/LocalManga.hpp" +#include "../../include/Notification.hpp" +#include "../../include/Config.hpp" +#include "../../include/Theme.hpp" +#include "../../include/StringUtils.hpp" +#include "../../include/Storage.hpp" + +namespace QuickMedia { + // Pages are sorted from 1.png to n.png + static std::vector<LocalMangaPage> get_images_in_manga(const Path &directory) { + std::vector<LocalMangaPage> 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<LocalMangaChapter> get_chapters_in_manga(const Path &directory) { + std::vector<LocalMangaChapter> chapter_list; + for_files_in_dir_sort_last_modified(directory, [&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; + }); + return chapter_list; + } + + static std::vector<LocalManga> get_manga_in_directory(const Path &directory) { + std::vector<LocalManga> manga_list; + for_files_in_dir_sort_last_modified(directory, [&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; + }); + return manga_list; + } + + static std::shared_ptr<BodyItem> local_manga_to_body_item(const LocalManga &local_manga, time_t time_now) { + auto body_item = BodyItem::create(local_manga.name); + 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)) + result_items.push_back(local_manga_to_body_item(local_manga, time_now)); + } + return SearchResult::OK; + } + + PluginResult LocalMangaSearchPage::submit(const SubmitArgs &args, std::vector<Tab> &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<LocalMangaChapter> 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<LocalMangaChaptersPage>(program, args.title, 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(); + + 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); + + const time_t time_now = time(nullptr); + for(const LocalManga &local_manga : manga_list) { + result_items.push_back(local_manga_to_body_item(local_manga, time_now)); + } + + return PluginResult::OK; + } + + PluginResult LocalMangaChaptersPage::submit(const SubmitArgs &args, std::vector<Tab> &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 chapter_url = Path(get_config().local_manga_directory).join(content_url).join(args.url); + std::vector<LocalMangaPage> pages = get_images_in_manga(chapter_url); + result_tabs.push_back(Tab{nullptr, std::make_unique<LocalMangaImagesPage>(program, content_title, args.title, args.url, thumbnail_url, std::move(pages)), 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::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&) { + if(!chapter_image_urls.empty()) + return ImageResult::OK; + + for(const LocalMangaPage &local_manga_page : pages) { + chapter_image_urls.push_back(local_manga_page.path.data); + } + return ImageResult::OK; + } +}
\ No newline at end of file diff --git a/src/plugins/MangaCombined.cpp b/src/plugins/MangaCombined.cpp index bc4043f..fca5705 100644 --- a/src/plugins/MangaCombined.cpp +++ b/src/plugins/MangaCombined.cpp @@ -4,7 +4,7 @@ namespace QuickMedia { static const int SEARCH_TIMEOUT_MILLISECONDS = 5000; MangaCombinedSearchPage::MangaCombinedSearchPage(Program *program, std::vector<MangaPlugin> search_pages) : - Page(program), search_pages(std::move(search_pages)) + LazyFetchPage(program), search_pages(std::move(search_pages)) { } @@ -125,6 +125,19 @@ namespace QuickMedia { return page->submit(args, result_tabs); } + PluginResult MangaCombinedSearchPage::lazy_fetch(BodyItems&) { + for(MangaPlugin &manga_plugin : search_pages) { + if(manga_plugin.local_manga) { + LazyFetchPage *lazy_fetch_page = dynamic_cast<LazyFetchPage*>(manga_plugin.page.get()); + if(lazy_fetch_page) { + BodyItems dummy_body_items; + lazy_fetch_page->lazy_fetch(dummy_body_items); + } + } + } + return PluginResult::OK; + } + void MangaCombinedSearchPage::cancel_operation() { for(auto &search_thread : search_threads) { search_thread.second.cancel(); diff --git a/src/plugins/Page.cpp b/src/plugins/Page.cpp index 26c795c..c2e8060 100644 --- a/src/plugins/Page.cpp +++ b/src/plugins/Page.cpp @@ -124,6 +124,7 @@ namespace QuickMedia { if(thumbnail_url_json.isString()) { body_item->thumbnail_url = thumbnail_url_json.asString(); body_item->thumbnail_size = {101, 141}; + body_item->thumbnail_is_local = local_thumbnail; } if(timestamp_json.isInt64()) { |