diff options
author | dec05eba <dec05eba@protonmail.com> | 2022-02-17 19:18:19 +0100 |
---|---|---|
committer | dec05eba <dec05eba@protonmail.com> | 2022-02-17 19:18:34 +0100 |
commit | 02e029ed40f801e0710b09062069e7083cd30b93 (patch) | |
tree | b3bd567ad0c03074064d62d32a876920aa58fab4 /src/plugins | |
parent | d4cd63129ae5dff8fd69525424e0f8cb9ae1a905 (diff) |
Add local anime tracking. Check readme for more info about local_anime config
Diffstat (limited to 'src/plugins')
-rw-r--r-- | src/plugins/LocalAnime.cpp | 392 | ||||
-rw-r--r-- | src/plugins/LocalManga.cpp | 46 | ||||
-rw-r--r-- | src/plugins/Matrix.cpp | 1 | ||||
-rw-r--r-- | src/plugins/Page.cpp | 2 |
4 files changed, 382 insertions, 59 deletions
diff --git a/src/plugins/LocalAnime.cpp b/src/plugins/LocalAnime.cpp index 4bc296a..9b1205a 100644 --- a/src/plugins/LocalAnime.cpp +++ b/src/plugins/LocalAnime.cpp @@ -1,42 +1,408 @@ #include "../../plugins/LocalAnime.hpp" #include "../../include/Config.hpp" +#include "../../include/Theme.hpp" #include "../../include/Storage.hpp" #include "../../include/Notification.hpp" +#include "../../include/FileAnalyzer.hpp" +#include "../../include/ResourceLoader.hpp" +#include "../../include/StringUtils.hpp" +#include <mglpp/graphics/Rectangle.hpp> +#include <mglpp/window/Window.hpp> +#include <json/value.h> +#include <math.h> namespace QuickMedia { - static bool validate_local_anime_dir_config_is_set() { - if(get_config().local_anime.directory.empty()) { - show_notification("QuickMedia", "local_anime.directory config is not set", Urgency::CRITICAL); - return false; + static const mgl::Color finished_watching_color = mgl::Color(43, 255, 47); + + static float floor(float v) { + return (int)v; + } + + class LocalAnimeBodyItemData : public BodyItemExtra { + public: + void draw_overlay(mgl::Window &render_target, const Widgets &widgets) override { + if(!std::holds_alternative<LocalAnimeEpisode>(anime_item) || !widgets.thumbnail) + return; + + const int rect_height = 5; + const double watch_ratio = watch_progress.get_watch_ratio(); + + mgl::Rectangle watch_rect; + watch_rect.set_position({ widgets.thumbnail->position.x, widgets.thumbnail->position.y + widgets.thumbnail->size.y - rect_height }); + watch_rect.set_size({ floor(widgets.thumbnail->size.x * watch_ratio), rect_height }); + watch_rect.set_color(mgl::Color(255, 0, 0, 255)); + render_target.draw(watch_rect); + + mgl::Rectangle unwatch_rect; + unwatch_rect.set_position({ floor(widgets.thumbnail->position.x + widgets.thumbnail->size.x * watch_ratio), widgets.thumbnail->position.y + widgets.thumbnail->size.y - rect_height }); + unwatch_rect.set_size({ floor(widgets.thumbnail->size.x * (1.0 - watch_ratio)), rect_height }); + unwatch_rect.set_color(mgl::Color(255, 255, 255, 255)); + render_target.draw(unwatch_rect); + } + + LocalAnimeItem anime_item; + LocalAnimeWatchProgress watch_progress; + }; + + static std::vector<LocalAnimeItem> get_episodes_in_directory(const Path &directory) { + std::vector<LocalAnimeItem> episodes; + for_files_in_dir_sort_name(directory, [&episodes](const Path &filepath, FileType file_type) -> bool { + if(file_type != FileType::REGULAR || !is_video_ext(filepath.ext())) + return true; + + time_t modified_time_seconds; + if(!file_get_last_modified_time_seconds(filepath.data.c_str(), &modified_time_seconds)) + return true; + + episodes.push_back(LocalAnimeEpisode{ filepath, modified_time_seconds }); + return true; + }, FileSortDirection::DESC); + return episodes; + } + + static std::vector<LocalAnimeItem> get_episodes_or_seasons_in_directory(const Path &directory) { + std::vector<LocalAnimeItem> anime_items; + for_files_in_dir_sort_name(directory, [&](const Path &filepath, FileType file_type) -> bool { + time_t modified_time_seconds; + if(!file_get_last_modified_time_seconds(filepath.data.c_str(), &modified_time_seconds)) + return true; + + if(file_type == FileType::REGULAR) { + if(is_video_ext(filepath.ext())) + anime_items.push_back(LocalAnimeEpisode{ filepath, modified_time_seconds }); + return true; + } + + LocalAnimeSeason season; + season.path = filepath; + season.episodes = get_episodes_in_directory(filepath); + season.modified_time_seconds = modified_time_seconds; + if(season.episodes.empty()) + return true; + + anime_items.push_back(std::move(season)); + return true; + }, FileSortDirection::DESC); + return anime_items; + } + + static std::vector<LocalAnimeItem> get_anime_in_directory(const Path &directory) { + std::vector<LocalAnimeItem> anime_items; + auto callback = [&anime_items](const Path &filepath, FileType file_type) -> bool { + time_t modified_time_seconds; + if(!file_get_last_modified_time_seconds(filepath.data.c_str(), &modified_time_seconds)) + return true; + + if(file_type == FileType::REGULAR) { + if(is_video_ext(filepath.ext())) + anime_items.push_back(LocalAnimeEpisode{ filepath, modified_time_seconds }); + return true; + } + + LocalAnime anime; + anime.path = filepath; + anime.items = get_episodes_or_seasons_in_directory(filepath); + anime.modified_time_seconds = modified_time_seconds; + if(anime.items.empty()) + return true; + + anime_items.push_back(std::move(anime)); + return true; + }; + + if(get_config().local_anime.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 anime_items; + } + + static const LocalAnimeEpisode* get_latest_anime_item(const LocalAnimeItem &item) { + if(std::holds_alternative<LocalAnime>(item)) { + const LocalAnime &anime = std::get<LocalAnime>(item); + return get_latest_anime_item(anime.items.front()); + } else if(std::holds_alternative<LocalAnimeSeason>(item)) { + const LocalAnimeSeason &season = std::get<LocalAnimeSeason>(item); + return get_latest_anime_item(season.episodes.front()); + } else if(std::holds_alternative<LocalAnimeEpisode>(item)) { + const LocalAnimeEpisode &episode = std::get<LocalAnimeEpisode>(item); + return &episode; + } else { + return nullptr; + } + } + + static LocalAnimeWatchProgress get_watch_progress(const Json::Value &watched_json, const LocalAnimeItem &item) { + LocalAnimeWatchProgress progress; + Path latest_anime_path = get_latest_anime_item(item)->path; + + std::string filename_relative_to_anime_dir = latest_anime_path.data.substr(get_config().local_anime.directory.size() + 1); + const Json::Value *found_watched_item = watched_json.find( + filename_relative_to_anime_dir.data(), + filename_relative_to_anime_dir.data() + filename_relative_to_anime_dir.size()); + if(!found_watched_item || !found_watched_item->isObject()) + return progress; + + const Json::Value &time_json = (*found_watched_item)["time"]; + const Json::Value &duration_json = (*found_watched_item)["duration"]; + if(!time_json.isInt64() || !duration_json.isInt64() || duration_json.asInt64() == 0) + return progress; + + // We consider having watched the anime if the user stopped watching 90% in, because they might skip the ending theme/credits + progress.time = (double)time_json.asInt64(); + progress.duration = (double)duration_json.asInt64(); + return progress; + } + + enum class WatchedStatus { + WATCHED, + NOT_WATCHED + }; + + static bool toggle_watched_save_to_file(const Path &filepath, WatchedStatus &watched_status) { + Path local_anime_progress_path = get_storage_dir().join("watch-progress").join("local-anime"); + + Json::Value json_root; + if(!read_file_as_json(local_anime_progress_path, json_root) || !json_root.isObject()) + json_root = Json::Value(Json::objectValue); + + bool watched = false; + std::string filename_relative_to_anime_dir = filepath.data.substr(get_config().local_anime.directory.size() + 1); + Json::Value &watched_item = json_root[filename_relative_to_anime_dir]; + if(watched_item.isObject()) { + watched = true; + } else { + watched_item = Json::Value(Json::objectValue); + watched = false; + } + + if(watched) { + json_root.removeMember(filename_relative_to_anime_dir.c_str()); + } else { + FileAnalyzer file_analyzer; + if(!file_analyzer.load_file(filepath.data.c_str(), true)) { + show_notification("QuickMedia", "Failed to mark " + filename_relative_to_anime_dir + " as watched", Urgency::CRITICAL); + return false; + } + + if(!file_analyzer.get_duration_seconds() || *file_analyzer.get_duration_seconds() == 0) { + show_notification("QuickMedia", "Failed to mark " + filename_relative_to_anime_dir + " as watched", Urgency::CRITICAL); + return false; + } + + watched_item["time"] = (int64_t)*file_analyzer.get_duration_seconds(); + watched_item["duration"] = (int64_t)*file_analyzer.get_duration_seconds(); + watched_item["thumbnail_url"] = filename_relative_to_anime_dir; + watched_item["timestamp"] = (int64_t)time(nullptr); } - if(get_file_type(get_config().local_anime.directory) != FileType::DIRECTORY) { - show_notification("QuickMedia", "local_anime.directory config is not set to a valid directory", Urgency::CRITICAL); + if(!save_json_to_file_atomic(local_anime_progress_path, json_root)) { + show_notification("QuickMedia", "Failed to mark " + filename_relative_to_anime_dir + " as " + (watched ? "not watched" : "watched"), Urgency::CRITICAL); return false; } + watched_status = watched ? WatchedStatus::NOT_WATCHED : WatchedStatus::WATCHED; return true; } + double LocalAnimeWatchProgress::get_watch_ratio() const { + if(duration == 0.0) + return 0.0; + return (double)time / (double)duration; + } + + // We consider having watched the anime if the user stopped watching 90% in, because they might skip the ending theme/credits + bool LocalAnimeWatchProgress::has_finished_watching() const { + return get_watch_ratio() >= 0.9; + } + PluginResult LocalAnimeSearchPage::submit(const SubmitArgs &args, std::vector<Tab> &result_tabs) { - if(!validate_local_anime_dir_config_is_set()) + LocalAnimeBodyItemData *item_data = static_cast<LocalAnimeBodyItemData*>(args.extra.get()); + if(std::holds_alternative<LocalAnime>(item_data->anime_item)) { + const LocalAnime &anime = std::get<LocalAnime>(item_data->anime_item); + result_tabs.push_back(Tab{ create_body(), std::make_unique<LocalAnimeSearchPage>(program, anime.path.data, LocalAnimeSearchPageType::ANIME), create_search_bar("Search...", SEARCH_DELAY_FILTER) }); return PluginResult::OK; - + } else if(std::holds_alternative<LocalAnimeSeason>(item_data->anime_item)) { + const LocalAnimeSeason &season = std::get<LocalAnimeSeason>(item_data->anime_item); + result_tabs.push_back(Tab{ create_body(), std::make_unique<LocalAnimeSearchPage>(program, season.path.data, LocalAnimeSearchPageType::SEASON), create_search_bar("Search...", SEARCH_DELAY_FILTER) }); + return PluginResult::OK; + } else if(std::holds_alternative<LocalAnimeEpisode>(item_data->anime_item)) { + const LocalAnimeEpisode &episode = std::get<LocalAnimeEpisode>(item_data->anime_item); + result_tabs.push_back(Tab{ nullptr, std::make_unique<LocalAnimeVideoPage>(program, episode.path.data, item_data->watch_progress), nullptr }); + return PluginResult::OK; + } return PluginResult::ERR; } PluginResult LocalAnimeSearchPage::lazy_fetch(BodyItems &result_items) { - if(!validate_local_anime_dir_config_is_set()) - return PluginResult::OK; + Json::Value json_root; + if(!read_file_as_json(get_storage_dir().join("watch-progress").join("local-anime"), json_root) || !json_root.isObject()) + json_root = Json::Value(Json::objectValue); - return PluginResult::ERR; + std::vector<LocalAnimeItem> anime_items; + switch(type) { + case LocalAnimeSearchPageType::DIRECTORY: + anime_items = get_anime_in_directory(directory); + break; + case LocalAnimeSearchPageType::ANIME: + anime_items = get_episodes_or_seasons_in_directory(directory); + break; + case LocalAnimeSearchPageType::SEASON: + anime_items = get_episodes_in_directory(directory); + break; + } + + const time_t time_now = time(nullptr); + for(LocalAnimeItem &anime_item : anime_items) { + auto body_item_data = std::make_shared<LocalAnimeBodyItemData>(); + body_item_data->watch_progress = get_watch_progress(json_root, anime_item); + const bool has_finished_watching = body_item_data->watch_progress.has_finished_watching(); + + if(std::holds_alternative<LocalAnime>(anime_item)) { + const LocalAnime &anime = std::get<LocalAnime>(anime_item); + + std::string title; + if(has_finished_watching) + title = "[Finished watching] "; + title += anime.path.filename(); + + auto body_item = BodyItem::create(std::move(title)); + if(has_finished_watching) + body_item->set_title_color(finished_watching_color); + + body_item->set_description("Updated " + seconds_to_relative_time_str(time_now - anime.modified_time_seconds)); + body_item->set_description_color(get_theme().faded_text_color); + + body_item->url = anime.path.data; + + body_item_data->anime_item = std::move(anime_item); + body_item->extra = std::move(body_item_data); + result_items.push_back(std::move(body_item)); + } else if(std::holds_alternative<LocalAnimeSeason>(anime_item)) { + const LocalAnimeSeason &season = std::get<LocalAnimeSeason>(anime_item); + + std::string title; + if(has_finished_watching) + title = "[Finished watching] "; + title += season.path.filename(); + + auto body_item = BodyItem::create(std::move(title)); + if(has_finished_watching) + body_item->set_title_color(finished_watching_color); + + body_item->set_description("Updated " + seconds_to_relative_time_str(time_now - season.modified_time_seconds)); + body_item->set_description_color(get_theme().faded_text_color); + + body_item->url = season.path.data; + + body_item_data->anime_item = std::move(anime_item); + body_item->extra = std::move(body_item_data); + result_items.push_back(std::move(body_item)); + } else if(std::holds_alternative<LocalAnimeEpisode>(anime_item)) { + const LocalAnimeEpisode &episode = std::get<LocalAnimeEpisode>(anime_item); + + std::string title; + if(has_finished_watching) + title = "[Finished watching] "; + title += episode.path.filename(); + + auto body_item = BodyItem::create(std::move(title)); + if(has_finished_watching) + body_item->set_title_color(finished_watching_color); + + body_item->set_description("Updated " + seconds_to_relative_time_str(time_now - episode.modified_time_seconds)); + body_item->set_description_color(get_theme().faded_text_color); + + body_item->url = episode.path.data; + body_item->thumbnail_is_local = true; + body_item->thumbnail_url = episode.path.data; + + body_item_data->anime_item = std::move(anime_item); + body_item->extra = std::move(body_item_data); + result_items.push_back(std::move(body_item)); + } + } + + return PluginResult::OK; } std::shared_ptr<BodyItem> LocalAnimeSearchPage::get_bookmark_body_item(BodyItem *selected_item) { - return nullptr; + if(!selected_item) + return nullptr; + + std::string filename_relative_to_anime_dir = selected_item->url.substr(get_config().local_anime.directory.size() + 1); + auto body_item = BodyItem::create(filename_relative_to_anime_dir); + body_item->url = filename_relative_to_anime_dir; + body_item->thumbnail_url = selected_item->thumbnail_url; + return body_item; } void LocalAnimeSearchPage::toggle_read(BodyItem *selected_item) { - // TODO: + if(!selected_item) + return; + + LocalAnimeBodyItemData *item_data = static_cast<LocalAnimeBodyItemData*>(selected_item->extra.get()); + WatchedStatus watch_status; + if(!toggle_watched_save_to_file(get_latest_anime_item(item_data->anime_item)->path, watch_status)) + return; + + mgl::Color color = get_theme().text_color; + std::string title; + if(watch_status == WatchedStatus::WATCHED) { + title = "[Finished watching] "; + color = finished_watching_color; + } + title += Path(selected_item->url).filename(); + + selected_item->set_title(std::move(title)); + selected_item->set_title_color(color); + } + + std::string LocalAnimeVideoPage::get_video_url(int, bool &has_embedded_audio, std::string &ext) { + ext = Path(url).ext(); + has_embedded_audio = true; + return url; + } + + std::string LocalAnimeVideoPage::get_url_timestamp() { + // If we are very close to the end then start from the beginning. + // This is the same behavior as mpv. + // This is better because we dont want the video player to stop immediately after we start playing and we dont get any chance to seek. + if(watch_progress.time + 10.0 >= watch_progress.duration) + return "0"; + else + return std::to_string(watch_progress.time); + } + + void LocalAnimeVideoPage::set_watch_progress(double time_pos_sec) { + std::string filename_relative_to_anime_dir = url.substr(get_config().local_anime.directory.size() + 1); + + Path watch_progress_dir = get_storage_dir().join("watch-progress"); + if(create_directory_recursive(watch_progress_dir) != 0) { + show_notification("QuickMedia", "Failed to create " + watch_progress_dir.data + " to set watch progress for " + filename_relative_to_anime_dir, Urgency::CRITICAL); + return; + } + + Path local_anime_progress_path = watch_progress_dir; + local_anime_progress_path.join("local-anime"); + + Json::Value json_root; + if(!read_file_as_json(local_anime_progress_path, json_root) || !json_root.isObject()) + json_root = Json::Value(Json::objectValue); + + Json::Value watch_progress_json(Json::objectValue); + watch_progress_json["time"] = (int64_t)time_pos_sec; + watch_progress_json["duration"] = (int64_t)watch_progress.duration; + watch_progress_json["thumbnail_url"] = filename_relative_to_anime_dir; + watch_progress_json["timestamp"] = (int64_t)time(nullptr); + json_root[filename_relative_to_anime_dir] = std::move(watch_progress_json); + + if(!save_json_to_file_atomic(local_anime_progress_path, json_root)) { + show_notification("QuickMedia", "Failed to set watch progress for " + filename_relative_to_anime_dir, Urgency::CRITICAL); + return; + } + + fprintf(stderr, "Set watch progress for \"%s\" to %d/%d\n", filename_relative_to_anime_dir.c_str(), (int)time_pos_sec, (int)watch_progress.duration); } }
\ No newline at end of file diff --git a/src/plugins/LocalManga.cpp b/src/plugins/LocalManga.cpp index 4367401..3fc7269 100644 --- a/src/plugins/LocalManga.cpp +++ b/src/plugins/LocalManga.cpp @@ -9,6 +9,8 @@ #include <json/value.h> #include <dirent.h> +// 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 @@ -418,47 +420,6 @@ namespace QuickMedia { selected_item->set_title_color(color); } - static std::unordered_set<std::string> get_lines_in_file(const Path &filepath) { - std::unordered_set<std::string> 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<std::string> 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<Tab> &result_tabs) { if(!validate_local_manga_dir_config_is_set()) return PluginResult::OK; @@ -484,9 +445,6 @@ namespace QuickMedia { chapter_image_urls.push_back(local_manga_page.path.data); } - if(is_program_executable_by_name("automedia")) - append_seen_manga_to_automedia_seen(manga_name + "/" + url); - num_images = chapter_image_urls.size(); return ImageResult::OK; } diff --git a/src/plugins/Matrix.cpp b/src/plugins/Matrix.cpp index 367c777..144bbd8 100644 --- a/src/plugins/Matrix.cpp +++ b/src/plugins/Matrix.cpp @@ -14,7 +14,6 @@ #include <rapidjson/stringbuffer.h> #include <rapidjson/filereadstream.h> #include <rapidjson/filewritestream.h> -#include <cmath> #include <fcntl.h> #include <unistd.h> #include <malloc.h> diff --git a/src/plugins/Page.cpp b/src/plugins/Page.cpp index c2e8060..654c983 100644 --- a/src/plugins/Page.cpp +++ b/src/plugins/Page.cpp @@ -123,7 +123,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_size = thumbnail_size; body_item->thumbnail_is_local = local_thumbnail; } |