Compare commits

...

33 Commits

Author SHA1 Message Date
syeopite
e488de4a4c
Merge dfd94f4f2e into c5fdd9ea65 2024-08-25 03:19:56 +00:00
Samantaz Fox
c5fdd9ea65
HTML: Sort playlists alphabetically in watch page drop down (#4853)
Closes issue 4708
2024-08-24 20:50:46 +02:00
Samantaz Fox
2876ee0f9f
HTML: Fix XSS vulnerability in description/comments (#4852)
Before this PR, the comment/description content was not HTML escaped when 'parse_description()'
was called with a JSON object lacking the "commandRuns" entry.

Closes issue 4727
2024-08-24 20:50:05 +02:00
Samantaz Fox
0699e5fc27
YtAPI: Bump client versions (#4849)
This might help reducing the amount of playback errors.

No related issue
2024-08-24 20:47:01 +02:00
Samantaz Fox
15669acccf
SigHelper: Fix inverted time comparison in 'check_update' (#4845)
Closes issue 4840
2024-08-24 20:44:52 +02:00
Samantaz Fox
cd2daf4adb
Storyboards: Various fixes and code cleaning (#4153)
Closes issue 3441
2024-08-24 20:43:05 +02:00
syeopite
ccecc6d318
Fix lint errors introduced in #4146 and #4295 (#4876)
* Ameba: Fix Naming/VariableNames

Introduced in #4295

* Ameba: Fix Naming/PredicateName

Introduced in #4146
2024-08-24 18:11:11 +00:00
Samantaz Fox
21ab5dc668
Storyboard: Revert cue timing "fix" 2024-08-22 00:29:15 +02:00
Samantaz Fox
764965c441
Storyboards: Fix lint error 2024-08-17 12:20:53 +02:00
Samantaz Fox
b795bdf2a4
HTML: Sort playlists alphabetically in watch page drop down 2024-08-16 12:10:22 +02:00
Samantaz Fox
5b05f3bd14
Storyboards: Workarounds for videojs-vtt-thumbnails
The workarounds are as follow:
  * Unescape HTML entities
  * Always use 0:00:00.000 for cue start/end
2024-08-16 11:36:01 +02:00
Samantaz Fox
a335bc0814
Storyboards: Fix some small logic mistakes 2024-08-16 10:05:49 +02:00
Samantaz Fox
7b50388eaf
Storyboards: Fix broken first storyboard 2024-08-16 10:05:48 +02:00
Samantaz Fox
da3d58f03c
Storyboards: Cleanup and document code 2024-08-16 10:05:47 +02:00
Samantaz Fox
8327862697
Storyboards: Use replace the NamedTuple by a struct 2024-08-16 10:04:40 +02:00
Samantaz Fox
6878822c4d
Storyboards: Move parser to its own file 2024-08-16 10:02:52 +02:00
Samantaz Fox
0b28054f8a
videos: Fix XSS vulnerability in description/comments
Patch provided by e-mail, thanks to an anonymous user whose cats are named
Yoshi and Yasuo.

Comment is mine
2024-08-15 18:26:17 +02:00
Samantaz Fox
cc33d3f074
YtAPI: Also update User-Agent string 2024-08-15 18:14:29 +02:00
Samantaz Fox
acbb625866
YtAPI: Update clients to latest version 2024-08-15 12:57:36 +02:00
Samantaz Fox
466bfbb306
SigHelper: Fix inverted time comparison in 'check_update' 2024-08-14 21:43:37 +02:00
syeopite
dfd94f4f2e
Improve error message for checksum failure 2024-02-19 15:16:33 -08:00
syeopite
34ae6f4aa2
Move hook for videojs-contrib-quality-levels to config file 2024-02-19 15:05:03 -08:00
syeopite
8f129b85ad
Use cache directory relative to Invidious repo
https://github.com/iv-org/invidious/pull/2397#discussion_r753732453
2024-02-19 15:01:27 -08:00
syeopite
55b92112e3
Add additional documentation 2024-02-19 14:58:20 -08:00
syeopite
e33c407855
VideoJS Dep. Manager: allow custom cache directory 2024-02-19 14:30:33 -08:00
syeopite
ee5a396a0a
VideoJS Dep. Manager: Allow custom registry url 2024-02-19 14:24:39 -08:00
syeopite
71806ae18b
VideoJS Dependency Manager: Refactor configuration 2024-02-19 14:18:56 -08:00
syeopite
5d0055361f
Use cached tarball when available 2024-02-19 12:15:46 -08:00
syeopite
facf727e66
Copy dependency file instead of moving them
This allows for using the native crystal File.copy function
and the caching of downloaded tarballs.
2024-02-19 11:44:22 -08:00
syeopite
37340f227d
Use native crystal sha1 digest
Crystal now supports OpenSSL 3

Reverts 4e629ca858
2024-02-18 16:56:26 -08:00
syeopite
3195fd06f2
Rewrite fetch logic for player dependencies
- Refactor into dedicated class
- Simplify workarounds for dependencies with differing structure
2024-02-18 16:52:00 -08:00
syeopite
9c0cb2a513
Add check for missing dependency files 2024-02-18 16:41:10 -08:00
syeopite
392e447969
VideoJS dep manager: Add ability to skip checksum 2024-02-18 13:41:20 -08:00
17 changed files with 541 additions and 282 deletions

1
.gitignore vendored
View File

@ -7,3 +7,4 @@
/invidious
/sentry
/config/config.yml
/invidious-videojs-dep-install

View File

@ -1,14 +1,253 @@
require "http"
require "yaml"
require "file_utils"
require "digest/sha1"
require "option_parser"
require "colorize"
# Represents an "install_instruction" section specified per dependency in `videojs-dependencies.yml`
#
# This is used to modify the download logic for dependencies that are packaged differently.
struct InstallInstruction
include YAML::Serializable
property js_path : String? = nil
property css_path : String? = nil
property download_as : String? = nil
property no_styling : Bool = false
end
# Object representing a dependency specified within `videojs-dependencies.yml`
class ConfigDependency
include YAML::Serializable
property version : String
property shasum : String
property install_instructions : InstallInstruction? = nil
# Checks if the current dependency needs to be installed/updated
def fetch?(name : String)
path = "assets/videojs/#{name}"
# Check for missing dependency files
#
# Does the directory exist?
# Does the Javascript file exist?
# Does the CSS file exist?
if !Dir.exists?(path)
Dir.mkdir(path)
return true
elsif !(File.exists?("#{path}/#{name}.js") || File.exists?("#{path}/versions.yml"))
return true
elsif !(self.install_instructions.try &.no_styling) && !File.exists?("#{path}/#{name}.css")
return true
end
# Check if we need to update the dependency
versions = File.open("#{path}/versions.yml") do |file|
YAML.parse(file).as_h
end
if versions["version"].as_s != self.version || versions["minified"].as_bool != CONFIG.minified
# Clear directory
{"*.js", "*.css"}.each do |file_types|
Dir.glob("#{path}/#{file_types}").each do |file_path|
File.delete(file_path)
end
end
return true
end
return false
end
end
# Object representing the `videojs-dependencies.yml` file
class PlayerDependenciesConfig
include YAML::Serializable
property version : String
property registry_url : String
property cache_directory : String
property dependencies : Hash(YAML::Any, ConfigDependency)
def get_dependencies_to_fetch
return self.dependencies.select { |name, config| config.fetch?(name.as_s) }
end
end
# Runtime Dependency config for easy access to all the variables
class Config
property minified : Bool
property skip_checksum : Bool
property clear_cache : Bool
property dependency_config : PlayerDependenciesConfig
def initialize(path : String)
@minified = false
@skip_checksum = false
@clear_cache = false
@dependency_config = PlayerDependenciesConfig.from_yaml(File.read(path))
end
# Less verbose way to access @dependency_config.registry_url
def registry_url
return @dependency_config.registry_url
end
# Less verbose way to access @dependency_config.cache_directory
def cache_directory
return @dependency_config.cache_directory
end
end
# Object representing a player dependency
class Dependency
@config : ConfigDependency
def initialize(@config : ConfigDependency, @dependency : String)
@download_path = "#{CONFIG.cache_directory}/#{@dependency}"
@destination_path = "assets/videojs/#{@dependency}"
end
private def validate_checksum(io)
return if CONFIG.skip_checksum
digest = Digest::SHA1.hexdigest(io)
if digest != @config.shasum
raise IO::Error.new("Checksum for '#{@dependency}' failed. \"#{digest}\" does not match configured \"#{@config.shasum}\"")
end
end
# Requests and downloads a specific dependency from NPM
#
# Validates a cached tarball if it already exists.
private def request_dependency
downloaded_package_path = "#{@download_path}/package.tgz"
# Create a download directory for the dependency if it does not already exist
if Dir.exists?(@download_path)
# Validate checksum of existing cached tarball
# Fetches a new one when the checksum fails.
if File.exists?(downloaded_package_path)
begin
return self.validate_checksum(File.open(downloaded_package_path))
rescue IO::Error
end
end
else
Dir.mkdir(@download_path)
end
HTTP::Client.get("#{CONFIG.registry_url}/#{@dependency}/-/#{@dependency}-#{@config.version}.tgz") do |response|
data = response.body_io.gets_to_end
File.write(downloaded_package_path, data)
self.validate_checksum(data)
end
end
# Moves a VideoJS dependency file of the given extension from extracted tarball to Invidious directory
private def move_file(full_target_path, extension)
minified_target_path = sprintf(full_target_path, {"file_extension": ".min.#{extension}"})
if CONFIG.minified && File.exists?(minified_target_path)
target_path = minified_target_path
else
target_path = sprintf(full_target_path, {"file_extension": ".#{extension}"})
end
if download_as = @config.install_instructions.try &.download_as
destination_path = "#{@destination_path}/#{sprintf(download_as, {"file_extension": ".#{extension}"})}"
else
destination_path = @destination_path
end
FileUtils.cp(target_path, destination_path)
end
# Fetch path of where a VideoJS dependency is located in the extracted tarball
private def fetch_path(is_css)
if is_css
raw_target_path = @config.install_instructions.try &.css_path
else
raw_target_path = @config.install_instructions.try &.js_path
end
if raw_target_path
return "#{@download_path}/package/#{raw_target_path}"
else
return "#{@download_path}/package/dist/#{@dependency}%{file_extension}"
end
end
# Wrapper around `#move_file` to move the dependency's JS file
private def move_js_file
return self.move_file(self.fetch_path(is_css: false), "js")
end
# Wrapper around `#move_file` to move the dependency's CSS file
#
# Does nothing with the CSS file does not exist.
private def move_css_file
path = self.fetch_path(is_css: true)
if File.exists?(sprintf(path, {"file_extension": ".css"}))
return move_file(path, "css")
end
end
# Updates the dependency's versions.yml with the current fetched version and its minified status
private def update_versions_yaml
File.open("#{@destination_path}/versions.yml", "w") do |io|
YAML.build(io) do |builder|
builder.mapping do
# Versions
builder.scalar "version"
builder.scalar "#{@config.version}"
builder.scalar "minified"
builder.scalar CONFIG.minified
end
end
end
end
# Installs a VideoJS dependency into Invidious
def install
self.request_dependency
# Crystal's stdlib provides no way of extracting a tarball
`tar -vzxf '#{@download_path}/package.tgz' -C '#{@download_path}'`
raise "Extraction for #{@dependency} failed" if !$?.success?
self.move_js_file
self.move_css_file
self.update_versions_yaml
end
end
CONFIG = Config.new("videojs-dependencies.yml")
# Hacky solution to get separated arguments when called from invidious.cr
if ARGV.size == 1
parser_args = [] of String
ARGV[0].split(",") { |str| parser_args << str.strip }
else
parser_args = ARGV
end
# Taken from https://crystal-lang.org/api/1.1.1/OptionParser.html
minified = false
OptionParser.parse do |parser|
OptionParser.parse(parser_args) do |parser|
parser.banner = "Usage: Fetch VideoJS dependencies [arguments]"
parser.on("-m", "--minified", "Use minified versions of VideoJS dependencies (performance and bandwidth benefit)") { minified = true }
parser.on("-m", "--minified", "Use minified versions of VideoJS dependencies (performance and bandwidth benefit)") { CONFIG.minified = true }
parser.on("--skip-checksum", "Skips the checksum validation of downloaded files") { CONFIG.skip_checksum = true }
parser.on("--clear-cache", "Clears the cache and re-downloads all dependency files") { CONFIG.clear_cache = true }
parser.on("-h", "--help", "Show this help") do
puts parser
@ -22,124 +261,17 @@ OptionParser.parse do |parser|
end
end
required_dependencies = File.open("videojs-dependencies.yml") do |file|
YAML.parse(file).as_h
end
def update_versions_yaml(required_dependencies, minified, dep_name)
File.open("assets/videojs/#{dep_name}/versions.yml", "w") do |io|
YAML.build(io) do |builder|
builder.mapping do
# Versions
builder.scalar "version"
builder.scalar "#{required_dependencies[dep_name]["version"]}"
builder.scalar "minified"
builder.scalar minified
end
end
end
end
# The first step is to check which dependencies we'll need to install.
# If the version we have requested in `videojs-dependencies.yml` is the
# same as what we've installed, we shouldn't do anything. Likewise, if it's
# different or the requested dependency just isn't present, then it needs to be
# installed.
# Since we can't know when videojs-youtube-annotations is updated, we'll just always fetch
# a new copy each time.
dependencies_to_install = [] of String
required_dependencies.keys.each do |dep|
dep = dep.to_s
path = "assets/videojs/#{dep}"
# Check for missing dependencies
if !Dir.exists?(path)
Dir.mkdir(path)
dependencies_to_install << dep
else
config = File.open("#{path}/versions.yml") do |file|
YAML.parse(file).as_h
end
if config["version"].as_s != required_dependencies[dep]["version"].as_s || config["minified"].as_bool != minified
`rm -rf #{path}/*.js #{path}/*.css`
dependencies_to_install << dep
end
end
end
# Now we begin the fun part of installing the dependencies.
# But first we'll setup a temp directory to store the plugins
tmp_dir_path = "#{Dir.tempdir}/invidious-videojs-dep-install"
Dir.mkdir(tmp_dir_path) if !Dir.exists? tmp_dir_path
# Create cache directory
Dir.mkdir(CONFIG.cache_directory) if !Dir.exists? CONFIG.cache_directory
dependencies_to_install = CONFIG.dependency_config.get_dependencies_to_fetch
channel = Channel(String | Exception).new
dependencies_to_install.each do |dep|
dependencies_to_install.each do |dep_name, dependency_config|
spawn do
dep_name = dep
download_path = "#{tmp_dir_path}/#{dep}"
dest_path = "assets/videojs/#{dep}"
HTTP::Client.get("https://registry.npmjs.org/#{dep}/-/#{dep}-#{required_dependencies[dep]["version"]}.tgz") do |response|
Dir.mkdir(download_path)
data = response.body_io.gets_to_end
File.write("#{download_path}/package.tgz", data)
# https://github.com/iv-org/invidious/pull/2397#issuecomment-922375908
if `sha1sum #{download_path}/package.tgz`.split(" ")[0] != required_dependencies[dep]["shasum"]
raise Exception.new("Checksum for '#{dep}' failed")
end
end
# Unless we install an external dependency, crystal provides no way of extracting a tarball.
# Thus we'll go ahead and call a system command.
`tar -vzxf '#{download_path}/package.tgz' -C '#{download_path}'`
raise "Extraction for #{dep} failed" if !$?.success?
# Would use File.rename in the following steps but for some reason it just doesn't work here.
# Video.js itself is structured slightly differently
dep = "video" if dep == "video.js"
# This dep nests everything under an additional JS or CSS folder
if dep == "silvermine-videojs-quality-selector"
js_path = "js/"
# It also stores their quality selector as `quality-selector.css`
`mv #{download_path}/package/dist/css/quality-selector.css #{dest_path}/quality-selector.css`
else
js_path = ""
end
# Would use File.rename but for some reason it just doesn't work here.
if minified && File.exists?("#{download_path}/package/dist/#{js_path}#{dep}.min.js")
`mv #{download_path}/package/dist/#{js_path}#{dep}.min.js #{dest_path}/#{dep}.js`
else
`mv #{download_path}/package/dist/#{js_path}#{dep}.js #{dest_path}/#{dep}.js`
end
# Fetch CSS which isn't guaranteed to exist
#
# Also, video JS changes structure here once again...
dep = "video-js" if dep == "video"
# VideoJS marker uses a dot on the CSS files.
dep = "videojs.markers" if dep == "videojs-markers"
if File.exists?("#{download_path}/package/dist/#{dep}.css")
if minified && File.exists?("#{download_path}/package/dist/#{dep}.min.css")
`mv #{download_path}/package/dist/#{dep}.min.css #{dest_path}/#{dep}.css`
else
`mv #{download_path}/package/dist/#{dep}.css #{dest_path}/#{dep}.css`
end
end
# Update/create versions file for the dependency
update_versions_yaml(required_dependencies, minified, dep_name)
channel.send(dep_name)
dependency = Dependency.new(dependency_config, dep_name.as_s)
dependency.install
channel.send(dep_name.as_s)
rescue ex
channel.send(ex)
end
@ -161,4 +293,6 @@ else
end
# Cleanup
`rm -rf #{tmp_dir_path}`
if CONFIG.clear_cache
FileUtils.rm_r("#{CONFIG.cache_directory}")
end

View File

@ -144,12 +144,26 @@ Invidious::Database.check_integrity(CONFIG)
# Running the script by itself would show some colorful feedback while this doesn't.
# Perhaps we should just move the script to runtime in order to get that feedback?
{% puts "\nChecking player dependencies, this may take more than 20 minutes... If it is stuck, check your internet connection.\n" %}
{% if flag?(:minified_player_dependencies) %}
{% puts run("../scripts/fetch-player-dependencies.cr", "--minified").stringify %}
{% else %}
{% puts run("../scripts/fetch-player-dependencies.cr").stringify %}
{% fetch_script_arguments = [] of StringLiteral %}
{%
potential_arguments = {
{:minified_player_dependencies, "--minified"},
{:skip_player_dependencies_checksum, "--skip-checksum"},
{:clear_player_dependencies_cache, "--clear-cache"},
}
%}
{% for potential_argument in potential_arguments %}
{% flag_, script_argument = potential_argument %}
{% if flag?(flag_) %}
{% fetch_script_arguments << script_argument.id %}
{% end %}
{% end %}
{% puts "\nChecking player dependencies, this may take more than 20 minutes... If it is stuck, check your internet connection.\n" %}
{% puts run("../scripts/fetch-player-dependencies.cr", fetch_script_arguments.splat).stringify %}
{% puts "\nDone checking player dependencies, now compiling Invidious...\n" %}
{% end %}

View File

@ -50,12 +50,12 @@ def get_about_info(ucid, locale) : AboutChannel
total_views = 0_i64
joined = Time.unix(0)
if ageGate = initdata.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs", 0, "tabRenderer", "content", "sectionListRenderer", "contents", 0, "channelAgeGateRenderer")
if age_gate_renderer = initdata.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs", 0, "tabRenderer", "content", "sectionListRenderer", "contents", 0, "channelAgeGateRenderer")
description_node = nil
author = ageGate["channelTitle"].as_s
author = age_gate_renderer["channelTitle"].as_s
ucid = initdata.dig("responseContext", "serviceTrackingParams", 0, "params", 0, "value").as_s
author_url = "https://www.youtube.com/channel/#{ucid}"
author_thumbnail = ageGate.dig("avatar", "thumbnails", 0, "url").as_s
author_thumbnail = age_gate_renderer.dig("avatar", "thumbnails", 0, "url").as_s
banner = nil
is_family_friendly = false
is_age_gated = true

View File

@ -140,6 +140,7 @@ module Invidious::Database::Playlists
request = <<-SQL
SELECT id,title FROM playlists
WHERE author = $1 AND id LIKE 'IV%'
ORDER BY title
SQL
PG_DB.query_all(request, email, as: {String, String})

View File

@ -10,10 +10,8 @@ class Invidious::DecryptFunction
end
def check_update
now = Time.utc
# If we have updated in the last 5 minutes, do nothing
return if (now - @last_update) > 5.minutes
return if (Time.utc - @last_update) < 5.minutes
# Get the amount of time elapsed since when the player was updated, in the
# event where multiple invidious processes are run in parallel.

View File

@ -277,17 +277,17 @@ module Invidious::JSONify::APIv1
def storyboards(json, id, storyboards)
json.array do
storyboards.each do |storyboard|
storyboards.each do |sb|
json.object do
json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}"
json.field "templateUrl", storyboard[:url]
json.field "width", storyboard[:width]
json.field "height", storyboard[:height]
json.field "count", storyboard[:count]
json.field "interval", storyboard[:interval]
json.field "storyboardWidth", storyboard[:storyboard_width]
json.field "storyboardHeight", storyboard[:storyboard_height]
json.field "storyboardCount", storyboard[:storyboard_count]
json.field "url", "/api/v1/storyboards/#{id}?width=#{sb.width}&height=#{sb.height}"
json.field "templateUrl", sb.url.to_s
json.field "width", sb.width
json.field "height", sb.height
json.field "count", sb.count
json.field "interval", sb.interval
json.field "storyboardWidth", sb.columns
json.field "storyboardHeight", sb.rows
json.field "storyboardCount", sb.images_count
end
end
end

View File

@ -1,3 +1,5 @@
require "html"
module Invidious::Routes::API::V1::Videos
def self.videos(env)
locale = env.get("preferences").as(Preferences).locale
@ -187,15 +189,14 @@ module Invidious::Routes::API::V1::Videos
haltf env, 500
end
storyboards = video.storyboards
width = env.params.query["width"]?
height = env.params.query["height"]?
width = env.params.query["width"]?.try &.to_i
height = env.params.query["height"]?.try &.to_i
if !width && !height
response = JSON.build do |json|
json.object do
json.field "storyboards" do
Invidious::JSONify::APIv1.storyboards(json, id, storyboards)
Invidious::JSONify::APIv1.storyboards(json, id, video.storyboards)
end
end
end
@ -205,35 +206,48 @@ module Invidious::Routes::API::V1::Videos
env.response.content_type = "text/vtt"
storyboard = storyboards.select { |sb| width == "#{sb[:width]}" || height == "#{sb[:height]}" }
# Select a storyboard matching the user's provided width/height
storyboard = video.storyboards.select { |x| x.width == width || x.height == height }
haltf env, 404 if storyboard.empty?
if storyboard.empty?
haltf env, 404
else
storyboard = storyboard[0]
end
# Alias variable, to make the code below esaier to read
sb = storyboard[0]
WebVTT.build do |vtt|
start_time = 0.milliseconds
end_time = storyboard[:interval].milliseconds
# Some base URL segments that we'll use to craft the final URLs
work_url = sb.proxied_url.dup
template_path = sb.proxied_url.path
storyboard[:storyboard_count].times do |i|
url = storyboard[:url]
authority = /(i\d?).ytimg.com/.match!(url)[1]?
url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "")
url = "#{HOST_URL}/sb/#{authority}/#{url}"
# Initialize cue timing variables
# NOTE: videojs-vtt-thumbnails gets lost when the cue times don't overlap
# (i.e: if cue[n] end time is 1:06:25.000, cue[n+1] start time should be 1:06:25.000)
time_delta = sb.interval.milliseconds
start_time = 0.milliseconds
end_time = time_delta
storyboard[:storyboard_height].times do |j|
storyboard[:storyboard_width].times do |k|
current_cue_url = "#{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]}"
vtt.cue(start_time, end_time, current_cue_url)
# Build a VTT file for VideoJS-vtt plugin
vtt_file = WebVTT.build do |vtt|
sb.images_count.times do |i|
# Replace the variable component part of the path
work_url.path = template_path.sub("$M", i)
start_time += storyboard[:interval].milliseconds
end_time += storyboard[:interval].milliseconds
sb.rows.times do |j|
sb.columns.times do |k|
# The URL fragment represents the offset of the thumbnail inside the storyboard image
work_url.fragment = "xywh=#{sb.width * k},#{sb.height * j},#{sb.width - 2},#{sb.height}"
vtt.cue(start_time, end_time, work_url.to_s)
start_time += time_delta
end_time += time_delta
end
end
end
end
# videojs-vtt-thumbnails is not compliant to the VTT specification, it
# doesn't unescape the HTML entities, so we have to do it here:
# TODO: remove this when we migrate to VideoJS 8
return HTML.unescape(vtt_file)
end
def self.annotations(env)

View File

@ -53,7 +53,7 @@ module Invidious::Routes::Search
# An URL was copy/pasted in the search box.
# Redirect the user to the appropriate page.
if query.is_url?
if query.url?
return env.redirect UrlSanitizer.process(query.text).to_s
end

View File

@ -149,7 +149,7 @@ module Invidious::Search
end
# Checks if the query is a standalone URL
def is_url? : Bool
def url? : Bool
# If the smart features have been inhibited, don't go further.
return false if @inhibit_ssf

View File

@ -177,65 +177,8 @@ struct Video
# Misc. methods
def storyboards
storyboards = info.dig?("storyboards", "playerStoryboardSpecRenderer", "spec")
.try &.as_s.split("|")
if !storyboards
if storyboard = info.dig?("storyboards", "playerLiveStoryboardSpecRenderer", "spec").try &.as_s
return [{
url: storyboard.split("#")[0],
width: 106,
height: 60,
count: -1,
interval: 5000,
storyboard_width: 3,
storyboard_height: 3,
storyboard_count: -1,
}]
end
end
items = [] of NamedTuple(
url: String,
width: Int32,
height: Int32,
count: Int32,
interval: Int32,
storyboard_width: Int32,
storyboard_height: Int32,
storyboard_count: Int32)
return items if !storyboards
url = URI.parse(storyboards.shift)
params = HTTP::Params.parse(url.query || "")
storyboards.each_with_index do |sb, i|
width, height, count, storyboard_width, storyboard_height, interval, _, sigh = sb.split("#")
params["sigh"] = sigh
url.query = params.to_s
width = width.to_i
height = height.to_i
count = count.to_i
interval = interval.to_i
storyboard_width = storyboard_width.to_i
storyboard_height = storyboard_height.to_i
storyboard_count = (count / (storyboard_width * storyboard_height)).ceil.to_i
items << {
url: url.to_s.sub("$L", i).sub("$N", "M$M"),
width: width,
height: height,
count: count,
interval: interval,
storyboard_width: storyboard_width,
storyboard_height: storyboard_height,
storyboard_count: storyboard_count,
}
end
items
container = info.dig?("storyboards") || JSON::Any.new("{}")
return IV::Videos::Storyboard.from_yt_json(container, self.length_seconds)
end
def paid

View File

@ -36,7 +36,13 @@ def parse_description(desc, video_id : String) : String?
return "" if content.empty?
commands = desc["commandRuns"]?.try &.as_a
return content if commands.nil?
if commands.nil?
# Slightly faster than HTML.escape, as we're only doing one pass on
# the string instead of five for the standard library
return String.build do |str|
copy_string(str, content.each_codepoint, content.size)
end
end
# Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
# (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are

View File

@ -0,0 +1,122 @@
require "uri"
require "http/params"
module Invidious::Videos
struct Storyboard
# Template URL
getter url : URI
getter proxied_url : URI
# Thumbnail parameters
getter width : Int32
getter height : Int32
getter count : Int32
getter interval : Int32
# Image (storyboard) parameters
getter rows : Int32
getter columns : Int32
getter images_count : Int32
def initialize(
*, @url, @width, @height, @count, @interval,
@rows, @columns, @images_count
)
authority = /(i\d?).ytimg.com/.match!(@url.host.not_nil!)[1]?
@proxied_url = URI.parse(HOST_URL)
@proxied_url.path = "/sb/#{authority}/#{@url.path.lchop("/sb/")}"
@proxied_url.query = @url.query
end
# Parse the JSON structure from Youtube
def self.from_yt_json(container : JSON::Any, length_seconds : Int32) : Array(Storyboard)
# Livestream storyboards are a bit different
# TODO: document exactly how
if storyboard = container.dig?("playerLiveStoryboardSpecRenderer", "spec").try &.as_s
return [Storyboard.new(
url: URI.parse(storyboard.split("#")[0]),
width: 106,
height: 60,
count: -1,
interval: 5000,
rows: 3,
columns: 3,
images_count: -1
)]
end
# Split the storyboard string into chunks
#
# General format (whitespaces added for legibility):
# https://i.ytimg.com/sb/<video_id>/storyboard3_L$L/$N.jpg?sqp=<sig0>
# | 48 # 27 # 100 # 10 # 10 # 0 # default # rs$<sig1>
# | 80 # 45 # 95 # 10 # 10 # 10000 # M$M # rs$<sig2>
# | 160 # 90 # 95 # 5 # 5 # 10000 # M$M # rs$<sig3>
#
storyboards = container.dig?("playerStoryboardSpecRenderer", "spec")
.try &.as_s.split("|")
return [] of Storyboard if !storyboards
# The base URL is the first chunk
base_url = URI.parse(storyboards.shift)
return storyboards.map_with_index do |sb, i|
# Separate the different storyboard parameters:
# width/height: respective dimensions, in pixels, of a single thumbnail
# count: how many thumbnails are displayed across the full video
# columns/rows: maximum amount of thumbnails that can be stuffed in a
# single image, horizontally and vertically.
# interval: interval between two thumbnails, in milliseconds
# name: storyboard filename. Usually "M$M" or "default"
# sigh: URL cryptographic signature
width, height, count, columns, rows, interval, name, sigh = sb.split("#")
width = width.to_i
height = height.to_i
count = count.to_i
interval = interval.to_i
columns = columns.to_i
rows = rows.to_i
# Copy base URL object, so that we can modify it
url = base_url.dup
# Add the signature to the URL
params = url.query_params
params["sigh"] = sigh
url.query_params = params
# Replace the template parts with what we have
url.path = url.path.sub("$L", i).sub("$N", name)
# This value represents the maximum amount of thumbnails that can fit
# in a single image. The last image (or the only one for short videos)
# will contain less thumbnails than that.
thumbnails_per_image = columns * rows
# This value represents the total amount of storyboards required to
# hold all of the thumbnails. It can't be less than 1.
images_count = (count / thumbnails_per_image).ceil.to_i
# Compute the interval when needed (in general, that's only required
# for the first "default" storyboard).
if interval == 0
interval = ((length_seconds / count) * 1_000).to_i
end
Storyboard.new(
url: url,
width: width,
height: height,
count: count,
interval: interval,
rows: rows,
columns: columns,
images_count: images_count,
)
end
end
end
end

View File

@ -1,12 +1,12 @@
<link rel="stylesheet" href="/videojs/video.js/video-js.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/videojs/video.js/video.js.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/videojs/videojs-http-source-selector/videojs-http-source-selector.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/videojs/videojs-markers/videojs.markers.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/videojs/videojs-markers/videojs-markers.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/videojs/videojs-share/videojs-share.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/videojs/videojs-vtt-thumbnails/videojs-vtt-thumbnails.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/videojs/videojs-mobile-ui/videojs-mobile-ui.css?v=<%= ASSET_COMMIT %>">
<link rel="stylesheet" href="/css/player.css?v=<%= ASSET_COMMIT %>">
<script src="/videojs/video.js/video.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/videojs/video.js/video.js.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/videojs/videojs-mobile-ui/videojs-mobile-ui.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/videojs/videojs-contrib-quality-levels/videojs-contrib-quality-levels.js?v=<%= ASSET_COMMIT %>"></script>
<script src="/videojs/videojs-http-source-selector/videojs-http-source-selector.js?v=<%= ASSET_COMMIT %>"></script>

View File

@ -1,6 +1,6 @@
def add_yt_headers(request)
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"

View File

@ -6,10 +6,10 @@ module YoutubeAPI
extend self
# For Android versions, see https://en.wikipedia.org/wiki/Android_version_history
private ANDROID_APP_VERSION = "19.14.42"
private ANDROID_USER_AGENT = "com.google.android.youtube/19.14.42 (Linux; U; Android 12; US) gzip"
private ANDROID_SDK_VERSION = 31_i64
private ANDROID_APP_VERSION = "19.32.34"
private ANDROID_VERSION = "12"
private ANDROID_USER_AGENT = "com.google.android.youtube/#{ANDROID_APP_VERSION} (Linux; U; Android #{ANDROID_VERSION}; US) gzip"
private ANDROID_SDK_VERSION = 31_i64
private ANDROID_TS_APP_VERSION = "1.9"
private ANDROID_TS_USER_AGENT = "com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip"
@ -17,9 +17,9 @@ module YoutubeAPI
# For Apple device names, see https://gist.github.com/adamawolf/3048717
# For iOS versions, see https://en.wikipedia.org/wiki/IOS_version_history#Releases,
# then go to the dedicated article of the major version you want.
private IOS_APP_VERSION = "19.16.3"
private IOS_USER_AGENT = "com.google.ios.youtube/19.16.3 (iPhone14,5; U; CPU iOS 17_4 like Mac OS X;)"
private IOS_VERSION = "17.4.0.21E219" # Major.Minor.Patch.Build
private IOS_APP_VERSION = "19.32.8"
private IOS_USER_AGENT = "com.google.ios.youtube/#{IOS_APP_VERSION} (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)"
private IOS_VERSION = "17.6.1.21G93" # Major.Minor.Patch.Build
private WINDOWS_VERSION = "10.0"
@ -48,7 +48,7 @@ module YoutubeAPI
ClientType::Web => {
name: "WEB",
name_proto: "1",
version: "2.20240304.00.00",
version: "2.20240814.00.00",
screen: "WATCH_FULL_SCREEN",
os_name: "Windows",
os_version: WINDOWS_VERSION,
@ -57,7 +57,7 @@ module YoutubeAPI
ClientType::WebEmbeddedPlayer => {
name: "WEB_EMBEDDED_PLAYER",
name_proto: "56",
version: "1.20240303.00.00",
version: "1.20240812.01.00",
screen: "EMBED",
os_name: "Windows",
os_version: WINDOWS_VERSION,
@ -66,7 +66,7 @@ module YoutubeAPI
ClientType::WebMobile => {
name: "MWEB",
name_proto: "2",
version: "2.20240304.08.00",
version: "2.20240813.02.00",
os_name: "Android",
os_version: ANDROID_VERSION,
platform: "MOBILE",
@ -74,7 +74,7 @@ module YoutubeAPI
ClientType::WebScreenEmbed => {
name: "WEB",
name_proto: "1",
version: "2.20240304.00.00",
version: "2.20240814.00.00",
screen: "EMBED",
os_name: "Windows",
os_version: WINDOWS_VERSION,
@ -147,8 +147,8 @@ module YoutubeAPI
ClientType::IOSMusic => {
name: "IOS_MUSIC",
name_proto: "26",
version: "6.42",
user_agent: "com.google.ios.youtubemusic/6.42 (iPhone14,5; U; CPU iOS 17_4 like Mac OS X;)",
version: "7.14",
user_agent: "com.google.ios.youtubemusic/7.14 (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)",
device_make: "Apple",
device_model: "iPhone14,5",
os_name: "iPhone",
@ -161,7 +161,7 @@ module YoutubeAPI
ClientType::TvHtml5 => {
name: "TVHTML5",
name_proto: "7",
version: "7.20240304.10.00",
version: "7.20240813.07.00",
},
ClientType::TvHtml5ScreenEmbed => {
name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",

View File

@ -1,54 +1,80 @@
# Due to a 'video append of' error (see #3011), we're stuck on 7.12.1.
video.js:
version: 7.12.1
shasum: 1d12eeb1f52e3679e8e4c987d9b9eb37e2247fa2
version: 1
registry_url: "https://registry.npmjs.org"
videojs-contrib-quality-levels:
version: 2.1.0
shasum: 046e9e21ed01043f512b83a1916001d552457083
# Dependencies are stored as <dependency_name>/package.tgz
cache_directory: "./invidious-videojs-dep-install"
videojs-http-source-selector:
version: 1.1.6
shasum: 073aadbea0106ba6c98d6b611094dbf8554ffa1f
dependencies:
#Due to a 'video append of' error (see #3011), we're stuck on 7.12.1.
video.js:
version: 7.12.1
shasum: 1d12eeb1f52e3679e8e4c987d9b9eb37e2247fa2
videojs-markers:
version: 1.0.1
shasum: d7f8d804253fd587813271f8db308a22b9f7df34
install_instructions:
js_path: "dist/video%{file_extension}"
css_path: "dist/video-js%{file_extension}"
videojs-mobile-ui:
version: 0.6.1
shasum: 0e146c4c481cbee0729cb5e162e558b455562cd0
# Normalize names to simplify File.exists? check
download_as: "video.js%{file_extension}"
videojs-overlay:
version: 2.1.4
shasum: 5a103b25374dbb753eb87960d8360c2e8f39cc05
videojs-contrib-quality-levels:
version: 2.1.0
shasum: 046e9e21ed01043f512b83a1916001d552457083
videojs-share:
version: 3.2.1
shasum: 0a3024b981387b9d21c058c829760a72c14b8ceb
install_instructions:
no_styling: true
videojs-vr:
version: 1.8.0
shasum: 7f2f07f760d8a329c615acd316e49da6ee8edd34
videojs-http-source-selector:
version: 1.1.6
shasum: 073aadbea0106ba6c98d6b611094dbf8554ffa1f
videojs-vtt-thumbnails:
version: 0.0.13
shasum: d1e7d47f4ed80bb52f5fc4f4bad4bfc871f5970f
videojs-markers:
version: 1.0.1
shasum: d7f8d804253fd587813271f8db308a22b9f7df34
# We're using iv-org's fork of videojs-quality-selector,
# which isn't published on NPM, and doesn't have any
# easy way of fetching the compiled variant.
#
# silvermine-videojs-quality-selector:
# version: 1.1.2
# shasum: 94033ff9ee52ba6da1263b97c9a74d5b3dfdf711
install_instructions:
css_path: "dist/videojs.markers%{file_extension}"
download_as: "videojs-markers%{file_extension}"
videojs-mobile-ui:
version: 0.6.1
shasum: 0e146c4c481cbee0729cb5e162e558b455562cd0
videojs-overlay:
version: 2.1.4
shasum: 5a103b25374dbb753eb87960d8360c2e8f39cc05
videojs-share:
version: 3.2.1
shasum: 0a3024b981387b9d21c058c829760a72c14b8ceb
videojs-vr:
version: 1.8.0
shasum: 7f2f07f760d8a329c615acd316e49da6ee8edd34
videojs-vtt-thumbnails:
version: 0.0.13
shasum: d1e7d47f4ed80bb52f5fc4f4bad4bfc871f5970f
# We're using iv-org's fork of videojs-quality-selector,
# which isn't published on NPM, and doesn't have any
# easy way of fetching the compiled variant.
# silvermine-videojs-quality-selector:
# version: 1.1.2
# shasum: 94033ff9ee52ba6da1263b97c9a74d5b3dfdf711
# install_instructions:
# js_path: "dist/js/silvermine-videojs-quality-selector%{file_extension}"
# css_path: "dist/css/quality-selector%{file_extension}"
# download_as: silvermine-videojs-quality-selector%{file_extension}
# Ditto. Although this extension contains the complied variant in its git repo,
# it lacks any sort of versioning. As such, the script will ignore it.
#
# videojs-youtube-annotations:
# github: https://github.com/afrmtbl/videojs-youtube-annotations
# Ditto. Although this extension contains the complied variant in its git repo,
# it lacks any sort of versioning. As such, the script will ignore it.
#
# videojs-youtube-annotations:
# github: https://github.com/afrmtbl/videojs-youtube-annotations