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 invidious companion support #4985

Open
wants to merge 26 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3dff7a7
add support for invidious companion
unixfox Oct 20, 2024
73c84ba
redirect latest_version and dash manifest to invidious companion
unixfox Oct 20, 2024
1954463
fix Shadowing outer local variable `response`
unixfox Oct 20, 2024
c612423
fixing condition for Content-Security-Policy
unixfox Oct 20, 2024
2cc204a
throw error if inv_sig_helper and invidious_companion used same time
unixfox Nov 1, 2024
1c9f5b0
Use sample instead of Random.rand
unixfox Nov 5, 2024
27b24f5
Remove debug puts functions
unixfox Nov 5, 2024
409df4c
modify the description for config.example.yaml about invidious companion
unixfox Nov 5, 2024
ff3305d
move config checks for invidious companion
unixfox Nov 8, 2024
1aa154b
separate invidious_companion logic + better config.yaml config
unixfox Nov 16, 2024
9f84612
fixing "end" misplacement
unixfox Nov 16, 2024
b51770d
fix linting + use .empty?
unixfox Nov 16, 2024
bb2e3b2
crystal handle decompression already by itself
unixfox Nov 17, 2024
734e725
fix download function when invidious companion used
unixfox Nov 17, 2024
1f51edd
fix linting
unixfox Nov 18, 2024
7a070fa
invidious companion always used so always add CSP and redirect latest…
unixfox Nov 18, 2024
f710dd3
apply all the suggestions + rework invidious_companion parameter
unixfox Dec 8, 2024
a571eea
format watch.cr
unixfox Dec 8, 2024
ab72bba
fix ameba Redundant use of `Object#to_s` in interpolation
unixfox Dec 8, 2024
1de2054
add ability for invidious companion to check request from invidious
unixfox Dec 13, 2024
0dba767
Better document private_url and public_url
unixfox Dec 24, 2024
e9c354d
Better doc for invidious_companion_key
unixfox Dec 24, 2024
f550359
!empty? to present?
unixfox Dec 30, 2024
bfaf72b
skip proxy for invidious companion
unixfox Dec 30, 2024
84b87be
fixing format
unixfox Dec 30, 2024
a5acdde
missing ,
unixfox Dec 30, 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
38 changes: 38 additions & 0 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,44 @@ db:
##
#signature_server:

##
## Invidious companion is an external program
## for loading the video streams from YouTube servers.
##
## When this setting is commented out, Invidious companion is not used.
## Otherwise, Invidious will proxy the requests to Invidious companion.
##
## Note: multiple URL can be configured. In this case, invidious will
## randomly pick one every time video data needs to be retrieved. This
## URL is then kept in the video metadata cache to allow video playback
## to work. Once said cache has expired, requesting that video's data
## again will cause a new companion URL to be picked.
##
## The parameter private_url needs to be configured for the internal
## communication between the companion and Invidious.
## And public_url is the public URL from which companion is listening
## to the requests from the user(s).
## Both parameter can have identical URL when Invidious is hosted in
## an internal network or at home.
##
## Accepted values: "http(s)://<IP-HOSTNAME>:<Port>"
## Default: <none>
##
#invidious_companion:
# - private_url: "http://localhost:8282"
# public_url: "http://localhost:8282"

##
## API key for Invidious companion
## The size of the key needs to be more or equal to 16.
##
## Needed when invidious_companion is configured
##
## Accepted values: "http(s)://<IP-HOSTNAME>:<Port>"
## Default: <none>
##

#invidious_companion_key: "CHANGE_ME!!"

#########################################
#
Expand Down
34 changes: 34 additions & 0 deletions src/invidious/config.cr
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ end
class Config
include YAML::Serializable

class CompanionConfig
include YAML::Serializable

@[YAML::Field(converter: Preferences::URIConverter)]
property private_url : URI = URI.parse("")

@[YAML::Field(converter: Preferences::URIConverter)]
property public_url : URI = URI.parse("")
end

# Number of threads to use for crawling videos from channels (for updating subscriptions)
property channel_threads : Int32 = 1
# Time interval between two executions of the job that crawls channel videos (subscriptions update).
Expand Down Expand Up @@ -151,6 +161,12 @@ class Config
# poToken for passing bot attestation
property po_token : String? = nil

# Invidious companion
property invidious_companion : Array(CompanionConfig) = [] of CompanionConfig

# Invidious companion API key
property invidious_companion_key : String = ""

# Saved cookies in "name1=value1; name2=value2..." format
@[YAML::Field(converter: Preferences::StringToCookies)]
property cookies : HTTP::Cookies = HTTP::Cookies.new
Expand Down Expand Up @@ -222,6 +238,24 @@ class Config
end
{% end %}

if !config.invidious_companion.empty?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codestlye nitpick

What do you think about replacing !empty? with present? instead or unless {...}.empty?

I've seen a couple discussions in the Crystal community regarding if !empty? being harder to process cognitively due to an essentially double negation.

Related discussions:
https://forum.crystal-lang.org/t/collections-any-vs-empty/5303
crystal-lang/crystal#13847
crystal-lang/shards#577 (comment)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's perfect for me. That was already odd for me to do !empty?, happy there is an alternative.

# invidious_companion and signature_server can't work together
if config.signature_server
puts "Config: You can not run inv_sig_helper and invidious_companion at the same time."
exit(1)
end
if config.invidious_companion_key.empty?
unixfox marked this conversation as resolved.
Show resolved Hide resolved
puts "Config: Please configure a key if you are using invidious companion."
exit(1)
elsif config.invidious_companion_key == "CHANGE_ME!!"
puts "Config: The value of 'invidious_companion_key' needs to be changed!!"
exit(1)
elsif config.invidious_companion_key.size < 16
puts "Config: The value of 'invidious_companion_key' needs to be a size of 16 or more."
exit(1)
end
end

# HMAC_key is mandatory
# See: https://github.com/iv-org/invidious/issues/3854
if config.hmac_key.empty?
Expand Down
19 changes: 19 additions & 0 deletions src/invidious/helpers/utils.cr
Original file line number Diff line number Diff line change
Expand Up @@ -383,3 +383,22 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String)
end
return text
end

def encrypt_ecb_without_salt(data, key)
cipher = OpenSSL::Cipher.new("aes-128-ecb")
cipher.encrypt
cipher.key = key

io = IO::Memory.new
io.write(cipher.update(data))
io.write(cipher.final)
io.rewind

return io
end

def invidious_companion_encrypt(data)
timestamp = Time.utc.to_unix
encrypted_data = encrypt_ecb_without_salt("#{timestamp}|#{data}", CONFIG.invidious_companion_key)
return Base64.urlsafe_encode(encrypted_data)
end
5 changes: 5 additions & 0 deletions src/invidious/routes/api/manifest.cr
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ module Invidious::Routes::API::Manifest
id = env.params.url["id"]
region = env.params.query["region"]?

if !CONFIG.invidious_companion.empty?
invidious_companion = CONFIG.invidious_companion.sample
return env.redirect "#{invidious_companion.public_url}/api/manifest/dash/id/#{id}?#{env.params.query}"
end

# Since some implementations create playlists based on resolution regardless of different codecs,
# we can opt to only add a source to a representation if it has a unique height within that representation
unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe }
Expand Down
7 changes: 7 additions & 0 deletions src/invidious/routes/embed.cr
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,13 @@ module Invidious::Routes::Embed
return env.redirect url
end

if companion_base_url = video.invidious_companion.try &.["baseUrl"].as_s
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src #{companion_base_url}")
.gsub("connect-src", "connect-src #{companion_base_url}")
end
unixfox marked this conversation as resolved.
Show resolved Hide resolved
Comment on lines +204 to +209
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you move these to the before_all handler instead maybe under a:

if {"/embed", "/watch"}.any? { |r| env.request.resource.starts_with? r }
      env.response.headers["Content-Security-Policy"] =
        env.response.headers["Content-Security-Policy"]
          .gsub("media-src", "media-src #{companion_base_url}")
          .gsub("connect-src", "connect-src #{companion_base_url}")
end


rendered "embed"
end
end
5 changes: 5 additions & 0 deletions src/invidious/routes/video_playback.cr
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,11 @@ module Invidious::Routes::VideoPlayback
# YouTube /videoplayback links expire after 6 hours,
# so we have a mechanism here to redirect to the latest version
def self.latest_version(env)
if !CONFIG.invidious_companion.empty?
invidious_companion = CONFIG.invidious_companion.sample
return env.redirect "#{invidious_companion.public_url}/latest_version?#{env.params.query}"
end

id = env.params.query["id"]?
itag = env.params.query["itag"]?.try &.to_i?

Expand Down
17 changes: 14 additions & 3 deletions src/invidious/routes/watch.cr
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,13 @@ module Invidious::Routes::Watch
captions: video.captions
)

if companion_base_url = video.invidious_companion.try &.["baseUrl"].as_s
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src #{companion_base_url}")
.gsub("connect-src", "connect-src #{companion_base_url}")
end

templated "watch"
end

Expand Down Expand Up @@ -320,14 +327,18 @@ module Invidious::Routes::Watch
env.params.query["label"] = URI.decode_www_form(label.as_s)

return Invidious::Routes::API::V1::Videos.captions(env)
elsif itag = download_widget["itag"]?.try &.as_i
elsif itag = download_widget["itag"]?.try &.as_i.to_s
# URL params specific to /latest_version
env.params.query["id"] = video_id
env.params.query["itag"] = itag.to_s
env.params.query["title"] = filename
env.params.query["local"] = "true"

return Invidious::Routes::VideoPlayback.latest_version(env)
if (!CONFIG.invidious_companion.empty?)
video = get_video(video_id)
return env.redirect "#{video.invidious_companion["baseUrl"].as_s}/latest_version?#{env.params.query}"
else
return Invidious::Routes::VideoPlayback.latest_version(env)
end
else
return error_template(400, "Invalid label or itag")
end
Expand Down
6 changes: 5 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 Down Expand Up @@ -192,6 +192,10 @@ struct Video
}
end

def invidious_companion : Hash(String, JSON::Any)?
info["invidiousCompanion"]?.try &.as_h || {} of String => JSON::Any
end

# Macros defining getters/setters for various types of data

private macro getset_string(name)
Expand Down
42 changes: 22 additions & 20 deletions src/invidious/videos/parser.cr
Original file line number Diff line number Diff line change
Expand Up @@ -100,30 +100,32 @@ def extract_video_info(video_id : String)
params = parse_video_info(video_id, player_response)
params["reason"] = JSON::Any.new(reason) if reason

new_player_response = nil

# Don't use Android test suite client if po_token is passed because po_token doesn't
# work for Android test suite client.
if reason.nil? && CONFIG.po_token.nil?
# Fetch the video streams using an Android client in order to get the
# decrypted URLs and maybe fix throttling issues (#2194). See the
# following issue for an explanation about decrypted URLs:
# https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
client_config.client_type = YoutubeAPI::ClientType::AndroidTestSuite
new_player_response = try_fetch_streaming_data(video_id, client_config)
end
if !CONFIG.invidious_companion.empty?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if !CONFIG.invidious_companion.empty?
if CONFIG.invidious_companion.empty?

The Invidious stream data workarounds should run when invidious companion is not set

new_player_response = nil

# Don't use Android test suite client if po_token is passed because po_token doesn't
# work for Android test suite client.
if reason.nil? && CONFIG.po_token.nil?
# Fetch the video streams using an Android client in order to get the
# decrypted URLs and maybe fix throttling issues (#2194). See the
# following issue for an explanation about decrypted URLs:
# https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
client_config.client_type = YoutubeAPI::ClientType::AndroidTestSuite
new_player_response = try_fetch_streaming_data(video_id, client_config)
end

# Replace player response and reset reason
if !new_player_response.nil?
# Preserve captions & storyboard data before replacement
new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]?
new_player_response["captions"] = player_response["captions"] if player_response["captions"]?
# Replace player response and reset reason
if !new_player_response.nil?
# Preserve captions & storyboard data before replacement
new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]?
new_player_response["captions"] = player_response["captions"] if player_response["captions"]?

player_response = new_player_response
params.delete("reason")
player_response = new_player_response
params.delete("reason")
end
end

{"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f|
{"captions", "playabilityStatus", "playerConfig", "storyboards", "invidiousCompanion"}.each do |f|
params[f] = player_response[f] if player_response[f]?
end

Expand Down
12 changes: 10 additions & 2 deletions src/invidious/views/components/player.ecr
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
audio_streams.each_with_index do |fmt, i|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
src_url += "&local=true" if params.local
src_url = video.invidious_companion["baseUrl"].as_s + src_url +
"&check=#{invidious_companion_encrypt(video.id)}" if (!CONFIG.invidious_companion.empty?)

bitrate = fmt["bitrate"]
mimetype = HTML.escape(fmt["mimeType"].as_s)
Expand All @@ -34,8 +36,12 @@
<% end %>
<% end %>
<% else %>
<% if params.quality == "dash" %>
<source src="/api/manifest/dash/id/<%= video.id %>?local=true&unique_res=1" type='application/dash+xml' label="dash">
<% if params.quality == "dash"
src_url = "/api/manifest/dash/id/" + video.id + "?local=true&unique_res=1"
src_url = video.invidious_companion["baseUrl"].as_s + src_url +
"&check=#{invidious_companion_encrypt(video.id)}" if (!CONFIG.invidious_companion.empty?)
%>
<source src="<%= src_url %>" type='application/dash+xml' label="dash">
<% end %>

<%
Expand All @@ -44,6 +50,8 @@
fmt_stream.each_with_index do |fmt, i|
src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}"
src_url += "&local=true" if params.local
src_url = video.invidious_companion["baseUrl"].as_s + src_url +
"&check=#{invidious_companion_encrypt(video.id)}" if (!CONFIG.invidious_companion.empty?)

quality = fmt["quality"]
mimetype = HTML.escape(fmt["mimeType"].as_s)
Expand Down
51 changes: 50 additions & 1 deletion src/invidious/yt_backend/youtube_api.cr
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,11 @@ module YoutubeAPI
data["params"] = params
end

return self._post_json("/youtubei/v1/player", data, client_config)
if !CONFIG.invidious_companion.empty?
return self._post_invidious_companion("/youtubei/v1/player", data)
unixfox marked this conversation as resolved.
Show resolved Hide resolved
else
return self._post_json("/youtubei/v1/player", data, client_config)
end
end

####################################################################
Expand Down Expand Up @@ -666,6 +670,51 @@ module YoutubeAPI
return initial_data
end

####################################################################
# _post_invidious_companion(endpoint, data)
#
# Internal function that does the actual request to Invidious companion
# and handles errors.
#
# The requested data is an endpoint (URL without the domain part)
# and the data as a Hash object.
#
def _post_invidious_companion(
endpoint : String,
data : Hash
) : Hash(String, JSON::Any)
headers = HTTP::Headers{
"Content-Type" => "application/json; charset=UTF-8",
"Authorization" => "Bearer #{CONFIG.invidious_companion_key}",
}

# Logging
LOGGER.debug("Invidious companion: Using endpoint: \"#{endpoint}\"")
LOGGER.trace("Invidious companion: POST data: #{data}")

# Send the POST request

begin
invidious_companion = CONFIG.invidious_companion.sample
response = make_client(invidious_companion.private_url,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since Invidious is expected to constantly make requests to the Invidious companion shouldn't it use a connection pool?

It shouldn't be too difficult to modify the connection pool's factory method to randomly select a companion URL for each client it creates.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you suggest how to do that? I'm not very familiar with this stuff.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't tested it but something like this should do the trick:

Patch
diff --git a/src/invidious.cr b/src/invidious.cr
index b422dcbb..c0c78a79 100644
--- a/src/invidious.cr
+++ b/src/invidious.cr
@@ -97,6 +97,10 @@ YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size)
 
 GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size)
 
+COMPANION_POOL = CompanionConnectionPool.new(
+  capacity: CONFIG.pool_size
+)
+
 # CLI
 Kemal.config.extra_options do |parser|
   parser.banner = "Usage: invidious [arguments]"
diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr
index c4a73aa7..6f1ef9bd 100644
--- a/src/invidious/yt_backend/connection_pool.cr
+++ b/src/invidious/yt_backend/connection_pool.cr
@@ -46,6 +46,45 @@ struct YoutubeConnectionPool
   end
 end
 
+struct CompanionConnectionPool
+  property pool : DB::Pool(HTTP::Client)
+
+  def initialize(capacity = 5, timeout = 5.0)
+    options = DB::Pool::Options.new(
+      initial_pool_size: 0,
+      max_pool_size: capacity,
+      max_idle_pool_size: capacity,
+      checkout_timeout: timeout
+    )
+
+    @pool = DB::Pool(HTTP::Client).new(options) do
+      companion = CONFIG.invidious_companion.sample
+      next make_client(companion.private_url, force_resolve: true)
+    end
+  end
+
+  def client(&)
+    conn = pool.checkout
+    # Proxy needs to be reinstated every time we get a client from the pool
+    conn.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy
+
+    begin
+      response = yield conn
+    rescue ex
+      conn.close
+
+      companion = CONFIG.invidious_companion.sample
+      conn = make_client(companion.private_url, force_resolve: true)
+
+      response = yield conn
+    ensure
+      pool.release(conn)
+    end
+
+    response
+  end
+end
+
 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/128.0.0.0 Safari/537.36"
diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr
index 74f65449..9bc6fe05 100644
--- a/src/invidious/yt_backend/youtube_api.cr
+++ b/src/invidious/yt_backend/youtube_api.cr
@@ -695,9 +695,7 @@ module YoutubeAPI
     # Send the POST request
 
     begin
-      invidious_companion = CONFIG.invidious_companion.sample
-      response = make_client(invidious_companion.private_url,
-        &.post(endpoint, headers: headers, body: data.to_json))
+      response = COMPANION_POOL.client &.post(endpoint, headers: headers, body: data.to_json)
       body = response.body
       if (response.status_code != 200)
         raise Exception.new(

Keep in mind that the connection pool will have the behavior described here #4326 (comment)

unixfox marked this conversation as resolved.
Show resolved Hide resolved
&.post(endpoint, headers: headers, body: data.to_json))
body = response.body
if (response.status_code != 200)
raise Exception.new(
"Error while communicating with Invidious companion: \
status code: #{response.status_code} and body: #{body.dump}"
)
end
rescue ex
raise InfoException.new("Error while communicating with Invidious companion: " + (ex.message || "no extra info found"))
end

# Convert result to Hash
initial_data = JSON.parse(body).as_h

return initial_data
end

####################################################################
# _decompress(body_io, headers)
#
Expand Down
Loading