aboutsummaryrefslogtreecommitdiff
path: root/src/plugins
diff options
context:
space:
mode:
authordec05eba <dec05eba@protonmail.com>2022-02-17 19:18:19 +0100
committerdec05eba <dec05eba@protonmail.com>2022-02-17 19:18:34 +0100
commit02e029ed40f801e0710b09062069e7083cd30b93 (patch)
treeb3bd567ad0c03074064d62d32a876920aa58fab4 /src/plugins
parentd4cd63129ae5dff8fd69525424e0f8cb9ae1a905 (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.cpp392
-rw-r--r--src/plugins/LocalManga.cpp46
-rw-r--r--src/plugins/Matrix.cpp1
-rw-r--r--src/plugins/Page.cpp2
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;
}