Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add chapters support to Invidious #4111

Open
wants to merge 30 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
560c689
Add logic to parse video chapters
syeopite Aug 20, 2023
2e6e384
Add chapters data to API
syeopite Aug 20, 2023
59a1b7f
Add method to convert chapters to vtt
syeopite Aug 20, 2023
437476a
Add chapters track to player.ecr
syeopite Aug 20, 2023
942cb46
Add initial html for chapters selector in desc
syeopite Aug 21, 2023
dc1c98d
Remove initial whitespace from video description
syeopite Aug 21, 2023
1903b23
Fetch chapter thumbnails for selector in desc
syeopite Aug 21, 2023
5c55c65
Remove whitespace in chapters component
syeopite Aug 21, 2023
a587fe1
Fix description chapter component design
syeopite Aug 21, 2023
397e352
Fix missing timestamp on first chapter
syeopite Aug 21, 2023
f350b86
Add data-jump-time attribute to chapters component
syeopite Aug 21, 2023
c99d06d
Add message for when chapters are auto generated
syeopite Aug 22, 2023
df7f78a
Localize chapters label
syeopite Aug 22, 2023
fec6d7e
Remove extraneous space between desc and date
syeopite Aug 22, 2023
874c373
Add field for whether or not chapter is auto gen
syeopite Aug 22, 2023
32266f5
Change order of chapters button within the player
syeopite Aug 22, 2023
978580c
Escape localization for desc chapters widget
syeopite Aug 23, 2023
51dcbcb
Properly camelcase auto gen chapters attribute
syeopite Sep 19, 2023
fe80849
Use short-hand block notation for parsing chapters
syeopite Sep 19, 2023
4c47ff7
Move parsed chapters info to "extra video infos"
syeopite Sep 19, 2023
9601de6
Add separate method for constructing chapters json
syeopite Sep 20, 2023
c7e046f
Add data for chapters to JSON endpoint for videos
syeopite Sep 20, 2023
f4b2251
Use proxy url for chapter thumbnails in JSON API
syeopite Sep 20, 2023
eb48c3a
Use WebVTT.build for chapters vtt file
syeopite Oct 21, 2023
14bc5b3
Use Time::Span for timestamps in chapter struct
syeopite Oct 21, 2023
06cd9ba
Refactor: Add object to represent chapters
syeopite Jan 14, 2024
8048a56
Code quality fixes
syeopite Jan 22, 2024
4a6a0ec
Don't use generic click handler for chapter widget
syeopite Jan 22, 2024
d6573c9
Run Crystal formatter
syeopite Apr 29, 2024
1432827
Fix Ameba warnings
syeopite Nov 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions assets/css/default.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}
4 changes: 4 additions & 0 deletions assets/css/player.css
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ ul.vjs-menu-content::-webkit-scrollbar {
order: 5;
}

.vjs-chapters-button {
order: 5;
syeopite marked this conversation as resolved.
Show resolved Hide resolved
}

.vjs-share-control {
order: 6;
}
Expand Down
1 change: 1 addition & 0 deletions assets/js/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ var options = {
'remainingTimeDisplay',
'Spacer',
'captionsButton',
'ChaptersButton',
'audioTrackButton',
'qualitySelector',
'playbackRateMenuButton',
Expand Down
6 changes: 6 additions & 0 deletions assets/js/watch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}))
4 changes: 3 additions & 1 deletion locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
8 changes: 8 additions & 0 deletions src/invidious/jsonify/api_v1/video_json.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions src/invidious/routes/api/v1/videos.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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"]?
syeopite marked this conversation as resolved.
Show resolved Hide resolved

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
8 changes: 7 additions & 1 deletion src/invidious/routes/images.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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]?
Expand Down
1 change: 1 addition & 0 deletions src/invidious/routing.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
syeopite marked this conversation as resolved.
Show resolved Hide resolved

# Feeds
get "/api/v1/trending", {{namespace}}::Feeds, :trending
Expand Down
23 changes: 22 additions & 1 deletion src/invidious/videos.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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?

Expand Down Expand Up @@ -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
Expand Down
108 changes: 108 additions & 0 deletions src/invidious/videos/chapters.cr
Original file line number Diff line number Diff line change
@@ -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
49 changes: 37 additions & 12 deletions src/invidious/videos/parser.cr
Original file line number Diff line number Diff line change
Expand Up @@ -270,14 +270,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)
Expand Down Expand Up @@ -416,6 +413,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
Expand All @@ -436,13 +459,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
Expand Down
Loading
Loading