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 27 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bae4e44
add support for invidious companion
unixfox Oct 20, 2024
3629bdf
redirect latest_version and dash manifest to invidious companion
unixfox Oct 20, 2024
8dbe1f6
fix Shadowing outer local variable `response`
unixfox Oct 20, 2024
c29c878
fixing condition for Content-Security-Policy
unixfox Oct 20, 2024
02c5def
throw error if inv_sig_helper and invidious_companion used same time
unixfox Nov 1, 2024
c95dc9a
Use sample instead of Random.rand
unixfox Nov 5, 2024
84bd6a8
Remove debug puts functions
unixfox Nov 5, 2024
c262d70
modify the description for config.example.yaml about invidious companion
unixfox Nov 5, 2024
401cba0
move config checks for invidious companion
unixfox Nov 8, 2024
39e5370
separate invidious_companion logic + better config.yaml config
unixfox Nov 16, 2024
02c904c
fixing "end" misplacement
unixfox Nov 16, 2024
233f952
fix linting + use .empty?
unixfox Nov 16, 2024
4d7ee90
crystal handle decompression already by itself
unixfox Nov 17, 2024
d95df87
fix download function when invidious companion used
unixfox Nov 17, 2024
96ed65f
fix linting
unixfox Nov 18, 2024
50f47a5
invidious companion always used so always add CSP and redirect latest…
unixfox Nov 18, 2024
d7d3aed
apply all the suggestions + rework invidious_companion parameter
unixfox Dec 8, 2024
d7fe911
format watch.cr
unixfox Dec 8, 2024
b4910f4
fix ameba Redundant use of `Object#to_s` in interpolation
unixfox Dec 8, 2024
847d4b5
add ability for invidious companion to check request from invidious
unixfox Dec 13, 2024
74bf684
Better document private_url and public_url
unixfox Dec 24, 2024
5beb2a8
Better doc for invidious_companion_key
unixfox Dec 24, 2024
da5f959
!empty? to present?
unixfox Dec 30, 2024
020de08
skip proxy for invidious companion
unixfox Dec 30, 2024
5e6cee6
fixing format
unixfox Dec 30, 2024
568081b
missing ,
unixfox Dec 30, 2024
e57f292
add companion pooling http
unixfox Mar 2, 2025
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
47 changes: 47 additions & 0 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,53 @@ 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).
##
## 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
##
## Both parameter can have identical URL when Invidious is hosted in
## an internal network or at home or locally (localhost).
##
## 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, used for securing the communication
## between Invidious and Invidious companion.
## The size of the key needs to be more or equal to 16.
##
## Note: This parameter is mandatory when Invidious companion is enabled
## and should be a random string.
## Such random string can be generated on linux with the following
## command: `pwgen 16 1`
##
## Accepted values: a string
## Default: <none>
##
#invidious_companion_key: "CHANGE_ME!!"

#########################################
#
Expand Down
4 changes: 4 additions & 0 deletions src/invidious.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
Expand Down
33 changes: 33 additions & 0 deletions src/invidious/config.cr
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,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 @@ -160,6 +170,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 @@ -240,6 +256,23 @@ class Config
end
{% end %}

if config.invidious_companion.present?
# 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)
elsif config.invidious_companion_key.empty?
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.present?
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 @@ -203,6 +203,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
Comment on lines +206 to +211
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 @@ -256,6 +256,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.present?
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 @@ -192,6 +192,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 @@ -314,14 +321,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.present?)
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 @@ -108,30 +108,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.present?
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.present?)

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.present?)
%>
<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.present?)

quality = fmt["quality"]
mimetype = HTML.escape(fmt["mimeType"].as_s)
Expand Down
47 changes: 43 additions & 4 deletions src/invidious/yt_backend/connection_pool.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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
next make_client(companion.private_url, force_resolve: true)
next make_client(companion.private_url, use_http_proxy: false)

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
Comment on lines +68 to +69
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
# 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

Copy link
Member Author

Choose a reason for hiding this comment

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

ok you mean removing those lines


begin
response = yield conn
rescue ex
conn.close

companion = CONFIG.invidious_companion.sample
conn = make_client(companion.private_url, force_resolve: true)
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
conn = make_client(companion.private_url, force_resolve: true)
conn = make_client(companion.private_url, use_http_proxy: false)


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"
Expand All @@ -61,9 +100,9 @@ def add_yt_headers(request)
end
end

def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false)
def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false, use_http_proxy : Bool = true)
client = HTTP::Client.new(url)
client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy
client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy && use_http_proxy

# Force the usage of a specific configured IP Family
if force_resolve
Expand All @@ -78,8 +117,8 @@ def make_client(url : URI, region = nil, force_resolve : Bool = false, force_you
return client
end

def make_client(url : URI, region = nil, force_resolve : Bool = false, &)
client = make_client(url, region, force_resolve: force_resolve)
def make_client(url : URI, region = nil, force_resolve : Bool = false, use_http_proxy : Bool = true, &)
client = make_client(url, region, force_resolve: force_resolve, use_http_proxy: use_http_proxy)
begin
yield client
ensure
Expand Down
Loading