From 85c6c916541968f298badb391b718cdf6d81d332 Mon Sep 17 00:00:00 2001 From: dec05eba Date: Sat, 19 Jun 2021 13:13:59 +0200 Subject: Support youtube description chapters --- src/QuickMedia.cpp | 22 +++++++++--------- src/VideoPlayer.cpp | 59 ++++++++++++++++++++++++++++++++++++++++++++++++- src/plugins/Youtube.cpp | 52 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 120 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index bf6cf7e..a4ea258 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -2412,7 +2412,6 @@ namespace QuickMedia { if(create_directory_recursive(video_cache_dir) != 0) { show_notification("QuickMedia", "Failed to create video cache directory", Urgency::CRITICAL); current_page = previous_page; - go_to_previous_page = true; return false; } @@ -2435,18 +2434,15 @@ namespace QuickMedia { case TaskResult::FALSE: { show_notification("QuickMedia", "Failed to download " + video_url, Urgency::CRITICAL); current_page = previous_page; - go_to_previous_page = true; return false; } case TaskResult::CANCEL: { current_page = previous_page; - go_to_previous_page = true; return false; } } } else if(video_is_not_streamble_result == TaskResult::CANCEL) { current_page = previous_page; - go_to_previous_page = true; return false; } } @@ -2496,8 +2492,9 @@ namespace QuickMedia { const int num_load_tries_max = 3; int load_try = 0; std::string prev_start_time; + std::vector media_chapters; - auto load_video_error_check = [this, &prev_start_time, &in_seeking, &video_url, &audio_url, &video_title, &video_tasks, &channel_url, previous_page, &go_to_previous_page, &video_loaded, video_page, &video_event_callback, &on_window_create, &video_player_window, is_youtube, is_matrix, download_if_streaming_fails](std::string start_time = "", bool reuse_media_source = false) mutable { + auto load_video_error_check = [this, &prev_start_time, &media_chapters, &in_seeking, &video_url, &audio_url, &video_title, &video_tasks, &channel_url, previous_page, &go_to_previous_page, &video_loaded, video_page, &video_event_callback, &on_window_create, &video_player_window, is_youtube, is_matrix, download_if_streaming_fails](std::string start_time = "", bool reuse_media_source = false) mutable { video_player.reset(); channel_url.clear(); video_loaded = false; @@ -2510,8 +2507,8 @@ namespace QuickMedia { if(!reuse_media_source) { std::string new_title; - TaskResult load_result = run_task_with_loading_screen([video_page, &new_title, &channel_url]() { - return video_page->load(new_title, channel_url) == PluginResult::OK; + TaskResult load_result = run_task_with_loading_screen([video_page, &new_title, &channel_url, &media_chapters]() { + return video_page->load(new_title, channel_url, media_chapters) == PluginResult::OK; }); if(!new_title.empty()) @@ -2547,8 +2544,10 @@ namespace QuickMedia { } if(!is_youtube && download_if_streaming_fails) { - if(!video_download_if_non_streamable(video_url, audio_url, is_audio_only, has_embedded_audio, previous_page)) + if(!video_download_if_non_streamable(video_url, audio_url, is_audio_only, has_embedded_audio, previous_page)) { + go_to_previous_page = true; return; + } } } @@ -2560,7 +2559,7 @@ namespace QuickMedia { watched_videos.insert(video_page->get_url()); video_player = std::make_unique(is_audio_only, use_system_mpv_config, is_matrix && !is_youtube, video_event_callback, on_window_create, resources_root, largest_monitor_height); - VideoPlayer::Error err = video_player->load_video(video_url.c_str(), audio_url.c_str(), window.getSystemHandle(), is_youtube, video_title, start_time); + VideoPlayer::Error err = video_player->load_video(video_url.c_str(), audio_url.c_str(), window.getSystemHandle(), is_youtube, video_title, start_time, media_chapters); if(err != VideoPlayer::Error::OK) { std::string err_msg = "Failed to play url: "; err_msg += video_page->get_url(); @@ -3722,6 +3721,7 @@ namespace QuickMedia { comment_page_scroll_stack.clear(); } redraw = true; + idle_active_handler(); } else { BodyItem *selected_item = thread_body->get_selected(); if(selected_item && !selected_item->url.empty()) { @@ -6446,7 +6446,7 @@ namespace QuickMedia { bool start() override { remove(output_filepath.c_str()); - std::vector args = { "youtube-dl", "--no-warnings", "--no-continue", "--output", output_filepath.c_str(), "--newline" }; + std::vector args = { "youtube-dl", "-f", "bestvideo+bestaudio/best", "--no-warnings", "--no-continue", "--output", output_filepath.c_str(), "--newline" }; if(no_video) args.push_back("-x"); args.insert(args.end(), { "--", url.c_str(), nullptr }); @@ -6573,7 +6573,7 @@ namespace QuickMedia { if(download_use_youtube_dl) { task_result = run_task_with_loading_screen([this, url, &filename]{ std::string json_str; - std::vector args = { "youtube-dl", "--skip-download", "--print-json", "--no-warnings" }; + std::vector args = { "youtube-dl", "-f", "bestvideo+bestaudio/best", "--skip-download", "--print-json", "--no-warnings" }; if(no_video) args.push_back("-x"); args.insert(args.end(), { "--", url, nullptr }); if(exec_program(args.data(), accumulate_string, &json_str) != 0) diff --git a/src/VideoPlayer.cpp b/src/VideoPlayer.cpp index 9e17bda..1d854de 100644 --- a/src/VideoPlayer.cpp +++ b/src/VideoPlayer.cpp @@ -2,6 +2,7 @@ #include "../include/Storage.hpp" #include "../include/Program.hpp" #include "../include/Utils.hpp" +#include "../include/StringUtils.hpp" #include "../include/Notification.hpp" #include #include @@ -20,6 +21,49 @@ const int MAX_RETRIES_CONNECT = 1000; const int READ_TIMEOUT_MS = 200; namespace QuickMedia { + static std::string media_chapters_to_ffmetadata_chapters(const std::vector &chapters) { + std::string result = ";FFMETADATA1\n\n"; + for(size_t i = 0; i < chapters.size(); ++i) { + const MediaChapter &chapter = chapters[i]; + result += "[CHAPTER]\n"; + result += "TIMEBASE=1/1\n"; + result += "START=" + std::to_string(chapter.start_seconds) + "\n"; + result += "END=" + std::to_string(i + 1 == chapters.size() ? chapter.start_seconds : chapters[i].start_seconds) + "\n"; + std::string title = chapter.title; + string_replace_all(title, '\n', ' '); + result += "title=" + std::move(title) + "\n\n"; + } + return result; + } + + // If |chapters| is empty then |tmp_chapters_filepath| is removed, otherwise the file is overwritten + static bool create_tmp_file_with_chapters_data(char *tmp_chapters_filepath, const std::vector &chapters) { + if(chapters.empty()) { + if(tmp_chapters_filepath[0] != '\0') { + remove(tmp_chapters_filepath); + tmp_chapters_filepath[0] = '\0'; + } + return true; + } + + if(tmp_chapters_filepath[0] == '\0') { + strcpy(tmp_chapters_filepath, "/tmp/qm-mpv-chapter-XXXXXX"); + mktemp(tmp_chapters_filepath); + if(tmp_chapters_filepath[0] == '\0') { + fprintf(stderr, "Failed to create temporay file\n"); + return false; + } + } + + if(file_overwrite(tmp_chapters_filepath, media_chapters_to_ffmetadata_chapters(chapters)) == 0) { + return true; + } else { + remove(tmp_chapters_filepath); + tmp_chapters_filepath[0] = '\0'; + return false; + } + } + VideoPlayer::VideoPlayer(bool no_video, bool use_system_mpv_config, bool keep_open, EventCallbackFunc _event_callback, VideoPlayerWindowCreateCallback _window_create_callback, const std::string &resource_root, int monitor_height) : exit_status(0), no_video(no_video), @@ -41,6 +85,7 @@ namespace QuickMedia { response_data_status(ResponseDataStatus::NONE), resource_root(resource_root) { + tmp_chapters_filepath[0] = '\0'; display = XOpenDisplay(NULL); if (!display) { show_notification("QuickMedia", "Failed to open display to X11 server", Urgency::CRITICAL); @@ -64,6 +109,9 @@ namespace QuickMedia { if(display) XCloseDisplay(display); + + if(tmp_chapters_filepath[0] != '\0') + remove(tmp_chapters_filepath); } VideoPlayer::Error VideoPlayer::launch_video_process(const char *path, const char *audio_path, sf::WindowHandle _parent_window, bool is_youtube, const std::string &title, const std::string &start_time) { @@ -141,6 +189,12 @@ namespace QuickMedia { if(no_video) args.push_back("--no-video"); + std::string chapters_file_arg; + if(tmp_chapters_filepath[0] != '\0') { + chapters_file_arg = std::string("--chapters-file=") + tmp_chapters_filepath; + args.push_back(chapters_file_arg.c_str()); + } + std::string audio_file_arg; if(audio_path && audio_path[0] != '\0') { audio_file_arg = std::string("--audio-file=") + audio_path; @@ -171,11 +225,14 @@ namespace QuickMedia { return Error::OK; } - VideoPlayer::Error VideoPlayer::load_video(const char *path, const char *audio_path, sf::WindowHandle _parent_window, bool is_youtube, const std::string &title, const std::string &start_time) { + VideoPlayer::Error VideoPlayer::load_video(const char *path, const char *audio_path, sf::WindowHandle _parent_window, bool is_youtube, const std::string &title, const std::string &start_time, const std::vector &chapters) { // This check is to make sure we dont change window that the video belongs to. This is not a usecase we will have so // no need to support it for now at least. assert(parent_window == 0 || parent_window == _parent_window); assert(path); + if(!create_tmp_file_with_chapters_data(tmp_chapters_filepath, chapters)) + fprintf(stderr, "Warning: failed to create chapters file. Chapters will not be displayed\n"); + fprintf(stderr, "Playing video: %s, audio: %s\n", path ? path : "", audio_path ? audio_path : ""); if(video_process_id == -1) return launch_video_process(path, audio_path, _parent_window, is_youtube, title, start_time); diff --git a/src/plugins/Youtube.cpp b/src/plugins/Youtube.cpp index a8c58cb..b45b5fe 100644 --- a/src/plugins/Youtube.cpp +++ b/src/plugins/Youtube.cpp @@ -5,6 +5,7 @@ #include "../../include/StringUtils.hpp" #include "../../include/Scale.hpp" #include "../../include/Notification.hpp" +#include "../../include/VideoPlayer.hpp" #include "../../include/Utils.hpp" #include extern "C" { @@ -2019,7 +2020,49 @@ R"END( return chosen_audio_format->base.url; } - PluginResult YoutubeVideoPage::load(std::string &title, std::string &channel_url) { + // Returns -1 if timestamp is in an invalid format + static int youtube_comment_timestamp_to_seconds(const char *str, size_t size) { + if(size > 30) + return -1; + + char timestamp[32]; + memcpy(timestamp, str, size); + timestamp[size] = '\0'; + + int hours = 0; + int minutes = 0; + int seconds = 0; + if(sscanf(timestamp, "%d:%d:%d", &hours, &minutes, &seconds) == 3) + return (hours * 60 * 60) + (minutes * 60) + seconds; + if(sscanf(timestamp, "%d:%d", &minutes, &seconds) == 2) + return (minutes * 60) + seconds; + if(sscanf(timestamp, "%d", &seconds) == 1) + return seconds; + return -1; + } + + static std::vector youtube_description_extract_chapters(const std::string &description) { + std::vector result; + string_split(description, '\n', [&result](const char *str, size_t size) { + const void *first_space_p = memchr(str, ' ', size); + if(!first_space_p) + return true; + + int timestamp_seconds = youtube_comment_timestamp_to_seconds(str, (const char*)first_space_p - str); + if(timestamp_seconds == -1) + return true; + + MediaChapter chapter; + chapter.start_seconds = timestamp_seconds; + chapter.title.assign((const char*)first_space_p, (str + size) - (const char*)first_space_p); + chapter.title = strip(chapter.title); + result.push_back(std::move(chapter)); + return true; + }); + return result; + } + + PluginResult YoutubeVideoPage::load(std::string &title, std::string &channel_url, std::vector &chapters) { hls_manifest_url.clear(); video_formats.clear(); audio_formats.clear(); @@ -2084,6 +2127,13 @@ R"END( 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(); + + const Json::Value &description_json = video_details_json["shortDescription"]; + if(description_json.isString()) { + std::string description = description_json.asString(); + string_replace_all(description, '+', ' '); + chapters = youtube_description_extract_chapters(description); + } } const Json::Value &playback_tracing_json = json_root["playbackTracking"]; -- cgit v1.2.3