From b31bbdf6cf133f16752a7b0f78cbe9ae2fa667e2 Mon Sep 17 00:00:00 2001 From: dec05eba Date: Sat, 15 Aug 2020 20:46:59 +0200 Subject: Add youtube recommendations based on unwatched related videos --- include/QuickMedia.hpp | 2 + src/QuickMedia.cpp | 156 ++++++++++++++++++++++++++++++++++++++++++++--- src/plugins/Mangadex.cpp | 2 + src/plugins/Youtube.cpp | 5 +- 4 files changed, 155 insertions(+), 10 deletions(-) diff --git a/include/QuickMedia.hpp b/include/QuickMedia.hpp index 9bf2d0f..d055c1a 100644 --- a/include/QuickMedia.hpp +++ b/include/QuickMedia.hpp @@ -59,6 +59,7 @@ namespace QuickMedia { void plugin_get_watch_history(Plugin *plugin, BodyItems &history_items); Json::Value load_video_history_json(Plugin *plugin); + Json::Value load_recommended_json(Plugin *plugin); private: Display *disp; sf::RenderWindow window; @@ -96,5 +97,6 @@ namespace QuickMedia { bool use_system_mpv_config = false; // TODO: Save this to config file when switching modes ImageViewMode image_view_mode = ImageViewMode::SINGLE; + BodyItems related_media; }; } \ No newline at end of file diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index d3627e1..c1886f3 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -397,6 +397,68 @@ namespace QuickMedia { } } + static void fill_recommended_items_from_json(const Json::Value &recommended_json, BodyItems &body_items) { + assert(recommended_json.isObject()); + + std::vector> recommended_items(recommended_json.size()); + /* TODO: Optimize member access */ + for(auto &member_name : recommended_json.getMemberNames()) { + Json::Value recommended_item = recommended_json[member_name]; + if(recommended_item.isObject()) + recommended_items.push_back(std::make_pair(member_name, std::move(recommended_item))); + } + + /* TODO: Better algorithm for recommendations */ + std::sort(recommended_items.begin(), recommended_items.end(), [](std::pair &a, std::pair &b) { + Json::Value &a_timestamp_json = a.second["recommended_timestamp"]; + Json::Value &b_timestamp_json = b.second["recommended_timestamp"]; + int64_t a_timestamp = 0; + int64_t b_timestamp = 0; + if(a_timestamp_json.isNumeric()) + a_timestamp = a_timestamp_json.asInt64(); + if(b_timestamp_json.isNumeric()) + b_timestamp = b_timestamp_json.asInt64(); + + Json::Value &a_recommended_count_json = a.second["recommended_count"]; + Json::Value &b_recommended_count_json = b.second["recommended_count"]; + int64_t a_recommended_count = 0; + int64_t b_recommended_count = 0; + if(a_recommended_count_json.isNumeric()) + a_recommended_count = a_recommended_count_json.asInt64(); + if(b_recommended_count_json.isNumeric()) + b_recommended_count = b_recommended_count_json.asInt64(); + + /* Put frequently recommended videos on top of recommendations. Each recommendation count is worth 5 minutes */ + a_timestamp += (300 * a_recommended_count); + b_timestamp += (300 * b_recommended_count); + + return a_timestamp > b_timestamp; + }); + + for(auto it = recommended_items.begin(); it != recommended_items.end(); ++it) { + const std::string &recommended_item_id = it->first; + Json::Value &recommended_item = it->second; + + int64_t watched_count = 0; + const Json::Value &watched_count_json = recommended_item["watched_count"]; + if(watched_count_json.isNumeric()) + watched_count = watched_count_json.asInt64(); + + /* TODO: Improve recommendations with some kind of algorithm. Videos we have seen should be recommended in some cases */ + if(watched_count != 0) + continue; + + const Json::Value &recommended_title_json = recommended_item["title"]; + if(!recommended_title_json.isString()) + continue; + + auto body_item = std::make_unique(recommended_title_json.asString()); + body_item->url = "https://www.youtube.com/watch?v=" + recommended_item_id; + body_item->thumbnail_url = "https://img.youtube.com/vi/" + recommended_item_id + "/hqdefault.jpg"; + body_items.push_back(std::move(body_item)); + } + } + static Path get_video_history_filepath(Plugin *plugin) { Path video_history_dir = get_storage_dir().join("history"); if(create_directory_recursive(video_history_dir) != 0) { @@ -410,7 +472,21 @@ namespace QuickMedia { return video_history_filepath.join(plugin->name).append(".json"); } + static Path get_recommended_filepath(Plugin *plugin) { + Path video_history_dir = get_storage_dir().join("recommended"); + if(create_directory_recursive(video_history_dir) != 0) { + std::string err_msg = "Failed to create recommended directory "; + err_msg += video_history_dir.data; + show_notification("QuickMedia", err_msg.c_str(), Urgency::CRITICAL); + exit(1); + } + + Path video_history_filepath = video_history_dir; + return video_history_filepath.join(plugin->name).append(".json"); + } + // This is not cached because we could have multiple instances of QuickMedia running the same plugin! + // TODO: Find a way to optimize this Json::Value Program::load_video_history_json(Plugin *plugin) { Path video_history_filepath = get_video_history_filepath(plugin); Json::Value json_result; @@ -419,6 +495,16 @@ namespace QuickMedia { return json_result; } + // This is not cached because we could have multiple instances of QuickMedia running the same plugin! + // TODO: Find a way to optimize this + Json::Value Program::load_recommended_json(Plugin *plugin) { + Path recommended_filepath = get_recommended_filepath(plugin); + Json::Value json_result; + if(!read_file_as_json(recommended_filepath, json_result) || !json_result.isObject()) + json_result = Json::Value(Json::objectValue); + return json_result; + } + void Program::plugin_get_watch_history(Plugin *plugin, BodyItems &history_items) { // TOOD: Make generic, instead of checking for plugin if(plugin->is_manga()) { @@ -506,10 +592,11 @@ namespace QuickMedia { sf::Text history_tab_text("History", font, tab_text_size); sf::Text recommended_tab_text("Recommended", font, tab_text_size); - // if(current_plugin->name == "youtube") { - // recommended_body = std::make_unique(this, font, bold_font); - // recommended_body->draw_thumbnails = true; - // } + if(current_plugin->name == "youtube") { + recommended_body = std::make_unique(this, font, bold_font); + recommended_body->draw_thumbnails = true; + fill_recommended_items_from_json(load_recommended_json(current_plugin), recommended_body->items); + } struct Tab { Body *body; @@ -604,6 +691,7 @@ namespace QuickMedia { }; std::future recommended_future; + /* if(recommended_body) { recommended_future = std::async(std::launch::async, [this]() { BodyItems body_items; @@ -611,9 +699,10 @@ namespace QuickMedia { return body_items; }); } else { + */ PluginResult front_page_result = current_plugin->get_front_page(body->items); body->clamp_selection(); - } + /*}*/ sf::Vector2f body_pos; sf::Vector2f body_size; @@ -697,11 +786,13 @@ namespace QuickMedia { autocomplete_running = false; } + /* if(recommended_future.valid() && recommended_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { recommended_body->items = recommended_future.get(); recommended_body->filter_search_fuzzy(recommended_filter); recommended_body->clamp_selection(); } + */ window.clear(back_color); { @@ -897,6 +988,8 @@ namespace QuickMedia { show_notification("Video player", err_msg.c_str(), Urgency::CRITICAL); current_page = previous_page; } else { + related_media = current_plugin->get_related_media(content_url); + // TODO: Make this also work for other video plugins if(current_plugin->name != "youtube") return; @@ -915,18 +1008,65 @@ namespace QuickMedia { int existing_index = watch_history_get_item_by_id(video_history_json, video_id.c_str()); if(existing_index != -1) { Json::Value removed; + /* TODO: Optimize. This is slow */ video_history_json.removeIndex(existing_index, &removed); } + time_t time_now = time(NULL); + Json::Value new_content_object(Json::objectValue); new_content_object["id"] = video_id; new_content_object["title"] = content_title; - new_content_object["timestamp"] = time(NULL); + new_content_object["timestamp"] = time_now; - video_history_json.append(new_content_object); + video_history_json.append(std::move(new_content_object)); Path video_history_filepath = get_video_history_filepath(current_plugin); save_json_to_file_atomic(video_history_filepath, video_history_json); + + Json::Value recommended_json = load_recommended_json(current_plugin); + for(const auto &body_item : related_media) { + std::string recommended_video_id; + if(youtube_url_extract_id(body_item->url, recommended_video_id)) { + Json::Value &existing_recommendation = recommended_json[recommended_video_id]; + if(existing_recommendation.isObject()) { + int64_t recommended_count = 0; + Json::Value &count_json = existing_recommendation["recommended_count"]; + if(count_json.isNumeric()) + recommended_count = count_json.asInt64(); + existing_recommendation["recommended_count"] = recommended_count + 1; + existing_recommendation["recommended_timestamp"] = time_now; + } else { + Json::Value new_content_object(Json::objectValue); + new_content_object["title"] = body_item->title; + new_content_object["recommended_timestamp"] = time_now; + new_content_object["recommended_count"] = 1; + recommended_json[recommended_video_id] = std::move(new_content_object); + } + } else { + fprintf(stderr, "Failed to extract id of youtube url %s, video wont be saved in recommendations\n", content_url.c_str()); + } + } + + Json::Value &existing_recommended_json = recommended_json[video_id]; + if(existing_recommended_json.isObject()) { + int64_t watched_count = 0; + Json::Value &watched_count_json = existing_recommended_json["watched_count"]; + if(watched_count_json.isNumeric()) + watched_count = watched_count_json.asInt64(); + existing_recommended_json["watched_count"] = watched_count + 1; + existing_recommended_json["watched_timestamp"] = time_now; + } else { + Json::Value new_content_object(Json::objectValue); + new_content_object["title"] = content_title; + new_content_object["recommended_timestamp"] = time_now; + new_content_object["recommended_count"] = 1; + new_content_object["watched_count"] = 1; + new_content_object["watched_timestamp"] = time_now; + recommended_json[video_id] = std::move(new_content_object); + } + + save_json_to_file_atomic(get_recommended_filepath(current_plugin), recommended_json); } }; @@ -948,8 +1088,8 @@ namespace QuickMedia { has_video_started = false; std::string new_video_url; std::string new_video_title; - BodyItems related_media = current_plugin->get_related_media(content_url); // Find video that hasn't been played before in this video session + // TODO: Remove duplicates for(auto it = related_media.begin(), end = related_media.end(); it != end; ++it) { if(watched_videos.find((*it)->url) == watched_videos.end()) { new_video_url = (*it)->url; diff --git a/src/plugins/Mangadex.cpp b/src/plugins/Mangadex.cpp index d2dee13..78c8ec9 100644 --- a/src/plugins/Mangadex.cpp +++ b/src/plugins/Mangadex.cpp @@ -61,6 +61,7 @@ namespace QuickMedia { return SearchResult::ERR; std::vector> chapters(chapter_json.size()); + /* TODO: Optimize member access */ for(auto &member_name : chapter_json.getMemberNames()) { Json::Value chapter = chapter_json[member_name]; if(chapter.isObject()) @@ -81,6 +82,7 @@ namespace QuickMedia { time_t time_now = time(NULL); + /* TODO: Pointer */ std::string prev_chapter_number; for(auto it = chapters.begin(); it != chapters.end(); ++it) { const std::string &chapter_id = it->first; diff --git a/src/plugins/Youtube.cpp b/src/plugins/Youtube.cpp index b50939e..6f21991 100644 --- a/src/plugins/Youtube.cpp +++ b/src/plugins/Youtube.cpp @@ -62,10 +62,11 @@ namespace QuickMedia { auto body_item = std::make_unique(title); /* TODO: Make date a different color */ - if(date) { + /* TODO: Do not append to title, messes up history.. */ + /*if(date) { body_item->title += '\n'; body_item->title += date; - } + }*/ body_item->url = "https://www.youtube.com/watch?v=" + video_id_str; body_item->thumbnail_url = std::move(thumbnail_url); added_videos.insert(video_id_str); -- cgit v1.2.3