From 371dbd73fe8fa234c31f8e3827778a6606532bee Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 19 Aug 2023 19:31:11 -0700 Subject: [PATCH 01/29] Add logic to parse video chapters --- src/invidious/videos.cr | 2 +- src/invidious/videos/parser.cr | 21 +++++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index c218b4ef..540ce6b2 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -15,7 +15,7 @@ struct Video # NOTE: don't forget to bump this number if any change is made to # the `params` structure in videos/parser.cr!!! # - SCHEMA_VERSION = 2 + SCHEMA_VERSION = 3 property id : String diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 0e1a947c..c7c9eeb7 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -243,11 +243,12 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any end end + player_overlays = player_response.dig?("playerOverlays", "playerOverlayRenderer") + # If nothing was found previously, fall back to end screen renderer if related.empty? # Container for "endScreenVideoRenderer" items - player_overlays = player_response.dig?( - "playerOverlays", "playerOverlayRenderer", + end_screen_watch_next_array = player_overlays.try &.dig?( "endScreen", "watchNextEndScreenRenderer", "results" ) @@ -389,6 +390,20 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any .try &.as_s.split(" ", 2)[0] end + # Chapters + chapters_array = [] of JSON::Any + + # Yes, `decoratedPlayerBarRenderer` is repeated twice. + if player_bar = player_overlays.try &.dig?("decoratedPlayerBarRenderer", "decoratedPlayerBarRenderer", "playerBar") + if markers = player_bar.dig?("multiMarkersPlayerBarRenderer", "markersMap") + potential_chapters_array = markers.as_a.find { |m| m["key"] == "DESCRIPTION_CHAPTERS" } + + if potential_chapters_array + chapters_array = potential_chapters_array.as_a + end + end + end + # Return data if live_now @@ -434,6 +449,8 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any "authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""), "authorVerified" => JSON::Any.new(author_verified || false), "subCountText" => JSON::Any.new(subs_text || "-"), + + "chapters" => JSON::Any.new(chapters_array), } return params From 98c6cee383e5a2e972204bc6561613e5666a599b Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 19 Aug 2023 20:14:15 -0700 Subject: [PATCH 02/29] Add chapters data to API --- src/invidious/routes/api/v1/videos.cr | 59 +++++++++++++++++++++++++++ src/invidious/routing.cr | 1 + src/invidious/videos.cr | 11 +++++ src/invidious/videos/chapters.cr | 49 ++++++++++++++++++++++ src/invidious/videos/parser.cr | 4 +- 5 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 src/invidious/videos/chapters.cr diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 9281f4dd..3665eda5 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -406,4 +406,63 @@ module Invidious::Routes::API::V1::Videos end end end + + def self.chapters(env) + id = env.params.url["id"] + region = env.params.query["region"]? || env.params.body["region"]? + + if id.nil? || id.size != 11 || !id.matches?(/^[\w-]+$/) + return error_json(400, "Invalid video ID") + end + + format = env.params.query["format"]? + + begin + video = get_video(id, region: region) + rescue ex : NotFoundException + haltf env, 404 + rescue ex + haltf env, 500 + end + + begin + chapters = video.chapters + rescue ex + haltf env, 500 + end + + if format == "json" + env.response.content_type = "application/json" + + response = JSON.build do |json| + json.object do + json.field "chapters" do + json.array do + chapters.each do |chapter| + json.object do + json.field "title", chapter.title + json.field "startMs", chapter.start_ms + json.field "endMs", chapter.end_ms + + json.field "thumbnails" do + json.array do + chapter.thumbnails.each do |thumbnail| + json.object do + json.field "url", thumbnail["url"] + json.field "width", thumbnail["width"] + json.field "height", thumbnail["height"] + end + end + end + end + end + end + end + end + end + end + + return response + end + end end diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index ba05da19..849ac483 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -236,6 +236,7 @@ module Invidious::Routing get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations get "/api/v1/comments/:id", {{namespace}}::Videos, :comments get "/api/v1/clips/:id", {{namespace}}::Videos, :clips + get "/api/v1/chapters/:id", {{namespace}}::Videos, :chapters # Feeds get "/api/v1/trending", {{namespace}}::Feeds, :trending diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 540ce6b2..1224ffff 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -26,6 +26,9 @@ struct Video @[DB::Field(ignore: true)] @captions = [] of Invidious::Videos::Captions::Metadata + @[DB::Field(ignore: true)] + @chapters = [] of Invidious::Videos::Chapters::Chapter + @[DB::Field(ignore: true)] property adaptive_fmts : Array(Hash(String, JSON::Any))? @@ -227,6 +230,14 @@ struct Video return @captions end + def chapters + if @chapters.empty? + @chapters = Invidious::Videos::Chapters.parse(@info["chapters"].as_a, self.length_seconds) + end + + return @chapters + end + def hls_manifest_url : String? info.dig?("streamingData", "hlsManifestUrl").try &.as_s end diff --git a/src/invidious/videos/chapters.cr b/src/invidious/videos/chapters.cr new file mode 100644 index 00000000..8f5a3c24 --- /dev/null +++ b/src/invidious/videos/chapters.cr @@ -0,0 +1,49 @@ +# Namespace for methods and objects relating to chapters +module Invidious::Videos::Chapters + record Chapter, start_ms : Int32, end_ms : Int32, title : String, thumbnails : Array(Hash(String, Int32 | String)) + + # Parse raw chapters data into an array of Chapter structs + # + # Requires the length of the video the chapters are associated to in order to construct correct ending time + def self.parse(chapters : Array(JSON::Any), video_length_seconds : Int32) + video_length_milliseconds = video_length_seconds.seconds.total_milliseconds + + segments = [] of Chapter + + chapters.each_with_index do |chapter, index| + chapter = chapter["chapterRenderer"] + + title = chapter["title"]["simpleText"].as_s + + raw_thumbnails = chapter["thumbnail"]["thumbnails"].as_a + thumbnails = [] of Hash(String, Int32 | String) + + raw_thumbnails.each do |thumbnail| + thumbnails << { + "url" => thumbnail["url"].as_s, + "width" => thumbnail["width"].as_i, + "height" => thumbnail["height"].as_i, + } + end + + start_ms = chapter["timeRangeStartMillis"].as_i + + # To get the ending range we have to peek at the next chapter. + # If we're the last chapter then we need to calculate the end time through the video length. + if next_chapter = chapters[index + 1]? + end_ms = next_chapter["chapterRenderer"]["timeRangeStartMillis"].as_i + else + end_ms = video_length_milliseconds.to_i + end + + segments << Chapter.new( + start_ms: start_ms, + end_ms: end_ms, + title: title, + thumbnails: thumbnails, + ) + end + + return segments + end +end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index c7c9eeb7..709ca644 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -396,10 +396,10 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any # Yes, `decoratedPlayerBarRenderer` is repeated twice. if player_bar = player_overlays.try &.dig?("decoratedPlayerBarRenderer", "decoratedPlayerBarRenderer", "playerBar") if markers = player_bar.dig?("multiMarkersPlayerBarRenderer", "markersMap") - potential_chapters_array = markers.as_a.find { |m| m["key"] == "DESCRIPTION_CHAPTERS" } + potential_chapters_array = markers.as_a.find { |m| m["key"]? == "DESCRIPTION_CHAPTERS" } if potential_chapters_array - chapters_array = potential_chapters_array.as_a + chapters_array = potential_chapters_array["value"]["chapters"].as_a end end end From 39b0229835670b406667469c59d7be818089d197 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 19 Aug 2023 21:37:36 -0700 Subject: [PATCH 03/29] Add method to convert chapters to vtt --- src/invidious/routes/api/v1/videos.cr | 4 +++ src/invidious/videos/chapters.cr | 37 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 3665eda5..5b35d39f 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -463,6 +463,10 @@ module Invidious::Routes::API::V1::Videos end return response + else + env.response.content_type = "text/vtt; charset=UTF-8" + + return Invidious::Videos::Chapters.chapters_to_vtt(chapters) end end end diff --git a/src/invidious/videos/chapters.cr b/src/invidious/videos/chapters.cr index 8f5a3c24..f8c31648 100644 --- a/src/invidious/videos/chapters.cr +++ b/src/invidious/videos/chapters.cr @@ -46,4 +46,41 @@ module Invidious::Videos::Chapters return segments end + + # Converts an array of Chapter objects to a webvtt file + def self.chapters_to_vtt(chapters : Array(Chapter)) + vtt = String.build do |vtt| + vtt << <<-END_VTT + WEBVTT + + + END_VTT + + # Taken from Invidious::Videos::Caption.timedtext_to_vtt() + chapters.each do |chapter| + start_time = chapter.start_ms.milliseconds + end_time = chapter.end_ms.milliseconds + + # start_time + vtt << start_time.hours.to_s.rjust(2, '0') + vtt << ':' << start_time.minutes.to_s.rjust(2, '0') + vtt << ':' << start_time.seconds.to_s.rjust(2, '0') + vtt << '.' << start_time.milliseconds.to_s.rjust(3, '0') + + vtt << " --> " + + # end_time + vtt << end_time.hours.to_s.rjust(2, '0') + vtt << ':' << end_time.minutes.to_s.rjust(2, '0') + vtt << ':' << end_time.seconds.to_s.rjust(2, '0') + vtt << '.' << end_time.milliseconds.to_s.rjust(3, '0') + + vtt << "\n" + vtt << chapter.title + + vtt << "\n" + vtt << "\n" + end + end + end end From d9aeb2c3608e9f3a229642d4b1dbf01babef3264 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 19 Aug 2023 21:52:43 -0700 Subject: [PATCH 04/29] Add chapters track to player.ecr --- assets/js/player.js | 1 + src/invidious/routes/embed.cr | 2 ++ src/invidious/routes/watch.cr | 2 ++ src/invidious/views/components/player.ecr | 4 ++++ 4 files changed, 9 insertions(+) diff --git a/assets/js/player.js b/assets/js/player.js index 71c5e7da..db426b42 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -17,6 +17,7 @@ var options = { 'remainingTimeDisplay', 'Spacer', 'captionsButton', + 'ChaptersButton', 'audioTrackButton', 'qualitySelector', 'playbackRateMenuButton', diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 266f7ba4..46e31a98 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -191,6 +191,8 @@ module Invidious::Routes::Embed thumbnail = "/vi/#{video.id}/maxres.jpg" + chapters = video.chapters + if params.raw url = fmt_stream[0]["url"].as_s diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index aabe8dfc..144f24e0 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -158,6 +158,8 @@ module Invidious::Routes::Watch thumbnail = "/vi/#{video.id}/maxres.jpg" + chapters = video.chapters + if params.raw if params.listen url = audio_streams[0]["url"].as_s diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index c3c02df0..f3c5a17a 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -63,6 +63,10 @@ <% captions.each do |caption| %> <% end %> + + <% if !chapters.empty? %> + + <% end %> <% end %> From 0afd95fb78daafe8b72b4b2cabba039a56f0133c Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 20 Aug 2023 23:49:49 -0700 Subject: [PATCH 05/29] Add initial html for chapters selector in desc --- assets/css/default.css | 4 +++ .../description_chapters_widget.ecr | 27 +++++++++++++++++++ src/invidious/views/watch.ecr | 10 +++++-- 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 src/invidious/views/components/description_chapters_widget.ecr diff --git a/assets/css/default.css b/assets/css/default.css index a47762ec..90a65241 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -799,3 +799,7 @@ h1, h2, h3, h4, h5, p, #download_widget { width: 100%; } +.description-chapters-content-container { + display: flex; + flex-direction: row; +} diff --git a/src/invidious/views/components/description_chapters_widget.ecr b/src/invidious/views/components/description_chapters_widget.ecr new file mode 100644 index 00000000..e5ecdb99 --- /dev/null +++ b/src/invidious/views/components/description_chapters_widget.ecr @@ -0,0 +1,27 @@ +<% if !chapters.empty? %> +
+ +
+<% end %> \ No newline at end of file diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 7a1cf2c3..5fab5b8c 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -254,10 +254,16 @@ we're going to need to do it here in order to allow for translations.
<% if video.description.size < 200 || params.extend_desc %> -
<%= video.description_html %>
+
+ <%= video.description_html %> + <%= rendered "components/description_chapters_widget" %> +
<% else %> -
<%= video.description_html %>
+
+ <%= video.description_html %> + <%= rendered "components/description_chapters_widget" %> +
From 48ba6373dff9871476d4297ec645193db4fb7748 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 20 Aug 2023 23:57:55 -0700 Subject: [PATCH 06/29] Remove initial whitespace from video description --- src/invidious/views/watch.ecr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 5fab5b8c..cb5dcc75 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -255,13 +255,13 @@ we're going to need to do it here in order to allow for translations.
<% if video.description.size < 200 || params.extend_desc %>
- <%= video.description_html %> + <%-= video.description_html %> <%= rendered "components/description_chapters_widget" %>
<% else %>
- <%= video.description_html %> + <%-= video.description_html %> <%= rendered "components/description_chapters_widget" %>