From e8cf95fd56bb6cc16f937d06c3554260fd789a92 Mon Sep 17 00:00:00 2001 From: dec05eba Date: Wed, 3 Apr 2024 19:37:55 +0200 Subject: Youtube: fallback to yt-dlp, add support for sponsorblock (as chapters) --- TODO | 1 + example-config.json | 7 ++- include/Config.hpp | 6 ++ include/QuickMedia.hpp | 3 + plugins/Youtube.hpp | 3 + src/Config.cpp | 6 ++ src/QuickMedia.cpp | 50 +++++++++++++++-- src/VideoPlayer.cpp | 6 +- src/plugins/Youtube.cpp | 145 +++++++++++++++++++++++++++++++++++------------- 9 files changed, 182 insertions(+), 45 deletions(-) diff --git a/TODO b/TODO index a7d70ec..59dc944 100644 --- a/TODO +++ b/TODO @@ -296,3 +296,4 @@ Youtube community tab. v0.m3u8 doesn't work for some lbry videos (such as https://odysee.com/@MoneroMagazine:9/Privacy-101-w-Luke-Smith:2). Fallback to v1.m3u8 (next track in the master.m3u8 file) in such cases. Use DPMSInfoNotify. Use stb_image_resize2.h +Youtube audio only download if audio stream not available, also for youtube-dl fallback. \ No newline at end of file diff --git a/example-config.json b/example-config.json index 1977b1e..8a28b89 100644 --- a/example-config.json +++ b/example-config.json @@ -49,7 +49,12 @@ "load_progress": true, // The invidious instance to use. This is currently only used to show a video feed. // If not set, then local recommendations are used instead. - "invidious_instance": "" + "invidious_instance": "", + "sponsorblock": { + "enable": false, + // If the sponsor segment has less votes than this in sponsorblock then it will be ignored + "min_votes": 0 + } }, "matrix": { // List of homeservers to display in the "Room directory" tab diff --git a/include/Config.hpp b/include/Config.hpp index 0b0f021..11d7679 100644 --- a/include/Config.hpp +++ b/include/Config.hpp @@ -49,9 +49,15 @@ namespace QuickMedia { bool auto_group_episodes = true; }; + struct YoutubeSponsorblock { + bool enable = false; + int min_votes = 0; + }; + struct YoutubeConfig { bool load_progress = true; std::string invidious_instance; + YoutubeSponsorblock sponsorblock; }; struct MatrixConfig { diff --git a/include/QuickMedia.hpp b/include/QuickMedia.hpp index 0f8837d..a6de75c 100644 --- a/include/QuickMedia.hpp +++ b/include/QuickMedia.hpp @@ -117,8 +117,10 @@ namespace QuickMedia { void save_recommendations_from_related_videos(const char *plugin_name, const std::string &video_url, const std::string &video_title, const BodyItems &related_media_body_items); void set_clipboard(const std::string &str); + bool youtube_dl_extract_url(const std::string &url, std::string &video_url, std::string &audio_url); private: void init(mgl::WindowHandle parent_window, std::string &program_path, bool no_dialog); + const char* get_youtube_dl_program_name(); void check_youtube_dl_installed(const std::string &plugin_name); void load_plugin_by_name(std::vector &tabs, int &start_tab_index, FileManagerMimeType fm_mime_type, FileSelectionHandler file_selection_handler, std::string instance); void common_event_handler(mgl::Event &event); @@ -242,5 +244,6 @@ namespace QuickMedia { int video_max_height = 0; std::mutex login_inputs_mutex; const char *yt_dl_name = nullptr; + bool yt_dl_name_checked = false; }; } diff --git a/plugins/Youtube.hpp b/plugins/Youtube.hpp index 77aa86c..ac92526 100644 --- a/plugins/Youtube.hpp +++ b/plugins/Youtube.hpp @@ -203,5 +203,8 @@ namespace QuickMedia { std::string tracking_url; YoutubeVideoDetails video_details; bool goto_next_item; + bool use_youtube_dl_fallback = false; + std::string youtube_dl_video_fallback_url; + std::string youtube_dl_audio_fallback_url; }; } diff --git a/src/Config.cpp b/src/Config.cpp index 191beb4..cc1c476 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -280,6 +280,12 @@ namespace QuickMedia { if(youtube_json.isObject()) { get_json_value(youtube_json, "load_progress", config->youtube.load_progress); get_json_value(youtube_json, "invidious_instance", config->youtube.invidious_instance); + + const Json::Value &sponsorblock_json = youtube_json["sponsorblock"]; + if(sponsorblock_json.isObject()) { + get_json_value(sponsorblock_json, "enable", config->youtube.sponsorblock.enable); + get_json_value(sponsorblock_json, "min_votes", config->youtube.sponsorblock.min_votes); + } } bool has_known_matrix_homeservers_config = false; diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index b47ab13..c57540c 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -1089,18 +1089,58 @@ namespace QuickMedia { .related_media_thumbnail_handler({{"//div[data-role='video-relations']//img", "src", "xhcdn"}}); } - void Program::check_youtube_dl_installed(const std::string &plugin_name) { - if(yt_dl_name) - return; + bool Program::youtube_dl_extract_url(const std::string &url, std::string &video_url, std::string &audio_url) { + const char *youtube_dl_name = get_youtube_dl_program_name(); + if(!youtube_dl_name) + return false; + + std::string ytdl_format; + if(no_video) + ytdl_format = "(bestaudio/best)"; + else + ytdl_format = "(bestvideo[vcodec!*=av01][height<=?" + std::to_string(video_max_height) + "]+bestaudio/best)"; + + ytdl_format += "[protocol^=http]/" + ytdl_format + "[protocol^=m3u8]"; + + std::string result; + const char *args[] = { youtube_dl_name, "--no-warnings", "-f", ytdl_format.c_str(), "-g", "--", url.c_str(), nullptr }; + if(exec_program(args, accumulate_string, &result) != 0) + return false; + + string_split(result, '\n', [&](const char *str, size_t size) { + if(video_url.empty()) + video_url.assign(str, size); + else if(audio_url.empty()) + audio_url.assign(str, size); + return true; + }, false); + + return true; + } + + const char* Program::get_youtube_dl_program_name() { + if(yt_dl_name_checked) + return yt_dl_name; if(is_program_executable_by_name("yt-dlp")) { yt_dl_name = "yt-dlp"; } else if(is_program_executable_by_name("youtube-dl")) { yt_dl_name = "youtube-dl"; } else { - show_notification("QuickMedia", "yt-dlp or youtube-dl needs to be installed to play " + plugin_name + " videos", Urgency::CRITICAL); - exit(10); + yt_dl_name = nullptr; } + + yt_dl_name_checked = true; + return yt_dl_name; + } + + void Program::check_youtube_dl_installed(const std::string &plugin_name) { + get_youtube_dl_program_name(); + if(yt_dl_name) + return; + + show_notification("QuickMedia", "yt-dlp or youtube-dl needs to be installed to play " + plugin_name + " videos", Urgency::CRITICAL); + exit(10); } void Program::load_plugin_by_name(std::vector &tabs, int &start_tab_index, FileManagerMimeType fm_mime_type, FileSelectionHandler file_selection_handler, std::string instance) { diff --git a/src/VideoPlayer.cpp b/src/VideoPlayer.cpp index 4642855..5369a07 100644 --- a/src/VideoPlayer.cpp +++ b/src/VideoPlayer.cpp @@ -47,7 +47,11 @@ namespace QuickMedia { static const int MAX_RETRIES_CONNECT = 1000; static const double READ_TIMEOUT_SEC = 3.0; - static std::string media_chapters_to_ffmetadata_chapters(const std::vector &chapters) { + static std::string media_chapters_to_ffmetadata_chapters(std::vector chapters) { + std::sort(chapters.begin(), chapters.end(), [](const MediaChapter &a, const MediaChapter &b) { + return a.start_seconds < b.start_seconds; + }); + std::string result = ";FFMETADATA1\n\n"; for(size_t i = 0; i < chapters.size(); ++i) { const MediaChapter &chapter = chapters[i]; diff --git a/src/plugins/Youtube.cpp b/src/plugins/Youtube.cpp index fef5fce..482843e 100644 --- a/src/plugins/Youtube.cpp +++ b/src/plugins/Youtube.cpp @@ -2857,6 +2857,12 @@ namespace QuickMedia { } std::string YoutubeVideoPage::get_video_url(int max_height, bool &has_embedded_audio, std::string &ext) { + if(use_youtube_dl_fallback) { + has_embedded_audio = youtube_dl_audio_fallback_url.empty(); + ext = ".webm"; // TODO: Guess for now + return youtube_dl_video_fallback_url; + } + if(!livestream_url.empty() && video_formats.empty() && audio_formats.empty()) { has_embedded_audio = true; return livestream_url; @@ -2892,6 +2898,11 @@ namespace QuickMedia { } std::string YoutubeVideoPage::get_audio_url(std::string &ext) { + if(use_youtube_dl_fallback) { + ext = ".opus"; // TODO: Guess for now + return youtube_dl_audio_fallback_url; + } + if(audio_formats.empty()) return ""; @@ -3054,6 +3065,56 @@ namespace QuickMedia { video_details.duration = 0.0; } + static void sponsorblock_add_chapters(Page *page, const std::string &url, int min_votes, std::vector &chapters) { + std::string video_id; + if(!youtube_url_extract_id(url, video_id)) { + fprintf(stderr, "Failed to extract youtube id from %s\n", url.c_str()); + return; + } + + const std::string sponsorblock_url = "https://sponsor.ajay.app/api/skipSegments?videoID=" + video_id; + Json::Value json_root; + if(page->download_json(json_root, sponsorblock_url, {}) != DownloadResult::OK) + return; + + if(!json_root.isArray()) + return; + + for(const Json::Value &item_json : json_root) { + if(!item_json.isObject()) + continue; + + const Json::Value &category_json = item_json["category"]; + const Json::Value &action_type_json = item_json["actionType"]; + const Json::Value &segment_json = item_json["segment"]; + const Json::Value &votes_json = item_json["votes"]; + if(!category_json.isString() || !action_type_json.isString() || !segment_json.isArray() || !votes_json.isInt()) + continue; + + if(strcmp(category_json.asCString(), "sponsor") != 0 || strcmp(action_type_json.asCString(), "skip") != 0) + continue; + + if(segment_json.size() != 2) + continue; + + if(!segment_json[0].isDouble() || !segment_json[1].isDouble()) + continue; + + if(votes_json.asInt() < min_votes) + continue; + + MediaChapter ad_start; + ad_start.start_seconds = segment_json[0].asDouble(); + ad_start.title = "Ad start"; + chapters.push_back(std::move(ad_start)); + + MediaChapter ad_end; + ad_end.start_seconds = segment_json[1].asDouble(); + ad_end.title = "Ad end"; + chapters.push_back(std::move(ad_end)); + } + } + PluginResult YoutubeVideoPage::parse_video_response(const Json::Value &json_root, std::string &title, std::string &channel_url, std::vector &chapters, std::string &err_str) { livestream_url.clear(); video_formats.clear(); @@ -3062,45 +3123,13 @@ namespace QuickMedia { title.clear(); channel_url.clear(); chapters.clear(); + youtube_dl_video_fallback_url.clear(); + youtube_dl_audio_fallback_url.clear(); video_details_clear(video_details); if(!json_root.isObject()) return PluginResult::ERR; - const Json::Value &playability_status_json = json_root["playabilityStatus"]; - if(playability_status_json.isObject()) { - const Json::Value &status_json = playability_status_json["status"]; - if(status_json.isString() && (strcmp(status_json.asCString(), "UNPLAYABLE") == 0 || strcmp(status_json.asCString(), "LOGIN_REQUIRED") == 0)) { - const Json::Value &reason_json = playability_status_json["reason"]; - if(reason_json.isString()) - err_str = reason_json.asString(); - fprintf(stderr, "Unable to play video, status: %s, reason: %s\n", status_json.asCString(), reason_json.isString() ? reason_json.asCString() : "Unknown"); - return PluginResult::ERR; - } - } - - const Json::Value *streaming_data_json = &json_root["streamingData"]; - if(!streaming_data_json->isObject()) - return PluginResult::ERR; - - // TODO: Verify if this always works (what about copyrighted live streams?), also what about choosing video quality for live stream? Maybe use mpv --hls-bitrate option? - const Json::Value &hls_manifest_url_json = (*streaming_data_json)["hlsManifestUrl"]; - if(hls_manifest_url_json.isString()) - livestream_url = hls_manifest_url_json.asString(); - - /* - const Json::Value &dash_manifest_url_json = (*streaming_data_json)["dashManifestUrl"]; - if(livestream_url.empty() && dash_manifest_url_json.isString()) { - // TODO: mpv cant properly play dash videos. Video goes back and replays. - // So for now return here (get_video_info only hash dash stream and no hls stream) which will fallback to the player youtube endpoint which has hls stream. - return PluginResult::ERR; - } - */ - - parse_formats(*streaming_data_json); - if(video_formats.empty() && audio_formats.empty() && livestream_url.empty()) - return PluginResult::ERR; - const Json::Value &video_details_json = json_root["videoDetails"]; if(video_details_json.isObject()) { const Json::Value &channel_id_json = video_details_json["channelId"]; @@ -3166,6 +3195,48 @@ namespace QuickMedia { } } + const Json::Value &playability_status_json = json_root["playabilityStatus"]; + if(playability_status_json.isObject()) { + const Json::Value &status_json = playability_status_json["status"]; + if(status_json.isString() && (strcmp(status_json.asCString(), "UNPLAYABLE") == 0 || strcmp(status_json.asCString(), "LOGIN_REQUIRED") == 0)) { + fprintf(stderr, "Failed to load youtube video, trying with yt-dlp instead\n"); + if(program->youtube_dl_extract_url(url, youtube_dl_video_fallback_url, youtube_dl_audio_fallback_url)) { + if(get_config().youtube.sponsorblock.enable) + sponsorblock_add_chapters(this, url, get_config().youtube.sponsorblock.min_votes, chapters); + use_youtube_dl_fallback = true; + return PluginResult::OK; + } else { + const Json::Value &reason_json = playability_status_json["reason"]; + if(reason_json.isString()) + err_str = reason_json.asString(); + fprintf(stderr, "Unable to play video, status: %s, reason: %s\n", status_json.asCString(), reason_json.isString() ? reason_json.asCString() : "Unknown"); + return PluginResult::ERR; + } + } + } + + const Json::Value *streaming_data_json = &json_root["streamingData"]; + if(!streaming_data_json->isObject()) + return PluginResult::ERR; + + // TODO: Verify if this always works (what about copyrighted live streams?), also what about choosing video quality for live stream? Maybe use mpv --hls-bitrate option? + const Json::Value &hls_manifest_url_json = (*streaming_data_json)["hlsManifestUrl"]; + if(hls_manifest_url_json.isString()) + livestream_url = hls_manifest_url_json.asString(); + + /* + const Json::Value &dash_manifest_url_json = (*streaming_data_json)["dashManifestUrl"]; + if(livestream_url.empty() && dash_manifest_url_json.isString()) { + // TODO: mpv cant properly play dash videos. Video goes back and replays. + // So for now return here (get_video_info only hash dash stream and no hls stream) which will fallback to the player youtube endpoint which has hls stream. + return PluginResult::ERR; + } + */ + + parse_formats(*streaming_data_json); + if(video_formats.empty() && audio_formats.empty() && livestream_url.empty()) + return PluginResult::ERR; + std::sort(video_formats.begin(), video_formats.end(), [](const YoutubeVideoFormat &format1, const YoutubeVideoFormat &format2) { return format1.base.bitrate > format2.base.bitrate; }); @@ -3174,6 +3245,8 @@ namespace QuickMedia { return format1.base.bitrate > format2.base.bitrate; }); + if(get_config().youtube.sponsorblock.enable) + sponsorblock_add_chapters(this, url, get_config().youtube.sponsorblock.min_votes, chapters); return PluginResult::OK; } @@ -3184,10 +3257,6 @@ namespace QuickMedia { return PluginResult::ERR; } - // The first one works for copyrighted videos and regular videos but only if they can be embedded. - // The second one works for age restricted videos and regular videos but only if they can be embedded. It doesn't work for copyrighted videos. - // The third one works for all non-copyrighted, non-age restricted videos, embeddable or not. - const int num_request_types = 1; std::string request_data[num_request_types] = { R"END( -- cgit v1.2.3