#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 "../../plugins/EpisodeNameParser.hpp" #include #include #include #include namespace QuickMedia { 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(*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(150, 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(150, 150, 150, 255)); render_target.draw(unwatch_rect); } const LocalAnimeItem *anime_item = nullptr; WatchProgress watch_progress; }; static std::string_view anime_item_get_filename(const LocalAnimeItem &item) { if(std::holds_alternative(item)) { const LocalAnime &anime = std::get(item); return anime.name.c_str(); } else if(std::holds_alternative(item)) { const LocalAnimeSeason &season = std::get(item); return season.name.c_str(); } else if(std::holds_alternative(item)) { const LocalAnimeEpisode &episode = std::get(item); return episode.path.filename(); } else { return {}; } } static time_t anime_item_get_modified_timestamp(const LocalAnimeItem &item) { if(std::holds_alternative(item)) { const LocalAnime &anime = std::get(item); return anime.modified_time_seconds; } else if(std::holds_alternative(item)) { const LocalAnimeSeason &season = std::get(item); return season.modified_time_seconds; } else if(std::holds_alternative(item)) { const LocalAnimeEpisode &episode = std::get(item); return episode.modified_time_seconds; } else { return 0; } } static void sort_anime_items_by_name_desc(std::vector &items) { std::sort(items.begin(), items.end(), [](const LocalAnimeItem &item1, const LocalAnimeItem &item2) { return anime_item_get_filename(item1) > anime_item_get_filename(item2); }); } static void sort_anime_items_by_timestamp_asc(std::vector &items) { std::sort(items.begin(), items.end(), [](const LocalAnimeItem &item1, const LocalAnimeItem &item2) { return anime_item_get_modified_timestamp(item1) > anime_item_get_modified_timestamp(item2); }); } static time_t update_modified_time_from_episodes(LocalAnimeItem &item) { if(std::holds_alternative(item)) { LocalAnime &anime = std::get(item); time_t last_modified = 0; for(LocalAnimeItem &item : anime.items) { last_modified = std::max(last_modified, update_modified_time_from_episodes(item)); } anime.modified_time_seconds = last_modified; return last_modified; } else if(std::holds_alternative(item)) { LocalAnimeSeason &season = std::get(item); time_t last_modified = 0; for(LocalAnimeItem &item : season.episodes) { last_modified = std::max(last_modified, update_modified_time_from_episodes(item)); } season.modified_time_seconds = last_modified; return last_modified; } else if(std::holds_alternative(item)) { LocalAnimeEpisode &episode = std::get(item); return episode.modified_time_seconds; } else { return 0; } } static time_t update_modified_time_from_episodes(LocalAnime &anime) { time_t last_modified = 0; for(LocalAnimeItem &item : anime.items) { last_modified = std::max(last_modified, update_modified_time_from_episodes(item)); } anime.modified_time_seconds = last_modified; return last_modified; } struct GroupedAnime { LocalAnime anime; std::unordered_map seasons_by_name; }; static std::vector group_episodes_by_anime_name(std::vector items) { std::unordered_map anime_by_name; std::vector grouped_items; for(LocalAnimeItem &item : items) { if(std::holds_alternative(item)) { const LocalAnimeEpisode &episode = std::get(item); std::optional name_parts = episode_name_extract_parts(episode.path.filename()); if(!name_parts) { grouped_items.push_back(std::move(item)); continue; } GroupedAnime &grouped_anime = anime_by_name[name_parts->anime]; if(grouped_anime.anime.name.empty()) grouped_anime.anime.name = name_parts->anime; if(name_parts->season.empty()) { grouped_anime.anime.items.push_back(std::move(item)); continue; } LocalAnimeSeason &season = grouped_anime.seasons_by_name[name_parts->season]; if(season.name.empty()) season.name = name_parts->season; season.episodes.push_back(std::move(item)); } else { grouped_items.push_back(std::move(item)); } } for(auto anime_it : anime_by_name) { GroupedAnime &grouped_anime = anime_it.second; for(auto season_it : grouped_anime.seasons_by_name) { LocalAnimeSeason &season = season_it.second; sort_anime_items_by_name_desc(season.episodes); grouped_anime.anime.items.push_back(std::move(season)); } update_modified_time_from_episodes(grouped_anime.anime); sort_anime_items_by_name_desc(grouped_anime.anime.items); grouped_items.push_back(std::move(grouped_anime.anime)); } if(get_config().local_anime.sort_by_name) sort_anime_items_by_name_desc(grouped_items); else sort_anime_items_by_timestamp_asc(grouped_items); return grouped_items; } static std::vector get_episodes_in_directory(const Path &directory) { std::vector 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 get_episodes_or_seasons_in_directory(const Path &directory) { std::vector 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.name = filepath.filename(); 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; } std::vector get_anime_in_directory(const Path &directory) { std::vector 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.name = filepath.filename(); 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)); if(get_config().local_anime.auto_group_episodes) anime_items = group_episodes_by_anime_name(std::move(anime_items)); return anime_items; } static const LocalAnimeEpisode* get_latest_anime_item(const LocalAnimeItem &item) { if(std::holds_alternative(item)) { const LocalAnime &anime = std::get(item); return get_latest_anime_item(anime.items.front()); } else if(std::holds_alternative(item)) { const LocalAnimeSeason &season = std::get(item); return get_latest_anime_item(season.episodes.front()); } else if(std::holds_alternative(item)) { const LocalAnimeEpisode &episode = std::get(item); return &episode; } else { return nullptr; } } static std::string get_accumulated_name_to_latest_anime_item(const LocalAnimeItem &item, std::string name) { if(std::holds_alternative(item)) { const LocalAnime &anime = std::get(item); return get_accumulated_name_to_latest_anime_item(anime.items.front(), ""); } else if(std::holds_alternative(item)) { const LocalAnimeSeason &season = std::get(item); if(!name.empty()) name += '/'; name += season.name; return get_accumulated_name_to_latest_anime_item(season.episodes.front(), name); } else if(std::holds_alternative(item)) { const LocalAnimeEpisode &episode = std::get(item); if(!name.empty()) name += '/'; name += "Episode "; std::optional name_parts = episode_name_extract_parts(episode.path.filename()); if(!name_parts) { name += episode.path.filename_no_ext(); return name; } name += name_parts->episode; return name; } else { return ""; } } static std::string get_accumulated_name_to_latest_anime_item(const LocalAnime &anime) { return get_accumulated_name_to_latest_anime_item(anime.items.front(), ""); } static std::string get_accumulated_name_to_latest_anime_item(const LocalAnimeSeason &season) { return get_accumulated_name_to_latest_anime_item(season.episodes.front(), ""); } static std::string anime_path_to_item_name(const std::string &path) { return path.substr(get_config().local_anime.directory.size() + 1); } PluginResult LocalAnimeSearchPage::submit(const SubmitArgs &args, std::vector &result_tabs) { LocalAnimeBodyItemData *item_data = static_cast(args.extra.get()); if(std::holds_alternative(*item_data->anime_item)) { const LocalAnime &anime = std::get(*item_data->anime_item); result_tabs.push_back(Tab{ create_body(), std::make_unique(program, anime.items, this), create_search_bar("Search...", SEARCH_DELAY_FILTER) }); return PluginResult::OK; } else if(std::holds_alternative(*item_data->anime_item)) { const LocalAnimeSeason &season = std::get(*item_data->anime_item); result_tabs.push_back(Tab{ create_body(), std::make_unique(program, season.episodes, this), create_search_bar("Search...", SEARCH_DELAY_FILTER) }); return PluginResult::OK; } else if(std::holds_alternative(*item_data->anime_item)) { const LocalAnimeEpisode &episode = std::get(*item_data->anime_item); result_tabs.push_back(Tab{ nullptr, std::make_unique(program, this, episode.path.data, &item_data->watch_progress), nullptr }); return PluginResult::OK; } return PluginResult::ERR; } PluginResult LocalAnimeSearchPage::lazy_fetch(BodyItems &result_items) { std::unordered_map watch_progress = get_watch_progress_for_plugin("local-anime"); const time_t time_now = time(nullptr); for(const LocalAnimeItem &anime_item : anime_items) { const LocalAnimeEpisode *latest_anime_episode = get_latest_anime_item(anime_item); std::string filename_relative_to_anime_dir = anime_path_to_item_name(latest_anime_episode->path.data); auto body_item_data = std::make_shared(); body_item_data->watch_progress = watch_progress[filename_relative_to_anime_dir]; const bool has_finished_watching = body_item_data->watch_progress.has_finished_watching(); if(std::holds_alternative(anime_item)) { const LocalAnime &anime = std::get(anime_item); std::string title; if(has_finished_watching) title = "[Finished watching] "; title += anime.name; auto body_item = BodyItem::create(std::move(title)); if(has_finished_watching) body_item->set_title_color(finished_watching_color); body_item->set_description("Latest episode: " + get_accumulated_name_to_latest_anime_item(anime) + "\nUpdated: " + 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.name; body_item->thumbnail_is_local = true; body_item->thumbnail_url = latest_anime_episode->path.data; body_item_data->anime_item = &anime_item; body_item->extra = std::move(body_item_data); result_items.push_back(std::move(body_item)); } else if(std::holds_alternative(anime_item)) { const LocalAnimeSeason &season = std::get(anime_item); std::string title; if(has_finished_watching) title = "[Finished watching] "; title += season.name; auto body_item = BodyItem::create(std::move(title)); if(has_finished_watching) body_item->set_title_color(finished_watching_color); body_item->set_description("Latest episode: " + get_accumulated_name_to_latest_anime_item(season) + "\nUpdated: " + 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.name; body_item->thumbnail_is_local = true; body_item->thumbnail_url = latest_anime_episode->path.data; body_item_data->anime_item = &anime_item; body_item->extra = std::move(body_item_data); result_items.push_back(std::move(body_item)); } else if(std::holds_alternative(anime_item)) { const LocalAnimeEpisode &episode = std::get(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 = &anime_item; body_item->extra = std::move(body_item_data); result_items.push_back(std::move(body_item)); } } return PluginResult::OK; } void LocalAnimeSearchPage::toggle_read(BodyItem *selected_item) { if(!selected_item) return; LocalAnimeBodyItemData *item_data = static_cast(selected_item->extra.get()); Path latest_anime_path = get_latest_anime_item(*item_data->anime_item)->path; std::string filename_relative_to_anime_dir = anime_path_to_item_name(latest_anime_path.data); FileAnalyzer file_analyzer; if(!file_analyzer.load_file(latest_anime_path.data.c_str(), true)) { show_notification("QuickMedia", "Failed to load " + filename_relative_to_anime_dir + " to set watch progress", Urgency::CRITICAL); return; } if(!file_analyzer.get_duration_seconds()) { show_notification("QuickMedia", "Failed to get duration of " + filename_relative_to_anime_dir + " to set watch progress", Urgency::CRITICAL); return; } WatchedStatus watch_status; if(!toggle_watched_for_plugin_save_to_file("local-anime", filename_relative_to_anime_dir, *file_analyzer.get_duration_seconds(), latest_anime_path.data, 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; item_data->watch_progress.time_pos_sec = *file_analyzer.get_duration_seconds(); item_data->watch_progress.duration_sec = *file_analyzer.get_duration_seconds(); } else { item_data->watch_progress.time_pos_sec = 0; item_data->watch_progress.duration_sec = *file_analyzer.get_duration_seconds(); } 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_pos_sec + 10.0 >= watch_progress->duration_sec) return "0"; else return std::to_string(watch_progress->time_pos_sec); } static void refresh_pages_recursive(LocalAnimeSearchPage *search_page) { while(search_page) { search_page->needs_refresh = true; search_page = search_page->parent_search_page; } } void LocalAnimeVideoPage::set_watch_progress(int64_t time_pos_sec, int64_t) { std::string filename_relative_to_anime_dir = anime_path_to_item_name(url); FileAnalyzer file_analyzer; if(!file_analyzer.load_file(url.c_str(), true)) { show_notification("QuickMedia", "Failed to load " + filename_relative_to_anime_dir + " to set watch progress", Urgency::CRITICAL); return; } if(!file_analyzer.get_duration_seconds()) { show_notification("QuickMedia", "Failed to get duration of " + filename_relative_to_anime_dir + " to set watch progress", Urgency::CRITICAL); return; } refresh_pages_recursive(search_page); watch_progress->time_pos_sec = time_pos_sec; watch_progress->duration_sec = *file_analyzer.get_duration_seconds(); set_watch_progress_for_plugin("local-anime", filename_relative_to_anime_dir, time_pos_sec, *file_analyzer.get_duration_seconds(), url); } }