Skip to content

initial base_url companion support + proxy companion #5266

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

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 11 additions & 3 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,25 @@ db:
## If you are using a reverse proxy then you will probably need to
## configure the public_url to be the same as the domain used for Invidious.
## Also apply when used from an external IP address (without a domain).
## Examples: https://MYINVIDIOUSDOMAIN or http://192.168.1.100:8282
## Examples: https://MYINVIDIOUSDOMAIN/companion or http://192.168.1.100:8282/companion
##
## Both parameter can have identical URL when Invidious is hosted in
## an internal network or at home or locally (localhost).
##
## NOTE: If public_url is omitted, Invidious will use its built-in proxy
## to route companion requests through /companion, which is useful for
## simple setups where companion runs on the same network. When using
## the built-in proxy, CSP headers are not modified since requests
## stay within the same domain.
##
## Accepted values: "http(s)://<IP-HOSTNAME>:<Port>"
## Default: <none>
##
#invidious_companion:
# - private_url: "http://localhost:8282"
# public_url: "http://localhost:8282"
# - private_url: "http://localhost:8282/companion"
# public_url: "http://localhost:8282/companion"
# # Example with built-in proxy (omit public_url):
# # - private_url: "http://localhost:8282/companion"

##
## API key for Invidious companion, used for securing the communication
Expand Down
11 changes: 11 additions & 0 deletions src/invidious/config.cr
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ class Config

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

# Indicates if this companion instance uses the built-in proxy
property builtin_proxy : Bool = false
end

# Number of threads to use for crawling videos from channels (for updating subscriptions)
Expand Down Expand Up @@ -271,6 +274,14 @@ class Config
puts "Config: The value of 'invidious_companion_key' needs to be a size of 16 characters."
exit(1)
end

# Set public_url to built-in proxy path when omitted
config.invidious_companion.each do |companion|
if companion.public_url.to_s.empty?
companion.public_url = URI.parse("/companion")
companion.builtin_proxy = true
end
end
elsif config.signature_server
puts("WARNING: inv-sig-helper is deprecated. Please switch to Invidious companion: https://docs.invidious.io/companion-installation/")
else
Expand Down
1 change: 1 addition & 0 deletions src/invidious/routes/before_all.cr
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ module Invidious::Routes::BeforeAll
"/videoplayback",
"/latest_version",
"/download",
"/companion/",
}.any? { |r| env.request.resource.starts_with? r }

if env.request.cookies.has_key? "SID"
Expand Down
44 changes: 44 additions & 0 deletions src/invidious/routes/companion.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
module Invidious::Routes::Companion
Copy link
Member

@syeopite syeopite May 15, 2025

Choose a reason for hiding this comment

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

Please add the Accept-Encoding header to each companion request as so you aren't decompressing the body. Invidious should just directly proxy the compressed response from companion to the end user.

https://crystal-lang.org/api/1.16.3/HTTP/Client.html

If compress [HTTP::Client instance property] isn't set to false, and no Accept-Encoding header is explicitly specified, an HTTP::Client will add an "Accept-Encoding": "gzip, deflate" header, and automatically decompress the response body/body_io.

Copy link
Member Author

@unixfox unixfox Jun 14, 2025

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

Not the requests that doesn't have a Accept-Encoding encoder. In those requests the the client will request a compressed response from companion and then waste some CPU cycles to decompress it before proxying to the user

# /companion
def self.get_companion(env)
url = env.request.path
if env.request.query
url += "?#{env.request.query}"
end

begin
COMPANION_POOL.client do |wrapper|
puts env.request.headers
wrapper.client.get(url, env.request.headers) do |resp|
return self.proxy_companion(env, resp)
end
end
rescue ex
end
end

def self.options_companion(env)
url = env.request.path
if env.request.query
url += "?#{env.request.query}"
end

begin
COMPANION_POOL.client do |wrapper|
wrapper.client.options(url, env.request.headers) do |resp|
return self.proxy_companion(env, resp)
end
end
rescue ex
end
end

private def self.proxy_companion(env, response)
env.response.status_code = response.status_code
response.headers.each do |key, value|
env.response.headers[key] = value
end

return IO.copy response.body_io, env.response
end
end
15 changes: 11 additions & 4 deletions src/invidious/routes/embed.cr
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,17 @@ module Invidious::Routes::Embed

if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src #{invidious_companion.public_url}")
.gsub("connect-src", "connect-src #{invidious_companion.public_url}")
invidious_companion_urls = CONFIG.invidious_companion.reject(&.builtin_proxy).map do |companion|
uri =
"#{companion.public_url.scheme}://#{companion.public_url.host}#{companion.public_url.port ? ":#{companion.public_url.port}" : ""}"
end.join(" ")

if !invidious_companion_urls.empty?
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src #{invidious_companion_urls}")
.gsub("connect-src", "connect-src #{invidious_companion_urls}")
end
end

rendered "embed"
Expand Down
15 changes: 11 additions & 4 deletions src/invidious/routes/watch.cr
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,17 @@ module Invidious::Routes::Watch

if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src #{invidious_companion.public_url}")
.gsub("connect-src", "connect-src #{invidious_companion.public_url}")
invidious_companion_urls = CONFIG.invidious_companion.reject(&.builtin_proxy).map do |companion|
uri =
"#{companion.public_url.scheme}://#{companion.public_url.host}#{companion.public_url.port ? ":#{companion.public_url.port}" : ""}"
end.join(" ")

if !invidious_companion_urls.empty?
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src #{invidious_companion_urls}")
.gsub("connect-src", "connect-src #{invidious_companion_urls}")
end
end

templated "watch"
Expand Down
10 changes: 9 additions & 1 deletion src/invidious/routing.cr
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ module Invidious::Routing
self.register_api_v1_routes
self.register_api_manifest_routes
self.register_video_playback_routes
self.register_companion_routes
end

# -------------------
Expand Down Expand Up @@ -188,7 +189,7 @@ module Invidious::Routing
end

# -------------------
# Media proxy routes
# Proxy routes
# -------------------

def register_api_manifest_routes
Expand Down Expand Up @@ -223,6 +224,13 @@ module Invidious::Routing
get "/vi/:id/:name", Routes::Images, :thumbnails
end

def register_companion_routes
if CONFIG.invidious_companion.present?
get "/companion/*", Routes::Companion, :get_companion
options "/companion/*", Routes::Companion, :options_companion
end
end

# -------------------
# API routes
# -------------------
Expand Down
39 changes: 30 additions & 9 deletions src/invidious/yt_backend/connection_pool.cr
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,27 @@ struct YoutubeConnectionPool
end
end

# Packages a `HTTP::Client` to an Invidious companion instance alongside the configuration for that instance.
#
# This is used as the resource for the `CompanionPool` as to allow the ability to
# proxy the requests to Invidious companion from Invidious directly.
# Instead of setting up routes in a reverse proxy.
struct CompanionWrapper
property client : HTTP::Client
property companion : Config::CompanionConfig

def initialize(companion : Config::CompanionConfig)
@companion = companion
@client = make_client(companion.private_url, use_http_proxy: false)
end

def close
@client.close
end
end

struct CompanionConnectionPool
property pool : DB::Pool(HTTP::Client)
property pool : DB::Pool(CompanionWrapper)

def initialize(capacity = 5, timeout = 5.0)
options = DB::Pool::Options.new(
Expand All @@ -57,26 +76,28 @@ struct CompanionConnectionPool
checkout_timeout: timeout
)

@pool = DB::Pool(HTTP::Client).new(options) do
@pool = DB::Pool(CompanionWrapper).new(options) do
companion = CONFIG.invidious_companion.sample
next make_client(companion.private_url, use_http_proxy: false)
make_client(companion.private_url, use_http_proxy: false)
CompanionWrapper.new(companion: companion)
end
end

def client(&)
conn = pool.checkout
wrapper = pool.checkout

begin
response = yield conn
response = yield wrapper
rescue ex
conn.close
wrapper.close

companion = CONFIG.invidious_companion.sample
conn = make_client(companion.private_url, use_http_proxy: false)
make_client(companion.private_url, use_http_proxy: false)
wrapper = CompanionWrapper.new(companion: companion)

response = yield conn
response = yield wrapper
ensure
pool.release(conn)
pool.release(wrapper)
end

response
Expand Down
22 changes: 10 additions & 12 deletions src/invidious/yt_backend/youtube_api.cr
Original file line number Diff line number Diff line change
Expand Up @@ -695,22 +695,20 @@ module YoutubeAPI
# Send the POST request

begin
response = COMPANION_POOL.client &.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}"
)
response_body = Hash(String, JSON::Any).new

COMPANION_POOL.client do |wrapper|
companion_base_url = wrapper.companion.private_url.path

wrapper.client.post("#{companion_base_url}#{endpoint}", headers: headers, body: data.to_json) do |response|
response_body = JSON.parse(response.body_io).as_h
end
end

return response_body
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

####################################################################
Expand Down
Loading