aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordec05eba <dec05eba@protonmail.com>2023-10-29 14:22:24 +0100
committerdec05eba <dec05eba@protonmail.com>2023-10-29 14:22:24 +0100
commit89e018c4f24a03a9436024539d10d2b058d6e956 (patch)
tree001a3aa479793d6ba7934ce75f8c6e3c74839f16
parent10ecf0857b25ff2542a38015ecbe58462510efc1 (diff)
Youtube: support playlists
-rw-r--r--TODO5
-rw-r--r--plugins/Youtube.hpp19
-rw-r--r--src/QuickMedia.cpp19
-rw-r--r--src/plugins/Youtube.cpp295
4 files changed, 278 insertions, 60 deletions
diff --git a/TODO b/TODO
index da5fa70..0981eb9 100644
--- a/TODO
+++ b/TODO
@@ -122,7 +122,7 @@ Ctrl+S saving a video should copy the video from cache if the video was download
When synapse adds support for media download http request range then quickmedia should download the last 4096 bytes of mp4 files and move the moov atom and co64 atoms (and others) to the front of the file before mdat, similar to how qt-faststart does it. This is needed for video streaming certain mp4 files.
Ctrl+S when a body item is selected on youtube/xxxplugins should show an option to download the video, instead of having to press ctrl+s after the video is playing.
Show a subscribe button for channels, which should be red and say "subscribe" if we are not subscribed and it should be gray and say "unsubscribe" if we already are subscribed.
-Display youtube playlists differently and support downloading the whole playlist at once.
+Support downloading the whole youtube playlist at once.
Support 4chan archive.
Use old method of rendering body where rendering items is done up and down, starting from the selected item. This will make quickmedia run as fast when displaying 100 000 items as when displaying 10 items.
Use correct spacing when in grid (card) mode. Should be row spacing instead of body spacing.
@@ -290,4 +290,5 @@ Add command to ignore/hide a room (should also not get notified when you are men
Improve youtube live streaming performance. It's slow because mpv (ffmpeg) is slow at playing m3u8.
Add youtube config for allowing to specify download specific codec preference (or ignore video max height). Might have slow download so cant play high quality video but after downloading it it can be played.
Fix youtube age restricted videos.
-Support youtube playlists (not just playing the video, but also going to the next video. Maybe use mpv playlist feature for this). Also show playlists in search result and in channel page. \ No newline at end of file
+Direct link to youtube playlist should open the playlist page and select the playlist item.
+Support youtube mix. Doesn't work that nicely because the mix playlist doesn't show all items unless you click on the last item and then it will show more items. \ No newline at end of file
diff --git a/plugins/Youtube.hpp b/plugins/Youtube.hpp
index 0393739..22cb9e5 100644
--- a/plugins/Youtube.hpp
+++ b/plugins/Youtube.hpp
@@ -106,7 +106,8 @@ namespace QuickMedia {
enum class Type {
VIDEOS,
SHORTS,
- LIVE
+ LIVE,
+ PLAYLISTS
};
static void create_each_type(Program *program, std::string url, std::string continuation_token, std::string title, std::vector<Tab> &tabs);
@@ -154,9 +155,21 @@ namespace QuickMedia {
PluginResult submit(const SubmitArgs &args, std::vector<Tab> &result_tabs) override;
};
+ class YoutubePlaylistPage : public LazyFetchPage {
+ public:
+ YoutubePlaylistPage(Program *program, std::string url, std::string title) :
+ LazyFetchPage(program), url(std::move(url)), title(std::move(title)) {}
+ const char* get_title() const override { return title.c_str(); }
+ PluginResult submit(const SubmitArgs &args, std::vector<Tab> &result_tabs) override;
+ PluginResult lazy_fetch(BodyItems &result_items) override;
+ private:
+ std::string url;
+ std::string title;
+ };
+
class YoutubeVideoPage : public VideoPage {
public:
- YoutubeVideoPage(Program *program, std::string url, bool autoplay = true);
+ YoutubeVideoPage(Program *program, std::string url, bool autoplay = true, bool autoplay_next_item = false);
const char* get_title() const override { return ""; }
BodyItems get_related_media(const std::string &url) override;
PluginResult get_related_pages(const BodyItems &related_videos, const std::string &channel_url, std::vector<Tab> &result_tabs) override;
@@ -170,6 +183,7 @@ namespace QuickMedia {
void mark_watched() override;
void get_subtitles(SubtitleData &subtitle_data) override;
void set_watch_progress(int64_t time_pos_sec, int64_t duration_sec) override;
+ bool autoplay_next_item() override { return goto_next_item; }
private:
PluginResult parse_video_response(const Json::Value &json_root, std::string &title, std::string &channel_url, std::vector<MediaChapter> &chapters, std::string &err_str);
void parse_format(const Json::Value &format_json, bool is_adaptive);
@@ -186,5 +200,6 @@ namespace QuickMedia {
std::string watchtime_url;
std::string tracking_url;
YoutubeVideoDetails video_details;
+ bool goto_next_item;
};
}
diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp
index 513290e..2083df3 100644
--- a/src/QuickMedia.cpp
+++ b/src/QuickMedia.cpp
@@ -3471,15 +3471,20 @@ namespace QuickMedia {
current_page = previous_page;
go_to_previous_page = true;
} else {
- if(video_page->autoplay_next_item())
- return;
+ const bool autoplay_next_item = video_page->autoplay_next_item();
std::string url = video_page->get_url();
- related_videos.clear();
- related_videos_task = AsyncTask<void>([&related_videos, url, video_page]() {
- video_page->mark_watched();
- related_videos = video_page->get_related_media(url);
- });
+ if(autoplay_next_item) {
+ related_videos_task = AsyncTask<void>([url, video_page]() {
+ video_page->get_related_media(url);
+ });
+ } else {
+ related_videos.clear();
+ related_videos_task = AsyncTask<void>([&related_videos, url, video_page]() {
+ video_page->mark_watched();
+ related_videos = video_page->get_related_media(url);
+ });
+ }
// TODO: Make this also work for other video plugins
if(strcmp(plugin_name, "youtube") != 0 || is_resume_go_back)
diff --git a/src/plugins/Youtube.cpp b/src/plugins/Youtube.cpp
index 2959223..2e59a0b 100644
--- a/src/plugins/Youtube.cpp
+++ b/src/plugins/Youtube.cpp
@@ -538,6 +538,9 @@ namespace QuickMedia {
}
static std::shared_ptr<BodyItem> parse_common_video_item(const Json::Value &video_item_json, std::unordered_set<std::string> &added_videos) {
+ if(!video_item_json.isObject())
+ return nullptr;
+
const Json::Value &video_id_json = video_item_json["videoId"];
if(!video_id_json.isString())
return nullptr;
@@ -640,17 +643,6 @@ namespace QuickMedia {
return body_item;
}
- static std::shared_ptr<BodyItem> parse_content_video_renderer(const Json::Value &content_item_json, std::unordered_set<std::string> &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<BodyItem> parse_channel_renderer(const Json::Value &channel_renderer_json) {
if(!channel_renderer_json.isObject())
return nullptr;
@@ -724,6 +716,139 @@ namespace QuickMedia {
return token_json.asString();
}
+ static std::shared_ptr<BodyItem> parse_child_video_renderer(const Json::Value &child_video_renderer_json) {
+ if(!child_video_renderer_json.isObject())
+ return nullptr;
+
+ const Json::Value &video_id_json = child_video_renderer_json["videoId"];
+ if(!video_id_json.isString())
+ return nullptr;
+
+ std::optional<std::string> title = yt_json_get_text(child_video_renderer_json, "title");
+ std::optional<std::string> length = yt_json_get_text(child_video_renderer_json, "lengthText");
+
+ std::string title_str = title.value_or("No title") + " • " + length.value_or("0:00");
+ auto body_item = BodyItem::create(std::move(title_str));
+ body_item->url = video_id_json.asString();
+ return body_item;
+ }
+
+ static bool navigation_endpoint_get_video_id(const Json::Value &navigation_endpoint_json, std::string &video_id) {
+ if(!navigation_endpoint_json.isObject())
+ return false;
+
+ const Json::Value &watch_endpoint_json = navigation_endpoint_json["watchEndpoint"];
+ if(!watch_endpoint_json.isObject())
+ return false;
+
+ const Json::Value &video_id_json = watch_endpoint_json["videoId"];
+ if(!video_id_json.isString())
+ return false;
+
+ video_id = video_id_json.asString();
+ return true;
+ }
+
+ static std::shared_ptr<BodyItem> parse_playlist_renderer(const Json::Value &playlist_renderer_json) {
+ if(!playlist_renderer_json.isObject())
+ return nullptr;
+
+ const Json::Value &playlist_id_json = playlist_renderer_json["playlistId"];
+ const Json::Value &videos_json = playlist_renderer_json["videos"];
+ const Json::Value &video_count_json = playlist_renderer_json["videoCount"];
+ if(!playlist_id_json.isString())
+ return nullptr;
+
+ std::optional<std::string> video_count_short_text = yt_json_get_text(playlist_renderer_json, "videoCountShortText");
+ std::string video_count_text;
+ if(video_count_short_text)
+ video_count_text = video_count_short_text.value();
+ else if(video_count_json.isString())
+ video_count_text = video_count_json.asString();
+
+ std::optional<std::string> title = yt_json_get_text(playlist_renderer_json, "title");
+
+ std::optional<Thumbnail> thumbnail;
+ const Json::Value &thumbnail_json = playlist_renderer_json["thumbnail"];
+ if(thumbnail_json.isObject())
+ thumbnail = yt_json_get_thumbnail(thumbnail_json, ThumbnailSize::LARGEST);
+
+ const Json::Value &thumbnails_json = playlist_renderer_json["thumbnails"];
+ if(thumbnails_json.isArray() && !thumbnails_json.empty())
+ thumbnail = yt_json_get_thumbnail(thumbnails_json[0], ThumbnailSize::LARGEST);
+
+ std::string long_byline_text = yt_json_get_text(playlist_renderer_json, "longBylineText").value_or("");
+ if(long_byline_text.empty())
+ long_byline_text = yt_json_get_text(playlist_renderer_json, "shortBylineText").value_or("");
+
+ std::string video_id;
+ std::string description = std::move(long_byline_text);
+ if(!description.empty())
+ description += '\n';
+ description += video_count_text + " video" + (strcmp(video_count_text.c_str(), "1") == 0 ? "" : "s");
+ if(videos_json.isArray()) {
+ for(const Json::Value &video_json : videos_json) {
+ if(!video_json.isObject())
+ continue;
+
+ auto video_body_item = parse_child_video_renderer(video_json["childVideoRenderer"]);
+ if(video_body_item) {
+ //description += '\n';
+ //description += video_body_item->get_title();
+ if(video_id.empty())
+ video_id = video_body_item->url;
+ }
+ }
+ }
+
+ if(video_id.empty())
+ navigation_endpoint_get_video_id(playlist_renderer_json["navigationEndpoint"], video_id);
+
+ if(video_id.empty())
+ return nullptr;
+
+ auto body_item = BodyItem::create(title.value_or("No title"));
+ body_item->set_description(std::move(description));
+ body_item->set_description_color(get_theme().faded_text_color);
+ body_item->url = "https://www.youtube.com/watch?v=" + video_id + "&list=" + playlist_id_json.asString();
+ if(thumbnail) {
+ body_item->thumbnail_url = thumbnail->url;
+ body_item->thumbnail_size = { thumbnail->width, thumbnail->height };
+ }
+ return body_item;
+ }
+
+ static void parse_item_section_renderer_shelf_renderer(const Json::Value &shelf_renderer_json, std::unordered_set<std::string> &added_videos, BodyItems &result_items) {
+ if(!shelf_renderer_json.isObject())
+ return;
+
+ const Json::Value &item_content_json = shelf_renderer_json["content"];
+ if(!item_content_json.isObject())
+ return;
+
+ const Json::Value &vertical_list_renderer_json = item_content_json["verticalListRenderer"];
+ if(!vertical_list_renderer_json.isObject())
+ return;
+
+ const Json::Value &items_json = vertical_list_renderer_json["items"];
+ if(!items_json.isArray())
+ return;
+
+ for(const Json::Value &item_json : items_json) {
+ if(!item_json.isObject())
+ continue;
+
+ std::shared_ptr<BodyItem> body_item = parse_common_video_item(item_json["videoRenderer"], added_videos);
+ if(body_item)
+ result_items.push_back(std::move(body_item));
+
+ // TODO: Mix
+ //body_item = parse_playlist_renderer(item_json["radioRenderer"]);
+ //if(body_item)
+ // result_items.push_back(std::move(body_item));
+ }
+ }
+
static void parse_item_section_renderer(const Json::Value &item_section_renderer_json, std::unordered_set<std::string> &added_videos, BodyItems &result_items) {
if(!item_section_renderer_json.isObject())
return;
@@ -732,44 +857,29 @@ namespace QuickMedia {
if(!item_contents_json.isArray())
return;
+ std::shared_ptr<BodyItem> body_item;
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;
+ body_item = parse_channel_renderer(content_item_json["channelRenderer"]);
+ if(body_item)
+ result_items.push_back(std::move(body_item));
- const Json::Value &item_content_json = shelf_renderer_json["content"];
- if(!item_content_json.isObject())
- continue;
+ body_item = parse_playlist_renderer(content_item_json["playlistRenderer"]);
+ if(body_item)
+ result_items.push_back(std::move(body_item));
- const Json::Value &vertical_list_renderer_json = item_content_json["verticalListRenderer"];
- if(!vertical_list_renderer_json.isObject())
- continue;
+ // TODO: Mix
+ //body_item = parse_playlist_renderer(content_item_json["radioRenderer"]);
+ //if(body_item)
+ // result_items.push_back(std::move(body_item));
- 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<BodyItem> 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<BodyItem> body_item = parse_channel_renderer(*it);
- if(body_item)
- result_items.push_back(std::move(body_item));
- } else {
- std::shared_ptr<BodyItem> body_item = parse_content_video_renderer(content_item_json, added_videos);
- if(body_item)
- result_items.push_back(std::move(body_item));
- }
- }
+ parse_item_section_renderer_shelf_renderer(content_item_json["shelfRenderer"], added_videos, result_items);
+
+ body_item = parse_common_video_item(content_item_json["videoRenderer"], added_videos);
+ if(body_item)
+ result_items.push_back(std::move(body_item));
}
}
@@ -919,11 +1029,11 @@ namespace QuickMedia {
if(continuation_token.empty())
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;
+ std::shared_ptr<BodyItem> body_item = parse_common_video_item(item_json["gridVideoRenderer"], added_videos);
+ if(body_item)
+ body_items.push_back(std::move(body_item));
- auto body_item = parse_common_video_item(grid_video_renderer, added_videos);
+ body_item = parse_playlist_renderer(item_json["gridPlaylistRenderer"]);
if(body_item)
body_items.push_back(std::move(body_item));
}
@@ -1066,7 +1176,7 @@ namespace QuickMedia {
return SearchResult::ERR;
for(const Json::Value &json_item : search_result_list_json) {
- if(!json_item.isArray() || json_item.size() == 0)
+ if(!json_item.isArray() || json_item.empty())
continue;
const Json::Value &search_result_json = json_item[0];
@@ -1103,6 +1213,8 @@ namespace QuickMedia {
if(strncmp(args.url.c_str(), "https://www.youtube.com/channel/", 32) == 0) {
// TODO: Make all pages (for all services) lazy fetch in a similar manner!
YoutubeChannelPage::create_each_type(program, args.url, "", args.title, result_tabs);
+ } else if(strstr(args.url.c_str(), "&list=")) {
+ result_tabs.push_back(Tab{create_body(false, true), std::make_unique<YoutubePlaylistPage>(program, args.url, args.title), create_search_bar("Search...", SEARCH_DELAY_FILTER)});
} else {
result_tabs.push_back(Tab{nullptr, std::make_unique<YoutubeVideoPage>(program, args.url), nullptr});
}
@@ -1630,6 +1742,8 @@ namespace QuickMedia {
return "shorts";
case YoutubeChannelPage::Type::LIVE:
return "streams";
+ case YoutubeChannelPage::Type::PLAYLISTS:
+ return "playlists";
}
return "";
}
@@ -1642,6 +1756,8 @@ namespace QuickMedia {
return channel_name + " Shorts";
case YoutubeChannelPage::Type::LIVE:
return channel_name + " Live videos";
+ case YoutubeChannelPage::Type::PLAYLISTS:
+ return channel_name + " Playlists";
}
return "";
}
@@ -1651,6 +1767,7 @@ namespace QuickMedia {
tabs.push_back(Tab{program->create_body(false, true), std::make_unique<YoutubeChannelPage>(program, url, continuation_token, title, Type::VIDEOS), program->create_search_bar("Search...", 350)});
tabs.push_back(Tab{program->create_body(false, true), std::make_unique<YoutubeChannelPage>(program, url, continuation_token, title, Type::SHORTS), program->create_search_bar("Search...", 350)});
tabs.push_back(Tab{program->create_body(false, true), std::make_unique<YoutubeChannelPage>(program, url, continuation_token, title, Type::LIVE), program->create_search_bar("Search...", 350)});
+ tabs.push_back(Tab{program->create_body(false, true), std::make_unique<YoutubeChannelPage>(program, url, continuation_token, title, Type::PLAYLISTS), program->create_search_bar("Search...", 350)});
}
YoutubeChannelPage::YoutubeChannelPage(Program *program, std::string url, std::string continuation_token, std::string title, Type type) :
@@ -1879,7 +1996,12 @@ namespace QuickMedia {
PluginResult YoutubeChannelPage::submit(const SubmitArgs &args, std::vector<Tab> &result_tabs) {
if(args.url.empty())
return PluginResult::OK;
- result_tabs.push_back(Tab{nullptr, std::make_unique<YoutubeVideoPage>(program, args.url), nullptr});
+
+ if(strstr(args.url.c_str(), "&list=")) {
+ result_tabs.push_back(Tab{create_body(false, true), std::make_unique<YoutubePlaylistPage>(program, args.url, args.title), create_search_bar("Search...", SEARCH_DELAY_FILTER)});
+ } else {
+ result_tabs.push_back(Tab{nullptr, std::make_unique<YoutubeVideoPage>(program, args.url), nullptr});
+ }
return PluginResult::OK;
}
@@ -2135,6 +2257,79 @@ namespace QuickMedia {
return PluginResult::OK;
}
+ PluginResult YoutubePlaylistPage::submit(const SubmitArgs &args, std::vector<Tab> &result_tabs) {
+ result_tabs.push_back(Tab{nullptr, std::make_unique<YoutubeVideoPage>(program, args.url, true, true), nullptr});
+ return PluginResult::OK;
+ }
+
+ static void playlist_get_items(const Json::Value &json_root, std::unordered_set<std::string> &added_videos, BodyItems &result_items) {
+ if(!json_root.isObject())
+ return;
+
+ const Json::Value &response_json = json_root["response"];
+ if(!response_json.isObject())
+ return;
+
+ const Json::Value &contents_json = response_json["contents"];
+ if(!contents_json.isObject())
+ return;
+
+ const Json::Value &tcwnr_json = contents_json["twoColumnWatchNextResults"];
+ if(!tcwnr_json.isObject())
+ return;
+
+ const Json::Value &playlist_json = tcwnr_json["playlist"];
+ if(!playlist_json.isObject())
+ return;
+
+ const Json::Value &playlist_inner_json = playlist_json["playlist"];
+ if(!playlist_inner_json.isObject())
+ return;
+
+ //const Json::Value &title_json = playlist_inner_json["title"];
+ const Json::Value &playlist_contents_json = playlist_inner_json["contents"];
+ if(!playlist_contents_json.isArray())
+ return;
+
+ for(const Json::Value &content_json : playlist_contents_json) {
+ if(!content_json.isObject())
+ continue;
+
+ auto body_item = parse_common_video_item(content_json["playlistPanelVideoRenderer"], added_videos);
+ if(body_item)
+ result_items.push_back(std::move(body_item));
+ }
+ }
+
+ PluginResult YoutubePlaylistPage::lazy_fetch(BodyItems &result_items) {
+ std::vector<CommandArg> additional_args = {
+ { "-H", "x-youtube-client-name: 1" },
+ { "-H", youtube_client_version },
+ };
+
+ std::vector<CommandArg> cookies = get_cookies();
+ additional_args.insert(additional_args.end(), cookies.begin(), cookies.end());
+
+ Json::Value json_root;
+ DownloadResult download_result = download_json(json_root, url + "&pbj=1&gl=US&hl=en", additional_args, true);
+ if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result);
+
+ std::unordered_set<std::string> added_videos;
+ if(json_root.isObject()) {
+ playlist_get_items(json_root, added_videos, result_items);
+ return PluginResult::OK;
+ }
+
+ if(!json_root.isArray())
+ return PluginResult::OK;
+
+ for(const Json::Value &json_item : json_root) {
+ playlist_get_items(json_item, added_videos, result_items);
+ }
+
+ 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())
@@ -2192,7 +2387,9 @@ namespace QuickMedia {
return;
}
- YoutubeVideoPage::YoutubeVideoPage(Program *program, std::string url, bool autoplay) : VideoPage(program, "", autoplay) {
+ YoutubeVideoPage::YoutubeVideoPage(Program *program, std::string url, bool autoplay, bool autoplay_next_item) :
+ VideoPage(program, "", autoplay), goto_next_item(autoplay_next_item)
+ {
set_url(std::move(url));
}