From 503ace90f5c926308ffd39897e874247644adaee Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 13 Jan 2024 23:46:20 -0800 Subject: [PATCH] Refactor: Add object to represent chapters Prior to this commit we used an Array of Chapter structs to represent a video's chapters. However, as we often needed to apply operations on the entire sequence of chapters, multiple isolated functions had to be created and in turn clogged up the code. By grouping everything together under a chapters struct that stores a sequence of chapters, these functions can be grouped together, and can be simplifed due to instance variables containing the data that they need. Co-authored-by: Samantaz Fox --- src/invidious/jsonify/api_v1/video_json.cr | 4 +- src/invidious/routes/api/v1/videos.cr | 12 +- src/invidious/videos.cr | 20 ++- src/invidious/videos/chapters.cr | 161 ++++++++++-------- .../description_chapters_widget.ecr | 4 +- src/invidious/views/components/player.ecr | 2 +- 6 files changed, 111 insertions(+), 92 deletions(-) diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 0967403e..46c77cdd 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -201,10 +201,10 @@ module Invidious::JSONify::APIv1 end end - if !video.chapters.empty? + if !video.chapters.nil? json.field "chapters" do json.object do - Invidious::Videos::Chapters.to_json(json, video.chapters, video.automatically_generated_chapters?.as(Bool)) + video.chapters.to_json(json) end end end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index d1736900..17e9d89b 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -431,16 +431,16 @@ module Invidious::Routes::API::V1::Videos 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" - - response = Invidious::Videos::Chapters.to_json(chapters, video.automatically_generated_chapters?.as(Bool)) - - return response + return chapters.to_json else env.response.content_type = "text/vtt; charset=UTF-8" - - return Invidious::Videos::Chapters.chapters_to_vtt(chapters) + return chapters.to_vtt end end end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index cded9c6d..5850b33a 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -27,7 +27,7 @@ struct Video @captions = [] of Invidious::Videos::Captions::Metadata @[DB::Field(ignore: true)] - @chapters = [] of Invidious::Videos::Chapters::Chapter + @chapters : Invidious::Videos::Chapters? = nil @[DB::Field(ignore: true)] property adaptive_fmts : Array(Hash(String, JSON::Any))? @@ -231,17 +231,23 @@ struct Video end def chapters - if @chapters.empty? - @chapters = Invidious::Videos::Chapters.parse(@info["chapters"].as_a, self.length_seconds) + # 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 automatically_generated_chapters? : Bool? - return @info["autoGeneratedChapters"]?.try &.as_bool - 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 index 24a50628..446056d9 100644 --- a/src/invidious/videos/chapters.cr +++ b/src/invidious/videos/chapters.cr @@ -1,78 +1,90 @@ -# Namespace for methods and objects relating to chapters -module Invidious::Videos::Chapters - record Chapter, start_ms : Time::Span, end_ms : Time::Span, title : String, thumbnails : Array(Hash(String, Int32 | String)) +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 - # 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.milliseconds, - end_ms: end_ms.milliseconds, - title: title, - thumbnails: thumbnails, - ) + def initialize(@chapters : Array(Chapter), @auto_generated : Bool) end - return segments - 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_seconds : Int32, is_auto_generated : Bool = false) + video_length_milliseconds = video_length_seconds.seconds.total_milliseconds - # Converts an array of Chapter objects to a webvtt file - def self.chapters_to_vtt(chapters : Array(Chapter)) - vtt = WebVTT.build do |build| - chapters.each do |chapter| - build.cue(chapter.start_ms, chapter.end_ms, chapter.title) + 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 = [] 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 = 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 + vtt = WebVTT.build do |build| + self.each do |chapter| + build.cue(chapter.start_ms, chapter.end_ms, chapter.title) + end end end - end - def self.to_json(json : JSON::Builder, chapters : Array(Chapter), auto_generated? : Bool) - 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 + # 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"] + 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 @@ -81,14 +93,15 @@ module Invidious::Videos::Chapters end end end - end - def self.to_json(chapters : Array(Chapter), auto_generated? : Bool) - JSON.build do |json| - json.object do - json.field "chapters" do - json.object do - to_json(json, chapters, auto_generated?) + # 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 diff --git a/src/invidious/views/components/description_chapters_widget.ecr b/src/invidious/views/components/description_chapters_widget.ecr index daf37ed0..f6251f2a 100644 --- a/src/invidious/views/components/description_chapters_widget.ecr +++ b/src/invidious/views/components/description_chapters_widget.ecr @@ -1,9 +1,9 @@ -<% if !chapters.empty? %> +<% if !chapters.nil? %>

<%=HTML.escape(translate(locale, "video_chapters_label"))%>

- <% if video.automatically_generated_chapters? %> + <% if chapters.auto_generated? %>
<%=HTML.escape(translate(locale, "video_chapters_auto_generated_label"))%>
<% end %> diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index f3c5a17a..f45a265d 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -64,7 +64,7 @@ <% end %> - <% if !chapters.empty? %> + <% if !chapters.nil? %> <% end %> <% end %>