From 98c6cee383e5a2e972204bc6561613e5666a599b Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 19 Aug 2023 20:14:15 -0700 Subject: [PATCH] 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