aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordec05eba <dec05eba@protonmail.com>2021-06-19 13:13:59 +0200
committerdec05eba <dec05eba@protonmail.com>2021-06-19 13:13:59 +0200
commit85c6c916541968f298badb391b718cdf6d81d332 (patch)
treebf69ea6c1d3a67ec7d41786b5cf883f93b3b87c6
parent3f66a053942be531fe124cc16bb6b2e0eb94934e (diff)
Support youtube description chapters
-rw-r--r--TODO1
-rw-r--r--include/MediaChapter.hpp10
-rw-r--r--include/VideoPlayer.hpp7
-rw-r--r--plugins/Page.hpp3
-rw-r--r--plugins/Youtube.hpp2
-rw-r--r--src/QuickMedia.cpp22
-rw-r--r--src/VideoPlayer.cpp59
-rw-r--r--src/plugins/Youtube.cpp52
8 files changed, 136 insertions, 20 deletions
diff --git a/TODO b/TODO
index 68b0fc5..dbb95cc 100644
--- a/TODO
+++ b/TODO
@@ -162,7 +162,6 @@ Add loading of english subtitles for youtube.
Update item height when it switches from not being merged with previous to being merged with previous. This happens when loading previous messages in matrix and the message is the top one.
Reload youtube video url if the video is idle for too long. The video url is only valid for a specific amount of time (the valid duration is in the json).
Improve live stream startup time by downloading the video formats in parts instead of the hls manifest?
-Add youtube chapters.
Disable drop shadow on pinephone.
Load the next page in chapter list when reaching the bottom (when going to previous chapters in image view).
Loading image background should be rounded.
diff --git a/include/MediaChapter.hpp b/include/MediaChapter.hpp
new file mode 100644
index 0000000..f90493b
--- /dev/null
+++ b/include/MediaChapter.hpp
@@ -0,0 +1,10 @@
+#pragma once
+
+#include <string>
+
+namespace QuickMedia {
+ struct MediaChapter {
+ int start_seconds = 0;
+ std::string title; // newlines will be replaced by spaces
+ };
+} \ No newline at end of file
diff --git a/include/VideoPlayer.hpp b/include/VideoPlayer.hpp
index a7906a2..1a1e418 100644
--- a/include/VideoPlayer.hpp
+++ b/include/VideoPlayer.hpp
@@ -1,12 +1,10 @@
#pragma once
+#include "MediaChapter.hpp"
#include <SFML/Window/WindowHandle.hpp>
#include <SFML/System/Clock.hpp>
-#include <stdio.h>
#include <functional>
#include <json/value.h>
-
-#include <sys/un.h>
#include <X11/Xlib.h>
namespace QuickMedia {
@@ -41,7 +39,7 @@ namespace QuickMedia {
VideoPlayer& operator=(const VideoPlayer&) = delete;
// |audio_path| is only set when video and audio are separate files/urls.
- Error 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 = "");
+ Error 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<MediaChapter> &chapters = {});
// Should be called every update frame
Error update();
@@ -83,5 +81,6 @@ namespace QuickMedia {
};
ResponseDataStatus response_data_status;
std::string resource_root;
+ char tmp_chapters_filepath[27];
};
}
diff --git a/plugins/Page.hpp b/plugins/Page.hpp
index 39222b1..0d45b9e 100644
--- a/plugins/Page.hpp
+++ b/plugins/Page.hpp
@@ -5,6 +5,7 @@
#include "../include/Tab.hpp"
#include "../include/SearchBar.hpp"
#include "../include/Body.hpp"
+#include "../include/MediaChapter.hpp"
namespace QuickMedia {
constexpr int SEARCH_DELAY_FILTER = 50;
@@ -126,7 +127,7 @@ namespace QuickMedia {
virtual std::string get_audio_url() { return ""; }
virtual std::string url_get_playable_url(const std::string &url) { return url; }
virtual bool video_should_be_skipped(const std::string &url) { (void)url; return false; }
- virtual PluginResult load(std::string &title, std::string &channel_url) { (void)title; (void)channel_url; return PluginResult::OK; }
+ virtual PluginResult load(std::string &title, std::string &channel_url, std::vector<MediaChapter> &chapters) { (void)title; (void)channel_url; (void)chapters; return PluginResult::OK; }
virtual void mark_watched() {};
protected:
std::string url;
diff --git a/plugins/Youtube.hpp b/plugins/Youtube.hpp
index d8c4cc2..dcccc88 100644
--- a/plugins/Youtube.hpp
+++ b/plugins/Youtube.hpp
@@ -143,7 +143,7 @@ namespace QuickMedia {
std::string get_url_timestamp() override { return timestamp; }
std::string get_video_url(int max_height, bool &has_embedded_audio) override;
std::string get_audio_url() override;
- PluginResult load(std::string &title, std::string &channel_url) override;
+ PluginResult load(std::string &title, std::string &channel_url, std::vector<MediaChapter> &chapters) override;
void mark_watched() override;
private:
void parse_format(const Json::Value &format_json, bool is_adaptive);
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<MediaChapter> 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<VideoPlayer>(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<const char*> args = { "youtube-dl", "--no-warnings", "--no-continue", "--output", output_filepath.c_str(), "--newline" };
+ std::vector<const char*> 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<const char*> args = { "youtube-dl", "--skip-download", "--print-json", "--no-warnings" };
+ std::vector<const char*> 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 <string>
#include <json/reader.h>
@@ -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<MediaChapter> &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<MediaChapter> &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<MediaChapter> &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 <json/reader.h>
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<MediaChapter> youtube_description_extract_chapters(const std::string &description) {
+ std::vector<MediaChapter> 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<MediaChapter> &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"];