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) --- src/Config.cpp | 6 ++ src/QuickMedia.cpp | 50 +++++++++++++++-- src/VideoPlayer.cpp | 6 +- src/plugins/Youtube.cpp | 145 +++++++++++++++++++++++++++++++++++------------- 4 files changed, 163 insertions(+), 44 deletions(-) (limited to 'src') 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