#include "../../plugins/Youtube.hpp" #include "../../include/Storage.hpp" #include "../../include/NetUtils.hpp" #include "../../include/StringUtils.hpp" #include "../../include/Scale.hpp" #include "../../include/Notification.hpp" extern "C" { #include } #include #include #include namespace QuickMedia { // This is a common setup of text in the youtube json static std::optional yt_json_get_text(const Json::Value &json, const char *root_name) { if(!json.isObject()) return std::nullopt; const Json::Value &text_json = json[root_name]; if(!text_json.isObject()) return std::nullopt; const Json::Value &simple_text_json = text_json["simpleText"]; if(simple_text_json.isString()) { return simple_text_json.asString(); } else { const Json::Value &runs_json = text_json["runs"]; if(!runs_json.isArray() || runs_json.empty()) return std::nullopt; std::string result; for(const Json::Value &first_runs_json : runs_json) { if(!first_runs_json.isObject()) continue; const Json::Value &text_json = first_runs_json["text"]; if(text_json.isString()) result += text_json.asString(); } if(!result.empty()) return result; } return std::nullopt; } struct Thumbnail { const char *url; int width; int height; }; enum class ThumbnailSize { SMALLEST, MEDIUM, LARGEST }; static std::optional yt_json_get_thumbnail(const Json::Value &thumbnail_json, ThumbnailSize thumbnail_size) { if(!thumbnail_json.isObject()) return std::nullopt; const Json::Value &thumbnails_json = thumbnail_json["thumbnails"]; if(!thumbnails_json.isArray()) return std::nullopt; std::vector thumbnails; for(const Json::Value &thumbnail_data_json : thumbnails_json) { if(!thumbnail_data_json.isObject()) continue; const Json::Value &url_json = thumbnail_data_json["url"]; if(!url_json.isString()) continue; const Json::Value &width_json = thumbnail_data_json["width"]; if(!width_json.isInt()) continue; const Json::Value &height_json = thumbnail_data_json["height"]; if(!height_json.isInt()) continue; thumbnails.push_back({ url_json.asCString(), width_json.asInt(), height_json.asInt() }); } if(thumbnails.empty()) return std::nullopt; switch(thumbnail_size) { case ThumbnailSize::SMALLEST: return *std::min_element(thumbnails.begin(), thumbnails.end(), [](const Thumbnail &thumbnail1, const Thumbnail &thumbnail2) { int size1 = thumbnail1.width * thumbnail1.height; int size2 = thumbnail2.width * thumbnail2.height; return size1 < size2; }); case ThumbnailSize::MEDIUM: { std::sort(thumbnails.begin(), thumbnails.end(), [](const Thumbnail &thumbnail1, const Thumbnail &thumbnail2) { int size1 = thumbnail1.width * thumbnail1.height; int size2 = thumbnail2.width * thumbnail2.height; return size1 < size2; }); return thumbnails[thumbnails.size() / 2]; } case ThumbnailSize::LARGEST: return *std::max_element(thumbnails.begin(), thumbnails.end(), [](const Thumbnail &thumbnail1, const Thumbnail &thumbnail2) { int size1 = thumbnail1.width * thumbnail1.height; int size2 = thumbnail2.width * thumbnail2.height; return size1 < size2; }); } return std::nullopt; } static std::shared_ptr parse_common_video_item(const Json::Value &video_item_json, std::unordered_set &added_videos) { const Json::Value &video_id_json = video_item_json["videoId"]; if(!video_id_json.isString()) return nullptr; std::string video_id_str = video_id_json.asString(); if(added_videos.find(video_id_str) != added_videos.end()) return nullptr; std::optional title = yt_json_get_text(video_item_json, "title"); if(!title) return nullptr; std::optional date = yt_json_get_text(video_item_json, "publishedTimeText"); std::optional view_count_text = yt_json_get_text(video_item_json, "viewCountText"); std::optional owner_text = yt_json_get_text(video_item_json, "shortBylineText"); std::optional description_snippet = yt_json_get_text(video_item_json, "descriptionSnippet"); std::optional length = yt_json_get_text(video_item_json, "lengthText"); if(!length) { const Json::Value &thumbnail_overlays_json = video_item_json["thumbnailOverlays"]; if(thumbnail_overlays_json.isArray() && !thumbnail_overlays_json.empty()) { const Json::Value &thumbnail_overlay_json = thumbnail_overlays_json[0]; if(thumbnail_overlay_json.isObject()) length = yt_json_get_text(thumbnail_overlay_json["thumbnailOverlayTimeStatusRenderer"], "text"); } } std::string scheduled_text; const Json::Value &upcoming_event_data_json = video_item_json["upcomingEventData"]; if(upcoming_event_data_json.isObject()) { const Json::Value &start_time_json = upcoming_event_data_json["startTime"]; if(!start_time_json.isString()) return nullptr; std::optional upcoming_event_text = yt_json_get_text(upcoming_event_data_json, "upcomingEventText"); if(!upcoming_event_text) return nullptr; time_t start_time = strtol(start_time_json.asCString(), nullptr, 10); struct tm message_tm; localtime_r(&start_time, &message_tm); char time_str[128] = {0}; strftime(time_str, sizeof(time_str) - 1, "%a %b %d %H:%M", &message_tm); string_replace_all(upcoming_event_text.value(), "DATE_PLACEHOLDER", time_str); scheduled_text = std::move(upcoming_event_text.value()); } auto body_item = BodyItem::create(title.value()); std::string desc; if(view_count_text) desc += view_count_text.value(); if(date) { if(!desc.empty()) desc += " • "; desc += date.value(); } if(!scheduled_text.empty()) { if(!desc.empty()) desc += " • "; desc += scheduled_text; } if(length) { if(!desc.empty()) desc += '\n'; desc += length.value(); } if(owner_text) { if(!desc.empty()) desc += '\n'; desc += owner_text.value(); } if(description_snippet) { if(!desc.empty()) desc += '\n'; desc += '\n'; desc += description_snippet.value(); } body_item->set_description(std::move(desc)); body_item->set_description_color(sf::Color(179, 179, 179)); if(scheduled_text.empty()) body_item->url = "https://www.youtube.com/watch?v=" + video_id_str; body_item->thumbnail_url = "https://img.youtube.com/vi/" + video_id_str + "/mqdefault.jpg"; body_item->thumbnail_size = sf::Vector2i(192, 108); added_videos.insert(video_id_str); return body_item; } static std::shared_ptr parse_content_video_renderer(const Json::Value &content_item_json, std::unordered_set &added_videos) { if(!content_item_json.isObject()) return nullptr; const Json::Value &video_renderer_json = content_item_json["videoRenderer"]; if(!video_renderer_json.isObject()) return nullptr; return parse_common_video_item(video_renderer_json, added_videos); } static std::shared_ptr parse_channel_renderer(const Json::Value &channel_renderer_json) { if(!channel_renderer_json.isObject()) return nullptr; const Json::Value &channel_id_json = channel_renderer_json["channelId"]; if(!channel_id_json.isString()) return nullptr; std::optional title = yt_json_get_text(channel_renderer_json, "title"); if(!title) return nullptr; std::optional description = yt_json_get_text(channel_renderer_json, "descriptionSnippet"); std::optional video_count = yt_json_get_text(channel_renderer_json, "videoCountText"); std::optional subscribers = yt_json_get_text(channel_renderer_json, "subscriberCountText"); const Json::Value &thumbnail_json = channel_renderer_json["thumbnail"]; std::optional thumbnail = yt_json_get_thumbnail(thumbnail_json, ThumbnailSize::LARGEST); auto body_item = BodyItem::create(title.value()); std::string desc; if(subscribers) desc += subscribers.value(); if(video_count) { if(!desc.empty()) desc += " • "; desc += video_count.value(); if(strcmp(video_count.value().c_str(), "1") == 0) desc += " video"; else desc += " videos"; } if(description) { if(!desc.empty()) desc += '\n'; desc += '\n'; desc += description.value(); } body_item->set_description(std::move(desc)); body_item->set_description_color(sf::Color(179, 179, 179)); body_item->url = "https://www.youtube.com/channel/" + channel_id_json.asString(); if(thumbnail) { body_item->thumbnail_url = std::string("https:") + thumbnail->url; body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; body_item->thumbnail_size.x = thumbnail->width; body_item->thumbnail_size.y = thumbnail->height; body_item->thumbnail_size = clamp_to_size(body_item->thumbnail_size, sf::Vector2i(136, 136)); } return body_item; } // Returns empty string if continuation token can't be found static std::string item_section_renderer_get_continuation_token(const Json::Value &item_section_renderer_json) { const Json::Value &continuation_item_renderer_json = item_section_renderer_json["continuationItemRenderer"]; if(!continuation_item_renderer_json.isObject()) return ""; const Json::Value &continuation_endpoint_json = continuation_item_renderer_json["continuationEndpoint"]; if(!continuation_endpoint_json.isObject()) return ""; const Json::Value &continuation_command_json = continuation_endpoint_json["continuationCommand"]; if(!continuation_command_json.isObject()) return ""; const Json::Value &token_json = continuation_command_json["token"]; if(!token_json.isString()) return ""; return token_json.asString(); } static void parse_item_section_renderer(const Json::Value &item_section_renderer_json, std::unordered_set &added_videos, BodyItems &result_items) { if(!item_section_renderer_json.isObject()) return; const Json::Value &item_contents_json = item_section_renderer_json["contents"]; if(!item_contents_json.isArray()) return; for(const Json::Value &content_item_json : item_contents_json) { if(!content_item_json.isObject()) continue; for(Json::Value::const_iterator it = content_item_json.begin(); it != content_item_json.end(); ++it) { Json::Value key = it.key(); if(key.isString() && strcmp(key.asCString(), "shelfRenderer") == 0) { const Json::Value &shelf_renderer_json = *it; if(!shelf_renderer_json.isObject()) continue; const Json::Value &item_content_json = shelf_renderer_json["content"]; if(!item_content_json.isObject()) continue; const Json::Value &vertical_list_renderer_json = item_content_json["verticalListRenderer"]; if(!vertical_list_renderer_json.isObject()) continue; const Json::Value &items_json = vertical_list_renderer_json["items"]; if(!items_json.isArray()) continue; for(const Json::Value &item_json : items_json) { std::shared_ptr body_item = parse_content_video_renderer(item_json, added_videos); if(body_item) result_items.push_back(std::move(body_item)); } } else if(key.isString() && strcmp(key.asCString(), "channelRenderer") == 0) { std::shared_ptr body_item = parse_channel_renderer(*it); if(body_item) result_items.push_back(std::move(body_item)); } else { std::shared_ptr body_item = parse_content_video_renderer(content_item_json, added_videos); if(body_item) result_items.push_back(std::move(body_item)); } } } } static std::mutex cookies_mutex; static std::string cookies_filepath; static void remove_cookies_file_at_exit() { std::lock_guard lock(cookies_mutex); if(!cookies_filepath.empty()) remove(cookies_filepath.c_str()); } static std::vector get_cookies() { std::lock_guard lock(cookies_mutex); if(cookies_filepath.empty()) { char filename[] = "/tmp/quickmedia.youtube.cookie.XXXXXX"; int fd = mkstemp(filename); if(fd == -1) return {}; close(fd); cookies_filepath = filename; atexit(remove_cookies_file_at_exit); // TODO: Is there any way to bypass this? this is needed to set VISITOR_INFO1_LIVE which is required to read comments const char *args[] = { "curl", "-I", "-s", "-b", cookies_filepath.c_str(), "-c", cookies_filepath.c_str(), "https://www.youtube.com/subscription_manager?disable_polymer=1", nullptr }; if(exec_program(args, nullptr, nullptr) != 0) fprintf(stderr, "Failed to fetch cookies to view youtube comments\n"); } return { CommandArg{ "-b", cookies_filepath }, CommandArg{ "-c", cookies_filepath } }; } static std::string remove_index_from_playlist_url(const std::string &url) { std::string result = url; size_t index = result.rfind("&index="); if(index == std::string::npos) return result; return result.substr(0, index); } static std::shared_ptr parse_compact_video_renderer_json(const Json::Value &item_json, std::unordered_set &added_videos) { const Json::Value &compact_video_renderer_json = item_json["compactVideoRenderer"]; if(!compact_video_renderer_json.isObject()) return nullptr; return parse_common_video_item(compact_video_renderer_json, added_videos); } static std::string item_section_renderer_get_continuation(const Json::Value &item_section_renderer) { if(!item_section_renderer.isObject()) return ""; const Json::Value &continuations_json = item_section_renderer["continuations"]; if(!continuations_json.isArray()) return ""; for(const Json::Value &json_item : continuations_json) { if(!json_item.isObject()) continue; const Json::Value &next_continuation_data_json = json_item["nextContinuationData"]; if(!next_continuation_data_json.isObject()) continue; const Json::Value &continuation_json = next_continuation_data_json["continuation"]; if(continuation_json.isString()) return continuation_json.asString(); } return ""; } static BodyItems parse_channel_videos(const Json::Value &json_root, std::string &continuation_token, std::unordered_set &added_videos) { BodyItems body_items; if(!json_root.isArray()) return body_items; std::string new_continuation_token; for(const Json::Value &json_item : json_root) { if(!json_item.isObject()) continue; const Json::Value &response_json = json_item["response"]; if(!response_json.isObject()) continue; const Json::Value &contents_json = response_json["contents"]; if(!contents_json.isObject()) continue; const Json::Value &tcbrr_json = contents_json["twoColumnBrowseResultsRenderer"]; if(!tcbrr_json.isObject()) continue; const Json::Value &tabs_json = tcbrr_json["tabs"]; if(!tabs_json.isArray()) continue; for(const Json::Value &tab_json : tabs_json) { if(!tab_json.isObject()) continue; const Json::Value &tab_renderer_json = tab_json["tabRenderer"]; if(!tab_renderer_json.isObject()) continue; const Json::Value &content_json = tab_renderer_json["content"]; if(!content_json.isObject()) continue; const Json::Value §ion_list_renderer = content_json["sectionListRenderer"]; if(!section_list_renderer.isObject()) continue; const Json::Value &contents2_json = section_list_renderer["contents"]; if(!contents2_json.isArray()) continue; for(const Json::Value &content_item_json : contents2_json) { if(!content_item_json.isObject()) continue; const Json::Value &item_section_renderer_json = content_item_json["itemSectionRenderer"]; if(!item_section_renderer_json.isObject()) continue; const Json::Value &item_contents_json = item_section_renderer_json["contents"]; if(!item_contents_json.isArray()) continue; for(const Json::Value &content_json : item_contents_json) { if(!content_json.isObject()) continue; const Json::Value &grid_renderer_json = content_json["gridRenderer"]; if(!grid_renderer_json.isObject()) continue; const Json::Value &items_json = grid_renderer_json["items"]; if(!items_json.isArray()) continue; for(const Json::Value &item_json : items_json) { if(!item_json.isObject()) continue; if(new_continuation_token.empty()) new_continuation_token = item_section_renderer_get_continuation_token(item_json); const Json::Value &grid_video_renderer = item_json["gridVideoRenderer"]; if(!grid_video_renderer.isObject()) continue; auto body_item = parse_common_video_item(grid_video_renderer, added_videos); if(body_item) body_items.push_back(std::move(body_item)); } } } } } if(!new_continuation_token.empty()) continuation_token = std::move(new_continuation_token); return body_items; } static void parse_section_list_renderer(const Json::Value §ion_list_renderer_json, std::string &continuation_token, BodyItems &result_items, std::unordered_set &added_videos) { if(!section_list_renderer_json.isObject()) return; const Json::Value &contents2_json = section_list_renderer_json["contents"]; if(!contents2_json.isArray()) return; for(const Json::Value &item_json : contents2_json) { if(!item_json.isObject()) continue; if(continuation_token.empty()) continuation_token = item_section_renderer_get_continuation_token(item_json); const Json::Value &item_section_renderer_json = item_json["itemSectionRenderer"]; if(!item_section_renderer_json.isObject()) continue; parse_item_section_renderer(item_section_renderer_json, added_videos, result_items); } } SearchResult YoutubeSearchPage::search(const std::string &str, BodyItems &result_items) { continuation_token.clear(); current_page = 0; added_videos.clear(); search_url = "https://www.youtube.com/results?search_query="; search_url += url_param_encode(str); std::vector additional_args = { { "-H", "x-spf-referer: " + search_url }, { "-H", "x-youtube-client-name: 1" }, { "-H", "x-youtube-client-version: 2.20200626.03.00" }, { "-H", "referer: " + search_url } }; std::vector cookies = get_cookies(); additional_args.insert(additional_args.end(), cookies.begin(), cookies.end()); Json::Value json_root; DownloadResult result = download_json(json_root, search_url + "&pbj=1", std::move(additional_args), true); if(result != DownloadResult::OK) return download_result_to_search_result(result); if(!json_root.isArray()) return SearchResult::ERR; for(const Json::Value &json_item : json_root) { if(!json_item.isObject()) continue; const Json::Value &response_json = json_item["response"]; if(!response_json.isObject()) continue; const Json::Value &contents_json = response_json["contents"]; if(!contents_json.isObject()) continue; const Json::Value &tcsrr_json = contents_json["twoColumnSearchResultsRenderer"]; if(!tcsrr_json.isObject()) continue; const Json::Value &primary_contents_json = tcsrr_json["primaryContents"]; if(!primary_contents_json.isObject()) continue; parse_section_list_renderer(primary_contents_json["sectionListRenderer"], continuation_token, result_items, added_videos); } return SearchResult::OK; } PluginResult YoutubeSearchPage::get_page(const std::string&, int page, BodyItems &result_items) { while(current_page < page) { PluginResult plugin_result = search_get_continuation(search_url, continuation_token, result_items); if(plugin_result != PluginResult::OK) return plugin_result; ++current_page; } return PluginResult::OK; } PluginResult YoutubeSearchPage::submit(const std::string &title, const std::string &url, std::vector &result_tabs) { if(url.empty()) return PluginResult::OK; if(strncmp(url.c_str(), "https://www.youtube.com/channel/", 32) == 0) { // TODO: Make all pages (for all services) lazy fetch in a similar manner! result_tabs.push_back(Tab{create_body(), std::make_unique(program, url, "", title), create_search_bar("Search...", 350)}); } else { result_tabs.push_back(Tab{nullptr, std::make_unique(program, url), nullptr}); } return PluginResult::OK; } PluginResult YoutubeSearchPage::search_get_continuation(const std::string &url, const std::string ¤t_continuation_token, BodyItems &result_items) { std::string next_url = url + "&pbj=1&ctoken=" + current_continuation_token; std::vector additional_args = { { "-H", "x-spf-referer: " + url }, { "-H", "x-youtube-client-name: 1" }, { "-H", "x-spf-previous: " + url }, { "-H", "x-youtube-client-version: 2.20200626.03.00" }, { "-H", "referer: " + url } }; std::vector cookies = get_cookies(); additional_args.insert(additional_args.end(), cookies.begin(), cookies.end()); Json::Value json_root; DownloadResult result = download_json(json_root, next_url, std::move(additional_args), true); if(result != DownloadResult::OK) return download_result_to_plugin_result(result); if(!json_root.isArray()) return PluginResult::ERR; std::string new_continuation_token; for(const Json::Value &json_item : json_root) { if(!json_item.isObject()) continue; const Json::Value &response_json = json_item["response"]; if(!response_json.isObject()) continue; const Json::Value &on_response_received_commands_json = response_json["onResponseReceivedCommands"]; if(!on_response_received_commands_json.isArray()) continue; for(const Json::Value &response_received_command : on_response_received_commands_json) { if(!response_received_command.isObject()) continue; const Json::Value &append_continuation_items_action_json = response_received_command["appendContinuationItemsAction"]; if(!append_continuation_items_action_json.isObject()) continue; const Json::Value &continuation_items_json = append_continuation_items_action_json["continuationItems"]; if(!continuation_items_json.isArray()) continue; for(const Json::Value &continuation_item : continuation_items_json) { if(!continuation_item.isObject()) continue; if(new_continuation_token.empty()) { // Note: item_section_renderer is compatible with continuation_item new_continuation_token = item_section_renderer_get_continuation_token(continuation_item); } const Json::Value &item_section_renderer_json = continuation_item["itemSectionRenderer"]; if(!item_section_renderer_json.isObject()) continue; parse_item_section_renderer(item_section_renderer_json, added_videos, result_items); } } } if(!new_continuation_token.empty()) continuation_token = std::move(new_continuation_token); return PluginResult::OK; } PluginResult YoutubeCommentsPage::get_page(const std::string&, int page, BodyItems &result_items) { while(current_page < page) { PluginResult plugin_result = lazy_fetch(result_items); if(plugin_result != PluginResult::OK) return plugin_result; ++current_page; } return PluginResult::OK; } PluginResult YoutubeCommentsPage::submit(const std::string&, const std::string &url, std::vector &result_tabs) { if(url.empty()) return PluginResult::OK; result_tabs.push_back(Tab{create_body(), std::make_unique(program, xsrf_token, url), nullptr}); return PluginResult::OK; } static std::string comment_thread_renderer_get_replies_continuation(const Json::Value &comment_thread_renderer_json) { if(!comment_thread_renderer_json.isObject()) return ""; const Json::Value &replies_json = comment_thread_renderer_json["replies"]; if(!replies_json.isObject()) return ""; const Json::Value &comment_replies_renderer = replies_json["commentRepliesRenderer"]; if(!comment_replies_renderer.isObject()) return ""; // item_section_renderer_get_continuation is compatible with commentRepliesRenderer return item_section_renderer_get_continuation(comment_replies_renderer); } // Returns empty string if comment is not hearted static std::string comment_renderer_get_hearted_tooltip(const Json::Value &comment_renderer_json) { const Json::Value &action_buttons_json = comment_renderer_json["actionButtons"]; if(!action_buttons_json.isObject()) return ""; const Json::Value &comment_action_buttons_renderer_json = action_buttons_json["commentActionButtonsRenderer"]; if(!comment_action_buttons_renderer_json.isObject()) return ""; const Json::Value &creator_heart_json = comment_action_buttons_renderer_json["creatorHeart"]; if(!creator_heart_json.isObject()) return ""; const Json::Value &creator_heart_renderer_json = creator_heart_json["creatorHeartRenderer"]; if(!creator_heart_renderer_json.isObject()) return ""; const Json::Value &hearted_tooltip_json = creator_heart_renderer_json["heartedTooltip"]; if(!hearted_tooltip_json.isString()) return ""; return hearted_tooltip_json.asString(); } static std::shared_ptr comment_renderer_to_body_item(const Json::Value &comment_renderer_json) { if(!comment_renderer_json.isObject()) return nullptr; std::optional author_text = yt_json_get_text(comment_renderer_json, "authorText"); if(!author_text) return nullptr; std::string author = author_text.value(); std::optional published_time_text = yt_json_get_text(comment_renderer_json, "publishedTimeText"); if(published_time_text) author += " - " + published_time_text.value(); auto body_item = BodyItem::create(""); body_item->set_author(std::move(author)); std::string description; const Json::Value &author_is_channel_owner_json = comment_renderer_json["authorIsChannelOwner"]; if(author_is_channel_owner_json.isBool() && author_is_channel_owner_json.asBool()) body_item->set_author_color(sf::Color(150, 255, 150)); std::optional comment = yt_json_get_text(comment_renderer_json, "contentText"); if(comment) description = comment.value(); std::optional thumbnail = yt_json_get_thumbnail(comment_renderer_json["authorThumbnail"], ThumbnailSize::SMALLEST); if(thumbnail) { body_item->thumbnail_url = thumbnail->url; body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; body_item->thumbnail_size.x = thumbnail->width; body_item->thumbnail_size.y = thumbnail->height; body_item->thumbnail_size = body_item->thumbnail_size; } const Json::Value &like_count_json = comment_renderer_json["likeCount"]; if(like_count_json.isInt64()) { if(!description.empty()) description += '\n'; description += "👍 " + std::to_string(like_count_json.asInt64()); } const Json::Value &reply_count_json = comment_renderer_json["replyCount"]; if(reply_count_json.isInt64() && reply_count_json.asInt64() > 0) { if(!description.empty()) description += '\n'; if(reply_count_json.asInt64() == 1) description += "1 reply"; else description += std::to_string(reply_count_json.asInt64()) + " replies"; } std::string hearted_tooltip = comment_renderer_get_hearted_tooltip(comment_renderer_json); if(!hearted_tooltip.empty()) { if(!description.empty()) description += " - "; description += std::move(hearted_tooltip); } body_item->set_description(std::move(description)); return body_item; } PluginResult YoutubeCommentsPage::lazy_fetch(BodyItems &result_items) { if(continuation_token.empty()) return PluginResult::OK; std::string next_url = "https://www.youtube.com/comment_service_ajax?action_get_comments=1&pbj=1&ctoken="; next_url += url_param_encode(continuation_token); //next_url += "&continuation="; //next_url += url_param_encode(comments_continuation_token); next_url += "&type=next"; std::vector additional_args = { { "-H", "x-youtube-client-name: 1" }, { "-H", "x-youtube-client-version: 2.20210308.08.00" }, { "-F", "session_token=" + xsrf_token } }; std::vector cookies = get_cookies(); additional_args.insert(additional_args.end(), cookies.begin(), cookies.end()); Json::Value json_root; DownloadResult result = download_json(json_root, next_url, std::move(additional_args), true); if(result != DownloadResult::OK) return download_result_to_plugin_result(result); if(!json_root.isObject()) return PluginResult::ERR; const Json::Value &xsrf_token_json = json_root["xsrf_token"]; if(xsrf_token_json.isString()) xsrf_token = xsrf_token_json.asString(); const Json::Value &response_json = json_root["response"]; if(!response_json.isObject()) return PluginResult::ERR; const Json::Value &continuation_contents_json = response_json["continuationContents"]; if(!continuation_contents_json.isObject()) return PluginResult::ERR; const Json::Value &item_section_continuation_json = continuation_contents_json["itemSectionContinuation"]; if(!item_section_continuation_json.isObject()) return PluginResult::ERR; const Json::Value &contents_json = item_section_continuation_json["contents"]; if(contents_json.isArray()) { for(const Json::Value &json_item : contents_json) { if(!json_item.isObject()) continue; const Json::Value &comment_thread_renderer = json_item["commentThreadRenderer"]; if(!comment_thread_renderer.isObject()) continue; const Json::Value &comment_json = comment_thread_renderer["comment"]; if(!comment_json.isObject()) continue; auto body_item = comment_renderer_to_body_item(comment_json["commentRenderer"]); if(body_item) { body_item->url = comment_thread_renderer_get_replies_continuation(comment_thread_renderer); result_items.push_back(std::move(body_item)); } } } continuation_token = item_section_renderer_get_continuation(item_section_continuation_json); return PluginResult::OK; } PluginResult YoutubeCommentRepliesPage::get_page(const std::string&, int page, BodyItems &result_items) { while(current_page < page) { PluginResult plugin_result = lazy_fetch(result_items); if(plugin_result != PluginResult::OK) return plugin_result; ++current_page; } return PluginResult::OK; } PluginResult YoutubeCommentRepliesPage::submit(const std::string&, const std::string&, std::vector&) { return PluginResult::OK; } PluginResult YoutubeCommentRepliesPage::lazy_fetch(BodyItems &result_items) { if(continuation_token.empty()) return PluginResult::OK; std::string next_url = "https://www.youtube.com/comment_service_ajax?action_get_comment_replies=1&pbj=1&ctoken="; next_url += url_param_encode(continuation_token); //next_url += "&continuation="; //next_url += url_param_encode(comments_continuation_token); next_url += "&type=next"; std::vector additional_args = { { "-H", "x-youtube-client-name: 1" }, { "-H", "x-youtube-client-version: 2.20210308.08.00" }, { "-F", "session_token=" + xsrf_token } }; std::vector cookies = get_cookies(); additional_args.insert(additional_args.end(), cookies.begin(), cookies.end()); Json::Value json_root; DownloadResult result = download_json(json_root, next_url, std::move(additional_args), true); if(result != DownloadResult::OK) return download_result_to_plugin_result(result); if(!json_root.isArray()) return PluginResult::ERR; for(const Json::Value &json_item : json_root) { if(!json_item.isObject()) continue; const Json::Value &xsrf_token_json = json_item["xsrf_token"]; if(xsrf_token_json.isString()) xsrf_token = xsrf_token_json.asString(); const Json::Value &response_json = json_item["response"]; if(!response_json.isObject()) continue; const Json::Value &continuation_contents_json = response_json["continuationContents"]; if(!continuation_contents_json.isObject()) continue; const Json::Value &comment_replies_continuation_json = continuation_contents_json["commentRepliesContinuation"]; if(!comment_replies_continuation_json.isObject()) continue; const Json::Value &contents_json = comment_replies_continuation_json["contents"]; if(contents_json.isArray()) { for(const Json::Value &content_item_json : contents_json) { if(!content_item_json.isObject()) continue; auto body_item = comment_renderer_to_body_item(content_item_json["commentRenderer"]); if(body_item) result_items.push_back(std::move(body_item)); } } // item_section_renderer_get_continuation is compatible with commentRepliesContinuation continuation_token = item_section_renderer_get_continuation(comment_replies_continuation_json); return PluginResult::OK; } return PluginResult::ERR; } static std::string channel_url_extract_id(const std::string &channel_url) { size_t index = channel_url.find("channel/"); if(index == std::string::npos) return ""; index += 8; size_t end_index = channel_url.find('/', index); if(end_index == std::string::npos) return channel_url.substr(index); return channel_url.substr(index, end_index - index); } SearchResult YoutubeChannelPage::search(const std::string &str, BodyItems &result_items) { added_videos.clear(); continuation_token.clear(); current_page = 0; if(str.empty()) return plugin_result_to_search_result(lazy_fetch(result_items)); std::string next_url = "https://www.youtube.com/youtubei/v1/browse?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"; Json::Value request_json(Json::objectValue); Json::Value context_json(Json::objectValue); Json::Value client_json(Json::objectValue); client_json["hl"] = "en"; client_json["gl"] = "US"; client_json["deviceMake"] = ""; client_json["deviceModel"] = ""; client_json["userAgent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36"; client_json["clientName"] = "WEB"; client_json["clientVersion"] = "2.20210304.08.01"; client_json["osName"] = "X11"; client_json["osVersion"] = ""; client_json["originalUrl"] = url + "/videos"; context_json["client"] = std::move(client_json); request_json["context"] = std::move(context_json); request_json["browseId"] = channel_url_extract_id(url); request_json["query"] = str; request_json["params"] = "EgZzZWFyY2g%3D"; //request_json["continuation"] = current_continuation_token; Json::StreamWriterBuilder json_builder; json_builder["commentStyle"] = "None"; json_builder["indentation"] = ""; std::vector additional_args = { { "-H", "authority: www.youtube.com" }, { "-H", "x-origin: https://www.youtube.com" }, { "-H", "content-type: application/json" }, { "-H", "x-youtube-client-name: 1" }, { "-H", "x-youtube-client-version: 2.20200626.03.00" }, { "-H", "referer: " + url + "/videos" }, { "--data-raw", Json::writeString(json_builder, request_json) } }; std::vector cookies = get_cookies(); additional_args.insert(additional_args.end(), cookies.begin(), cookies.end()); Json::Value json_root; DownloadResult result = download_json(json_root, next_url, std::move(additional_args), true); if(result != DownloadResult::OK) return download_result_to_search_result(result); if(!json_root.isObject()) return SearchResult::ERR; const Json::Value &contents_json = json_root["contents"]; if(!contents_json.isObject()) return SearchResult::ERR; const Json::Value &two_column_browse_results_renderer_json = contents_json["twoColumnBrowseResultsRenderer"]; if(!two_column_browse_results_renderer_json.isObject()) return SearchResult::ERR; const Json::Value &tabs_json = two_column_browse_results_renderer_json["tabs"]; if(!tabs_json.isArray()) return SearchResult::ERR; for(const Json::Value &json_item : tabs_json) { if(!json_item.isObject()) continue; const Json::Value &expandable_tab_renderer_json = json_item["expandableTabRenderer"]; if(!expandable_tab_renderer_json.isObject()) continue; const Json::Value &content_json = expandable_tab_renderer_json["content"]; if(!content_json.isObject()) continue; parse_section_list_renderer(content_json["sectionListRenderer"], continuation_token, result_items, added_videos); } return SearchResult::OK; } PluginResult YoutubeChannelPage::get_page(const std::string&, int page, BodyItems &result_items) { while(current_page < page) { PluginResult plugin_result = search_get_continuation(url, continuation_token, result_items); if(plugin_result != PluginResult::OK) return plugin_result; ++current_page; } return PluginResult::OK; } PluginResult YoutubeChannelPage::search_get_continuation(const std::string &url, const std::string ¤t_continuation_token, BodyItems &result_items) { std::string next_url = "https://www.youtube.com/youtubei/v1/browse?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"; Json::Value request_json(Json::objectValue); Json::Value context_json(Json::objectValue); Json::Value client_json(Json::objectValue); client_json["hl"] = "en"; client_json["gl"] = "US"; client_json["deviceMake"] = ""; client_json["deviceModel"] = ""; client_json["userAgent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36"; client_json["clientName"] = "WEB"; client_json["clientVersion"] = "2.20210304.08.01"; client_json["osName"] = "X11"; client_json["osVersion"] = ""; client_json["originalUrl"] = url + "/videos"; context_json["client"] = std::move(client_json); request_json["context"] = std::move(context_json); request_json["continuation"] = current_continuation_token; Json::StreamWriterBuilder json_builder; json_builder["commentStyle"] = "None"; json_builder["indentation"] = ""; std::vector additional_args = { { "-H", "authority: www.youtube.com" }, { "-H", "x-origin: https://www.youtube.com" }, { "-H", "content-type: application/json" }, { "-H", "x-youtube-client-name: 1" }, { "-H", "x-youtube-client-version: 2.20200626.03.00" }, { "-H", "referer: " + url + "/videos" }, { "--data-raw", Json::writeString(json_builder, request_json) } }; std::vector cookies = get_cookies(); additional_args.insert(additional_args.end(), cookies.begin(), cookies.end()); Json::Value json_root; DownloadResult result = download_json(json_root, next_url, std::move(additional_args), true); if(result != DownloadResult::OK) return download_result_to_plugin_result(result); if(!json_root.isObject()) return PluginResult::ERR; const Json::Value &on_response_received_actions_json = json_root["onResponseReceivedActions"]; if(!on_response_received_actions_json.isArray()) return PluginResult::ERR; std::string new_continuation_token; for(const Json::Value &json_item : on_response_received_actions_json) { if(!json_item.isObject()) continue; const Json::Value &append_continuation_items_action_json = json_item["appendContinuationItemsAction"]; if(!append_continuation_items_action_json.isObject()) continue; const Json::Value &continuation_items_json = append_continuation_items_action_json["continuationItems"]; if(!continuation_items_json.isArray()) continue; for(const Json::Value &item_json : continuation_items_json) { if(!item_json.isObject()) continue; if(new_continuation_token.empty()) new_continuation_token = item_section_renderer_get_continuation_token(item_json); const Json::Value &grid_video_renderer = item_json["gridVideoRenderer"]; if(grid_video_renderer.isObject()) { auto body_item = parse_common_video_item(grid_video_renderer, added_videos); if(body_item) result_items.push_back(std::move(body_item)); } else { parse_item_section_renderer(item_json["itemSectionRenderer"], added_videos, result_items); } } } if(!new_continuation_token.empty()) continuation_token = std::move(new_continuation_token); return PluginResult::OK; } PluginResult YoutubeChannelPage::submit(const std::string&, const std::string &url, std::vector &result_tabs) { if(url.empty()) return PluginResult::OK; result_tabs.push_back(Tab{nullptr, std::make_unique(program, url), nullptr}); return PluginResult::OK; } PluginResult YoutubeChannelPage::lazy_fetch(BodyItems &result_items) { added_videos.clear(); std::vector additional_args = { { "-H", "x-spf-referer: " + url }, { "-H", "x-youtube-client-name: 1" }, { "-H", "x-spf-previous: " + url }, { "-H", "x-youtube-client-version: 2.20200626.03.00" }, { "-H", "referer: " + url } }; std::vector cookies = get_cookies(); additional_args.insert(additional_args.end(), cookies.begin(), cookies.end()); Json::Value json_root; DownloadResult result = download_json(json_root, url + "/videos?pbj=1", std::move(additional_args), true); if(result != DownloadResult::OK) return download_result_to_plugin_result(result); result_items = parse_channel_videos(json_root, continuation_token, added_videos); return PluginResult::OK; } TrackResult YoutubeChannelPage::track(const std::string&) { size_t channel_id_start = url.find("/channel/"); if(channel_id_start == std::string::npos) { show_notification("QuickMedia", "Unable to get channel id from " + url, Urgency::CRITICAL); return TrackResult::ERR; } channel_id_start += 9; size_t channel_id_end = url.find('/', channel_id_start); if(channel_id_end == std::string::npos) channel_id_end = url.size(); std::string channel_id = url.substr(channel_id_start, channel_id_end - channel_id_start); if(channel_id.empty()) { show_notification("QuickMedia", "Unable to get channel id from " + url, Urgency::CRITICAL); return TrackResult::ERR; } Path subscriptions_path = get_storage_dir().join("subscriptions"); if(create_directory_recursive(subscriptions_path) != 0) { show_notification("QuickMedia", "Failed to create directory: " + subscriptions_path.data, Urgency::CRITICAL); return TrackResult::ERR; } subscriptions_path.join("youtube.txt"); std::unordered_set channel_ids; std::string subscriptions_str; if(file_get_content(subscriptions_path, subscriptions_str) == 0) { string_split(subscriptions_str, '\n', [&channel_ids](const char *str, size_t size) { std::string line(str, size); line = strip(line); if(!line.empty()) channel_ids.insert(std::move(line)); return true; }); } auto it = channel_ids.find(channel_id); if(it == channel_ids.end()) { channel_ids.insert(channel_id); show_notification("QuickMedia", "Subscribed", Urgency::LOW); } else { channel_ids.erase(it); show_notification("QuickMedia", "Unsubscribed", Urgency::LOW); } std::string channel_ids_str; for(auto &it : channel_ids) { if(!channel_ids_str.empty()) channel_ids_str += '\n'; channel_ids_str += std::move(it); } if(file_overwrite_atomic(subscriptions_path, channel_ids_str) != 0) { show_notification("QuickMedia", "Failed to update subscriptions list with " + channel_id, Urgency::CRITICAL); return TrackResult::ERR; } return TrackResult::OK; } PluginResult YoutubeSubscriptionsPage::submit(const std::string&, const std::string &url, std::vector &result_tabs) { result_tabs.push_back(Tab{nullptr, std::make_unique(program, url), nullptr}); return PluginResult::OK; } struct SubscriptionEntry { std::string title; std::string video_id; time_t published = 0; }; struct SubscriptionData { SubscriptionEntry subscription_entry; bool inside_entry = false; }; static int string_view_equals(HtmlStringView *self, const char *sub) { const size_t sub_len = strlen(sub); return self->size == sub_len && memcmp(self->data, sub, sub_len) == 0; } // Returns relative time as a string (approximation) static std::string seconds_to_relative_time_str(time_t seconds) { seconds = std::max(0L, seconds); time_t minutes = seconds / 60; time_t hours = minutes / 60; time_t days = hours / 24; time_t months = days / 30; time_t years = days / 365; if(years >= 1) return std::to_string(years) + " year" + (years == 1 ? "" : "s") + " ago"; else if(months >= 1) return std::to_string(months) + " month" + (months == 1 ? "" : "s") + " ago"; else if(days >= 1) return std::to_string(days) + " day" + (days == 1 ? "" : "s") + " ago"; else if(hours >= 1) return std::to_string(hours) + " hour" + (hours == 1 ? "" : "s") + " ago"; else if(minutes >= 1) return std::to_string(minutes) + " minute" + (minutes == 1 ? "" : "s") + " ago"; else return std::to_string(seconds) + " second" + (seconds == 1 ? "" : "s") + " ago"; } PluginResult YoutubeSubscriptionsPage::lazy_fetch(BodyItems &result_items) { Path subscriptions_path = get_storage_dir().join("subscriptions").join("youtube.txt"); std::string subscriptions_str; if(file_get_content(subscriptions_path, subscriptions_str) != 0) return PluginResult::OK; // TODO: Make a async task pool to handle this more efficiently std::vector channel_ids; string_split(subscriptions_str, '\n', [&channel_ids](const char *str, size_t size) { std::string line(str, size); line = strip(line); if(!line.empty()) channel_ids.push_back(std::move(line)); return true; }); std::vector task_results; size_t async_task_index = 0; const time_t time_now = time(nullptr); for(const std::string &channel_id : channel_ids) { subscription_load_tasks[async_task_index] = AsyncTask>([&channel_id, time_now]() -> std::vector { std::string website_data; DownloadResult result = download_to_string("https://www.youtube.com/feeds/videos.xml?channel_id=" + url_param_encode(channel_id), website_data, {}, false); if(result != DownloadResult::OK) { auto body_item = BodyItem::create("Failed to fetch videos for channel: " + channel_id); return {YoutubeSubscriptionTaskResult{body_item, 0}}; } std::vector subscription_data_list; html_parser_parse(website_data.data(), website_data.size(), [](HtmlParser *html_parser, HtmlParseType parse_type, void *userdata) { std::vector &subscription_data_list = *(std::vector*)userdata; if(parse_type == HTML_PARSE_TAG_START && string_view_equals(&html_parser->tag_name, "entry")) { subscription_data_list.push_back({}); subscription_data_list.back().inside_entry = true; return; } else if(parse_type == HTML_PARSE_TAG_END && string_view_equals(&html_parser->tag_name, "entry")) { subscription_data_list.back().inside_entry = false; return; } if(subscription_data_list.empty() || !subscription_data_list.back().inside_entry) return; if(string_view_equals(&html_parser->tag_name, "title") && parse_type == HTML_PARSE_TAG_END) { subscription_data_list.back().subscription_entry.title.assign(html_parser->text_stripped.data, html_parser->text_stripped.size); } else if(string_view_equals(&html_parser->tag_name, "yt:videoId") && parse_type == HTML_PARSE_TAG_END) { subscription_data_list.back().subscription_entry.video_id.assign(html_parser->text_stripped.data, html_parser->text_stripped.size); } else if(string_view_equals(&html_parser->tag_name, "published") && parse_type == HTML_PARSE_TAG_END) { std::string published_str(html_parser->text_stripped.data, html_parser->text_stripped.size); int year = 0; int month = 0; int day = 0; int hour = 0; int minute = 0; int second = 0; sscanf(published_str.c_str(), "%d-%d-%dT%d:%d:%d", &year, &month, &day, &hour, &minute, &second); if(year == 0) return; struct tm time; memset(&time, 0, sizeof(time)); time.tm_year = year - 1900; time.tm_mon = month - 1; time.tm_mday = day; time.tm_hour = hour; time.tm_min = minute; time.tm_sec = second; subscription_data_list.back().subscription_entry.published = timegm(&time); } }, &subscription_data_list); std::vector results; for(SubscriptionData &subscription_data : subscription_data_list) { if(subscription_data.subscription_entry.title.empty() || subscription_data.subscription_entry.video_id.empty() || subscription_data.subscription_entry.published == 0) continue; html_unescape_sequences(subscription_data.subscription_entry.title); auto body_item = BodyItem::create(std::move(subscription_data.subscription_entry.title)); body_item->set_description("Uploaded " + seconds_to_relative_time_str(time_now - subscription_data.subscription_entry.published)); body_item->set_description_color(sf::Color(179, 179, 179)); body_item->url = "https://www.youtube.com/watch?v=" + subscription_data.subscription_entry.video_id; body_item->thumbnail_url = "https://img.youtube.com/vi/" + subscription_data.subscription_entry.video_id + "/mqdefault.jpg"; body_item->thumbnail_size = sf::Vector2i(192, 108); results.push_back({std::move(body_item), subscription_data.subscription_entry.published}); } return results; }); ++async_task_index; if(async_task_index == subscription_load_tasks.size()) { async_task_index = 0; for(auto &load_task : subscription_load_tasks) { if(!load_task.valid()) continue; auto new_task_results = load_task.get(); task_results.insert(task_results.end(), std::move_iterator(new_task_results.begin()), std::move_iterator(new_task_results.end())); } } } for(size_t i = 0; i < async_task_index; ++i) { auto &load_task = subscription_load_tasks[i]; if(!load_task.valid()) continue; auto new_task_results = load_task.get(); task_results.insert(task_results.end(), std::move_iterator(new_task_results.begin()), std::move_iterator(new_task_results.end())); } std::sort(task_results.begin(), task_results.end(), [](const YoutubeSubscriptionTaskResult &sub_data1, const YoutubeSubscriptionTaskResult &sub_data2) { return sub_data1.timestamp > sub_data2.timestamp; }); result_items.reserve(task_results.size()); for(auto &task_result : task_results) { result_items.push_back(std::move(task_result.body_item)); } return PluginResult::OK; } PluginResult YoutubeRelatedVideosPage::submit(const std::string&, const std::string &url, std::vector &result_tabs) { result_tabs.push_back(Tab{nullptr, std::make_unique(program, url), nullptr}); return PluginResult::OK; } static std::string two_column_watch_next_results_get_comments_continuation_token(const Json::Value &tcwnr_json) { const Json::Value &results_json = tcwnr_json["results"]; if(!results_json.isObject()) return ""; const Json::Value &results2_json = results_json["results"]; if(!results2_json.isObject()) return ""; const Json::Value &contents_json = results2_json["contents"]; if(!contents_json.isArray()) return ""; std::string comments_continuation_token; for(const Json::Value &content_item_json : contents_json) { if(!content_item_json.isObject()) continue; comments_continuation_token = item_section_renderer_get_continuation(content_item_json["itemSectionRenderer"]); if(!comments_continuation_token.empty()) return comments_continuation_token; } return ""; } BodyItems YoutubeVideoPage::get_related_media(const std::string &url, std::string &channel_url) { BodyItems result_items; std::string modified_url = remove_index_from_playlist_url(url); std::vector additional_args = { { "-H", "x-spf-referer: " + url }, { "-H", "x-youtube-client-name: 1" }, { "-H", "x-spf-previous: " + url }, { "-H", "x-youtube-client-version: 2.20200626.03.00" }, { "-H", "referer: " + url } }; std::vector cookies = get_cookies(); additional_args.insert(additional_args.end(), cookies.begin(), cookies.end()); Json::Value json_root; DownloadResult result = download_json(json_root, modified_url + "&pbj=1", std::move(additional_args), true); if(result != DownloadResult::OK) return result_items; if(!json_root.isArray()) return result_items; std::unordered_set added_videos; xsrf_token.clear(); comments_continuation_token.clear(); for(const Json::Value &json_item : json_root) { if(!json_item.isObject()) continue; if(channel_url.empty()) { const Json::Value &player_response_json = json_item["playerResponse"]; if(player_response_json.isObject()) { const Json::Value &video_details_json = player_response_json["videoDetails"]; if(video_details_json.isObject()) { const Json::Value &channel_id_json = video_details_json["channelId"]; if(channel_id_json.isString()) channel_url = "https://www.youtube.com/channel/" + channel_id_json.asString(); } } } if(xsrf_token.empty()) { const Json::Value &xsrf_token_json = json_item["xsrf_token"]; if(xsrf_token_json.isString()) xsrf_token = xsrf_token_json.asString(); } const Json::Value &response_json = json_item["response"]; if(!response_json.isObject()) continue; const Json::Value &contents_json = response_json["contents"]; if(!contents_json.isObject()) return result_items; const Json::Value &tcwnr_json = contents_json["twoColumnWatchNextResults"]; if(!tcwnr_json.isObject()) return result_items; if(comments_continuation_token.empty()) comments_continuation_token = two_column_watch_next_results_get_comments_continuation_token(tcwnr_json); const Json::Value &secondary_results_json = tcwnr_json["secondaryResults"]; if(!secondary_results_json.isObject()) return result_items; const Json::Value &secondary_results2_json = secondary_results_json["secondaryResults"]; if(!secondary_results2_json.isObject()) return result_items; const Json::Value &results_json = secondary_results2_json["results"]; if(!results_json.isArray()) return result_items; for(const Json::Value &item_json : results_json) { if(!item_json.isObject()) continue; auto body_item = parse_compact_video_renderer_json(item_json, added_videos); if(body_item) result_items.push_back(std::move(body_item)); const Json::Value &compact_autoplay_renderer_json = item_json["compactAutoplayRenderer"]; if(!compact_autoplay_renderer_json.isObject()) continue; const Json::Value &item_contents_json = compact_autoplay_renderer_json["contents"]; if(!item_contents_json.isArray()) continue; for(const Json::Value &content_item_json : item_contents_json) { if(!content_item_json.isObject()) continue; auto body_item = parse_compact_video_renderer_json(content_item_json, added_videos); if(body_item) result_items.push_back(std::move(body_item)); } } } return result_items; } std::unique_ptr YoutubeVideoPage::create_search_page(Program *program, int &search_delay) { search_delay = 350; return std::make_unique(program); } std::unique_ptr YoutubeVideoPage::create_comments_page(Program *program) { return std::make_unique(program, xsrf_token, comments_continuation_token); } std::unique_ptr YoutubeVideoPage::create_related_videos_page(Program *program, const std::string&, const std::string&) { return std::make_unique(program); } std::unique_ptr YoutubeVideoPage::create_channels_page(Program *program, const std::string &channel_url) { return std::make_unique(program, channel_url, "", "Channel videos"); } }