diff --git a/assets/css/default.css b/assets/css/default.css index 2cedcf0c..816ab7e3 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -160,7 +160,9 @@ body a.pure-button { button.pure-button-primary, body a.pure-button-primary, .channel-owner:hover, -.channel-owner:focus { +.channel-owner:focus, +.chapter:hover, +.chapter:focus { background-color: #a0a0a0; color: rgba(35, 35, 35, 1); } @@ -814,5 +816,26 @@ h1, h2, h3, h4, h5, p, } #download_widget { - width: 100%; + width: 100%; +} + +.description-chapters-section { + white-space: normal; +} + +.description-chapters-content-container { + display: flex; + flex-direction: row; + gap: 5px; + overflow: scroll; + + overflow-y: hidden; +} + +.chapter { + padding: 3px; +} + +.chapter .thumbnail { + width: 200px; } diff --git a/assets/css/player.css b/assets/css/player.css index 9cb400ad..80555591 100644 --- a/assets/css/player.css +++ b/assets/css/player.css @@ -115,6 +115,10 @@ ul.vjs-menu-content::-webkit-scrollbar { order: 5; } +.vjs-chapters-button { + order: 5; +} + .vjs-share-control { order: 6; } diff --git a/assets/js/player.js b/assets/js/player.js index 353a5296..bd1990fd 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -16,6 +16,7 @@ var options = { 'remainingTimeDisplay', 'Spacer', 'captionsButton', + 'ChaptersButton', 'audioTrackButton', 'qualitySelector', 'playbackRateMenuButton', diff --git a/assets/js/watch.js b/assets/js/watch.js index 26ad138f..bae4ca38 100644 --- a/assets/js/watch.js +++ b/assets/js/watch.js @@ -191,3 +191,9 @@ addEventListener('load', function (e) { comments.innerHTML = ''; } }); + +const chapter_widget_buttons = document.getElementsByClassName("chapter-widget-buttons") +Array.from(chapter_widget_buttons).forEach(e => e.addEventListener("click", function (event) { + event.preventDefault(); + player.currentTime(e.getAttribute('data-jump-time')); +})) \ No newline at end of file diff --git a/locales/en-US.json b/locales/en-US.json index c23f6bc3..f69d5b13 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -498,5 +498,7 @@ "toggle_theme": "Toggle Theme", "carousel_slide": "Slide {{current}} of {{total}}", "carousel_skip": "Skip the Carousel", - "carousel_go_to": "Go to slide `x`" + "carousel_go_to": "Go to slide `x`", + "video_chapters_label": "Chapters", + "video_chapters_auto_generated_label": "These chapters are auto-generated" } diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 08cd533f..92dbf271 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -218,6 +218,14 @@ module Invidious::JSONify::APIv1 end end + if !video.chapters.nil? + json.field "chapters" do + json.object do + video.chapters.to_json(json) + end + end + end + if !video.music.empty? json.field "musicTracks" do json.array do diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 368304ac..d137400b 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -429,4 +429,41 @@ 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 chapters.nil? + return error_json(404, "No chapters are defined in video \"#{id}\"") + end + + if format == "json" + env.response.content_type = "application/json" + return chapters.to_json + else + env.response.content_type = "text/vtt; charset=UTF-8" + return chapters.to_vtt + end + end end diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr index 639697db..bfb40d00 100644 --- a/src/invidious/routes/images.cr +++ b/src/invidious/routes/images.cr @@ -106,6 +106,12 @@ module Invidious::Routes::Images id = env.params.url["id"] name = env.params.url["name"] + # Some thumbnails such as the ones for chapters requires some additional queries. + query_params = HTTP::Params.new + {"sqp", "rs"}.each do |attest_param| + query_params[attest_param] = env.params.query[attest_param] if env.params.query[attest_param]? + end + headers = HTTP::Headers.new if name == "maxres.jpg" @@ -118,7 +124,7 @@ module Invidious::Routes::Images end end - url = "/vi/#{id}/#{name}" + url = "/vi/#{id}/#{name}?#{query_params}" REQUEST_HEADERS_WHITELIST.each do |header| if env.request.headers[header]? diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 9009062f..924dcac6 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 ae09e736..e8b1e680 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 @@ -26,6 +26,9 @@ struct Video @[DB::Field(ignore: true)] @captions = [] of Invidious::Videos::Captions::Metadata + @[DB::Field(ignore: true)] + @chapters : Invidious::Videos::Chapters? = nil + @[DB::Field(ignore: true)] property description : String? @@ -143,6 +146,24 @@ struct Video return @captions end + def chapters + # As the chapters key is always present in @info we need to check that it is + # actually populated + if @chapters.nil? + chapters = @info["chapters"].as_a + return nil if chapters.empty? + + @chapters = Invidious::Videos::Chapters.from_raw_chapters( + chapters, + self.length_seconds, + # Should never be nil but just in case + is_auto_generated: @info["autoGeneratedChapters"].as_bool? || false + ) + 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..b2c75ed3 --- /dev/null +++ b/src/invidious/videos/chapters.cr @@ -0,0 +1,108 @@ +module Invidious::Videos + # A `Chapters` struct represents an sequence of chapters for a given video + struct Chapters + record Chapter, start_ms : Time::Span, end_ms : Time::Span, title : String, thumbnails : Array(Hash(String, Int32 | String)) + property? auto_generated : Bool + + def initialize(@chapters : Array(Chapter), @auto_generated : Bool) + end + + # Constructs a chapters object from InnerTube's JSON object for chapters + # + # Requires the length of the video the chapters are associated to in order to construct correct ending time + def Chapters.from_raw_chapters(raw_chapters : Array(JSON::Any), video_length : Int32, is_auto_generated : Bool = false) + video_length_milliseconds = video_length.seconds.total_milliseconds + + parsed_chapters = [] of Chapter + + raw_chapters.each_with_index do |chapter, index| + chapter = chapter["chapterRenderer"] + + title = chapter["title"]["simpleText"].as_s + + raw_thumbnails = chapter["thumbnail"]["thumbnails"].as_a + thumbnails = raw_thumbnails.map do |thumbnail| + { + "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 = raw_chapters[index + 1]? + end_ms = next_chapter["chapterRenderer"]["timeRangeStartMillis"].as_i + else + end_ms = video_length_milliseconds.to_i + end + + parsed_chapters << Chapter.new( + start_ms: start_ms.milliseconds, + end_ms: end_ms.milliseconds, + title: title, + thumbnails: thumbnails, + ) + end + + return Chapters.new(parsed_chapters, is_auto_generated) + end + + # Calls the given block for each chapter and passes it as a parameter + def each(&) + @chapters.each { |c| yield c } + end + + # Converts the sequence of chapters to a WebVTT representation + def to_vtt + return WebVTT.build do |build| + self.each do |chapter| + build.cue(chapter.start_ms, chapter.end_ms, chapter.title) + end + end + end + + # Dumps a JSON representation of the sequence of chapters to the given JSON::Builder + def to_json(json : JSON::Builder) + json.field "autoGenerated", @auto_generated.to_s + 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.total_milliseconds + json.field "endMs", chapter.end_ms.total_milliseconds + + json.field "thumbnails" do + json.array do + chapter.thumbnails.each do |thumbnail| + json.object do + json.field "url", URI.parse(thumbnail["url"].as(String)).request_target + json.field "width", thumbnail["width"] + json.field "height", thumbnail["height"] + end + end + end + end + end + end + end + end + end + + # Create a JSON representation of the sequence of chapters + def to_json + JSON.build do |json| + json.object do + json.field "chapters" do + json.object do + to_json(json) + end + end + end + end + end + end +end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 915c9baf..25314b41 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -260,14 +260,11 @@ 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", - "endScreen", "watchNextEndScreenRenderer", "results" - ) - player_overlays.try &.as_a.each do |element| if item = element["endScreenVideoRenderer"]? related_video = parse_related_video(item) @@ -406,6 +403,32 @@ 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 + chapters_auto_generated = nil + + # 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(&.["key"]?.try &.== "DESCRIPTION_CHAPTERS") + + # Chapters that are manually created should have a higher precedence than automatically generated chapters + if !potential_chapters_array + potential_chapters_array = markers.as_a.find(&.["key"]?.try &.== "AUTO_CHAPTERS") + end + + if potential_chapters_array + if potential_chapters_array["key"] == "AUTO_CHAPTERS" + chapters_auto_generated = true + else + chapters_auto_generated = false + end + + chapters_array = potential_chapters_array["value"]["chapters"].as_a + end + end + end + # Return data if live_now @@ -426,13 +449,15 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any "lengthSeconds" => JSON::Any.new(length_txt || 0_i64), "published" => JSON::Any.new(published.to_rfc3339), # Extra video infos - "allowedRegions" => JSON::Any.new(allowed_regions.map { |v| JSON::Any.new(v) }), - "allowRatings" => JSON::Any.new(allow_ratings || false), - "isFamilyFriendly" => JSON::Any.new(family_friendly || false), - "isListed" => JSON::Any.new(is_listed || false), - "isUpcoming" => JSON::Any.new(is_upcoming || false), - "keywords" => JSON::Any.new(keywords.map { |v| JSON::Any.new(v) }), - "isPostLiveDvr" => JSON::Any.new(post_live_dvr), + "allowedRegions" => JSON::Any.new(allowed_regions.map { |v| JSON::Any.new(v) }), + "allowRatings" => JSON::Any.new(allow_ratings || false), + "isFamilyFriendly" => JSON::Any.new(family_friendly || false), + "isListed" => JSON::Any.new(is_listed || false), + "isUpcoming" => JSON::Any.new(is_upcoming || false), + "keywords" => JSON::Any.new(keywords.map { |v| JSON::Any.new(v) }), + "isPostLiveDvr" => JSON::Any.new(post_live_dvr), + "autoGeneratedChapters" => JSON::Any.new(chapters_auto_generated), + "chapters" => JSON::Any.new(chapters_array), # Related videos "relatedVideos" => JSON::Any.new(related), # Description 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..a3bc30cb --- /dev/null +++ b/src/invidious/views/components/description_chapters_widget.ecr @@ -0,0 +1,34 @@ +<% if chapters = video.chapters %> +
<%-= recode_length_seconds(start_in_seconds) -%>
+ <%- else -%> +0:00
+ <%- end -%> +<%-=chapter.title-%>
+