From 2beeddb325ecbc03ddd6c741449fabd527a3c8cc Mon Sep 17 00:00:00 2001 From: dec05eba Date: Sun, 20 Feb 2022 22:52:28 +0100 Subject: Local-anime: add option to group episodes into anime groups from the name of the anime by using the local_manga.auto_group_episodes config --- src/Config.cpp | 4 + src/QuickMedia.cpp | 2 +- src/plugins/EpisodeNameParser.cpp | 112 +++++++++++++++ src/plugins/LocalAnime.cpp | 278 +++++++++++++++++++++++++++++++------- 4 files changed, 344 insertions(+), 52 deletions(-) create mode 100644 src/plugins/EpisodeNameParser.cpp (limited to 'src') diff --git a/src/Config.cpp b/src/Config.cpp index d6f826b..7ddc1af 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -163,6 +163,10 @@ namespace QuickMedia { const Json::Value &sort_by_name_json = local_anime_json["sort_by_name"]; if(sort_by_name_json.isBool()) config->local_anime.sort_by_name = sort_by_name_json.asBool(); + + const Json::Value &auto_group_episodes_json = local_anime_json["auto_group_episodes"]; + if(auto_group_episodes_json.isBool()) + config->local_anime.auto_group_episodes = auto_group_episodes_json.asBool(); } const Json::Value &use_system_fonts_json = json_root["use_system_fonts"]; diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index 33bab93..5a94326 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -1100,7 +1100,7 @@ namespace QuickMedia { exit(1); } - auto search_page = std::make_unique(this, get_config().local_anime.directory, LocalAnimeSearchPageType::DIRECTORY); + auto search_page = std::make_unique(this, get_anime_in_directory(get_config().local_anime.directory)); tabs.push_back(Tab{create_body(false, true), std::move(search_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); start_tab_index = 0; diff --git a/src/plugins/EpisodeNameParser.cpp b/src/plugins/EpisodeNameParser.cpp new file mode 100644 index 0000000..cbaae50 --- /dev/null +++ b/src/plugins/EpisodeNameParser.cpp @@ -0,0 +1,112 @@ +#include "../../plugins/EpisodeNameParser.hpp" + +namespace QuickMedia { + static bool has_season_in_name(std::string_view episode_name) { + size_t sep_count = 0; + size_t index = 0; + while(true) { + size_t next_index = episode_name.find(" - ", index); + if(next_index == std::string_view::npos) + break; + + index = next_index + 3; + ++sep_count; + } + return sep_count >= 2; + } + + static bool is_whitespace(char c) { + return c == ' ' || c == '\n' || c == '\t' || c == '\v'; + } + + static std::string_view strip_left(std::string_view str) { + size_t i = 0; + for(; i < str.size(); ++i) { + if(!is_whitespace(str[i])) + break; + } + return str.substr(i); + } + + static std::string_view strip_right(std::string_view str) { + long i = (long)str.size() - 1; + for(; i >= 0; --i) { + if(!is_whitespace(str[i])) + break; + } + return str.substr(0, i + 1); + } + + static std::string_view episode_name_extract_group(std::string_view &episode_name) { + episode_name = strip_left(episode_name); + if(episode_name[0] == '[') { + size_t group_end_index = episode_name.find(']', 1); + if(group_end_index == std::string_view::npos) + return {}; + + std::string_view group = episode_name.substr(1, group_end_index - 1); + episode_name.remove_prefix(group_end_index + 1); + return group; + } + return {}; + } + + static std::string_view episode_name_extract_anime(std::string_view &episode_name) { + episode_name = strip_left(episode_name); + size_t episode_or_season_sep_index = episode_name.find(" - "); + if(episode_or_season_sep_index == std::string_view::npos) + episode_or_season_sep_index = episode_name.size(); + + std::string_view anime = episode_name.substr(0, episode_or_season_sep_index); + anime = strip_right(anime); + if(episode_or_season_sep_index + 3 > episode_name.size()) + episode_name = {}; + else + episode_name.remove_prefix(episode_or_season_sep_index + 3); + + return anime; + } + + static std::string_view episode_name_extract_season(std::string_view &episode_name) { + return episode_name_extract_anime(episode_name); + } + + static bool is_num_real_char(char c) { + return (c >= '0' && c <= '9') || c == '.'; + } + + static std::string_view episode_name_extract_episode(std::string_view &episode_name) { + episode_name = strip_left(episode_name); + size_t i = 0; + for(; i < episode_name.size(); ++i) { + if(!is_num_real_char(episode_name[i])) + break; + } + + if(i == 0) + return {}; + + std::string_view episode = episode_name.substr(0, i); + episode_name.remove_prefix(i + 1); + return episode; + } + + std::optional episode_name_extract_parts(std::string_view episode_name) { + EpisodeNameParts name_parts; + const bool has_season = has_season_in_name(episode_name); + + name_parts.group = episode_name_extract_group(episode_name); + name_parts.anime = episode_name_extract_anime(episode_name); + if(name_parts.anime.empty()) + return std::nullopt; + + if(has_season) + name_parts.season = episode_name_extract_season(episode_name); + + name_parts.episode = episode_name_extract_episode(episode_name); + if(name_parts.episode.empty()) + return std::nullopt; + + return name_parts; + } +} \ No newline at end of file diff --git a/src/plugins/LocalAnime.cpp b/src/plugins/LocalAnime.cpp index 53b26f5..8434810 100644 --- a/src/plugins/LocalAnime.cpp +++ b/src/plugins/LocalAnime.cpp @@ -6,6 +6,7 @@ #include "../../include/FileAnalyzer.hpp" #include "../../include/ResourceLoader.hpp" #include "../../include/StringUtils.hpp" +#include "../../plugins/EpisodeNameParser.hpp" #include #include #include @@ -21,7 +22,7 @@ namespace QuickMedia { class LocalAnimeBodyItemData : public BodyItemExtra { public: void draw_overlay(mgl::Window &render_target, const Widgets &widgets) override { - if(!std::holds_alternative(anime_item) || !widgets.thumbnail) + if(!std::holds_alternative(*anime_item) || !widgets.thumbnail) return; const int rect_height = 5; @@ -40,10 +41,149 @@ namespace QuickMedia { render_target.draw(unwatch_rect); } - LocalAnimeItem anime_item; + 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 { @@ -74,7 +214,7 @@ namespace QuickMedia { } LocalAnimeSeason season; - season.path = filepath; + season.name = filepath.filename(); season.episodes = get_episodes_in_directory(filepath); season.modified_time_seconds = modified_time_seconds; if(season.episodes.empty()) @@ -86,7 +226,7 @@ namespace QuickMedia { return anime_items; } - static std::vector get_anime_in_directory(const Path &directory) { + 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; @@ -100,7 +240,7 @@ namespace QuickMedia { } LocalAnime anime; - anime.path = filepath; + anime.name = filepath.filename(); anime.items = get_episodes_or_seasons_in_directory(filepath); anime.modified_time_seconds = modified_time_seconds; if(anime.items.empty()) @@ -115,6 +255,9 @@ namespace QuickMedia { 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; } @@ -133,19 +276,60 @@ namespace QuickMedia { } } + 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.path.data, LocalAnimeSearchPageType::ANIME), create_search_bar("Search...", SEARCH_DELAY_FILTER) }); + 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), 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.path.data, LocalAnimeSearchPageType::SEASON), create_search_bar("Search...", SEARCH_DELAY_FILTER) }); + } 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), 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, episode.path.data, item_data->watch_progress), nullptr }); + } 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, episode.path.data, &item_data->watch_progress), nullptr }); return PluginResult::OK; } return PluginResult::ERR; @@ -154,52 +338,38 @@ namespace QuickMedia { PluginResult LocalAnimeSearchPage::lazy_fetch(BodyItems &result_items) { std::unordered_map watch_progress = get_watch_progress_for_plugin("local-anime"); - std::vector 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; - } - - fprintf(stderr, "num watched animu: %zu\n", watch_progress.size()); - for(auto &it : watch_progress) { - fprintf(stderr, "watch progress: %s, time: %d, duration: %d\n", it.first.c_str(), (int)it.second.time_pos_sec, (int)it.second.duration_sec); - } - const time_t time_now = time(nullptr); - for(LocalAnimeItem &anime_item : anime_items) { - std::string filename_relative_to_anime_dir = get_latest_anime_item(anime_item)->path.data.substr(get_config().local_anime.directory.size() + 1); + 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(); - fprintf(stderr, "watch progress %s: time: %d, duration: %d\n", filename_relative_to_anime_dir.c_str(), (int)body_item_data->watch_progress.time_pos_sec, (int)body_item_data->watch_progress.duration_sec); - 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.path.filename(); + 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("Updated " + seconds_to_relative_time_str(time_now - anime.modified_time_seconds)); + 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.path.data; + 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 = std::move(anime_item); + 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)) { @@ -208,18 +378,23 @@ namespace QuickMedia { std::string title; if(has_finished_watching) title = "[Finished watching] "; - title += season.path.filename(); + 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("Updated " + seconds_to_relative_time_str(time_now - season.modified_time_seconds)); + 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.path.data; + 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 = std::move(anime_item); + 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)) { @@ -241,7 +416,7 @@ namespace QuickMedia { body_item->thumbnail_is_local = true; body_item->thumbnail_url = episode.path.data; - body_item_data->anime_item = std::move(anime_item); + body_item_data->anime_item = &anime_item; body_item->extra = std::move(body_item_data); result_items.push_back(std::move(body_item)); } @@ -256,8 +431,8 @@ namespace QuickMedia { 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 = latest_anime_path.data.substr(get_config().local_anime.directory.size() + 1); + 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)) { @@ -302,14 +477,14 @@ namespace QuickMedia { // 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) + if(watch_progress->time_pos_sec + 10.0 >= watch_progress->duration_sec) return "0"; else - return std::to_string(watch_progress.time_pos_sec); + return std::to_string(watch_progress->time_pos_sec); } void LocalAnimeVideoPage::set_watch_progress(int64_t time_pos_sec) { - std::string filename_relative_to_anime_dir = url.substr(get_config().local_anime.directory.size() + 1); + 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)) { @@ -322,7 +497,8 @@ namespace QuickMedia { return; } - watch_progress.duration_sec = *file_analyzer.get_duration_seconds(); + 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); } } \ No newline at end of file -- cgit v1.2.3