aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordec05eba <dec05eba@protonmail.com>2024-04-03 19:37:55 +0200
committerdec05eba <dec05eba@protonmail.com>2024-04-03 19:37:55 +0200
commite8cf95fd56bb6cc16f937d06c3554260fd789a92 (patch)
treeda92b53f153a4377b6aed960f6a14379035e00d5
parent714ed0e235a600502c489d08d78b3781e18fc327 (diff)
Youtube: fallback to yt-dlp, add support for sponsorblock (as chapters)
-rw-r--r--TODO1
-rw-r--r--example-config.json7
-rw-r--r--include/Config.hpp6
-rw-r--r--include/QuickMedia.hpp3
-rw-r--r--plugins/Youtube.hpp3
-rw-r--r--src/Config.cpp6
-rw-r--r--src/QuickMedia.cpp50
-rw-r--r--src/VideoPlayer.cpp6
-rw-r--r--src/plugins/Youtube.cpp145
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<Tab> &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<Tab> &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<MediaChapter> &chapters) {
+ static std::string media_chapters_to_ffmetadata_chapters(std::vector<MediaChapter> 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<MediaChapter> &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<MediaChapter> &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(