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 all 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
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
33 changes: 33 additions & 0 deletions src/invidious/config.cr
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@
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 @@
# 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,23 @@
end
{% end %}

if config.invidious_companion.present?

Check failure on line 241 in src/invidious/config.cr

View workflow job for this annotation

GitHub Actions / build - crystal: 1.10.1, stable: true

undefined method 'present?' for Array(Config::CompanionConfig)
# 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 @@ -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.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 @@ -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.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 @@ -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.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
8 changes: 4 additions & 4 deletions src/invidious/yt_backend/connection_pool.cr
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,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 +78,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
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.present?
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, use_http_proxy: false,
&.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