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 16 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
26 changes: 26 additions & 0 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,32 @@ 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.
##
## When this setting is configured, then Invidious will proxy the requests
## to Invidious companion.
## Or randomly choose one if multiple Invidious companion are configured.
unixfox marked this conversation as resolved.
Show resolved Hide resolved
##
## Accepted values: "http(s)://<IP-HOSTNAME>:<Port>"
## Default: <none>
##
#invidious_companion:
# - http://127.0.0.1:8282

##
## API key for Invidious companion
##
## Needed when invidious_companion is configured
##
## Accepted values: "http(s)://<IP-HOSTNAME>:<Port>"
## Default: <none>
##

#invidious_companion_key: "CHANGE_ME!!"

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

module URIArrayConverter
def self.to_yaml(values : Array(URI), yaml : YAML::Nodes::Builder)
yaml.sequence do
values.each { |v| yaml.scalar v.to_s }
end
end

def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(URI)
if node.is_a?(YAML::Nodes::Sequence)
node.map do |child|
unless child.is_a?(YAML::Nodes::Scalar)
node.raise "Expected scalar, not #{child.class}"
end

URI.parse(child.value)
end
else
node.raise "Expected sequence, not #{node.class}"
end
end
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 +173,13 @@ class Config
# poToken for passing bot attestation
property po_token : String? = nil

# Invidious companion
@[YAML::Field(converter: Config::URIArrayConverter)]
property invidious_companion : Array(URI) = [] of URI

# 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 +251,21 @@ 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)
end
end

# HMAC_key is mandatory
# See: https://github.com/iv-org/invidious/issues/3854
if config.hmac_key.empty?
Expand Down
4 changes: 4 additions & 0 deletions src/invidious/routes/api/manifest.cr
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
module Invidious::Routes::API::Manifest
# /api/manifest/dash/id/:id
def self.get_dash_video_id(env)
if !CONFIG.invidious_companion.empty?
return error_template(403, "This endpoint is not permitted because it is handled by Invidious companion.")
end
Copy link
Member

Choose a reason for hiding this comment

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

What do you think here to redirect to a random invidious companion, rather than display an error message? Sure, if there are multiple companions, we might not hit the same that initially loaded the watch page, but it might make playback smoother.

Copy link
Member Author

Choose a reason for hiding this comment

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

The issue is that you have to do a request to a random invidious companion for knowing its "public" URL. I had ideas to include the public URL inside the config.yml. But my poor Crystal knowledge limited me due to the new URIArrayConverter.

Something like:

invidious_companion:
 - http://127.0.0.1:8282:
      public_url: https://companion1.invidious.com

I want to avoid doing unnecessary requests to invidious companion.

http://127.0.0.1:8282 is the internal address that invidious uses to communicate with invidious companion. but the companion could very well be on another server, and so having a config like this can exist:

invidious_companion:
 - http://10.0.0.2:8282:
      public_url: https://companion1.invidious.com

10.0.0.2 is another server and 10.0.0.0/24 is an internal network faster than the internet network. example: https://www.ovhcloud.com/en/public-cloud/private-network/

Copy link
Member

Choose a reason for hiding this comment

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

Something like this would be more straight forward to add

invidious_companion:
  - private_url: "http://localhost:8000"
    public_url: "https://example.com"

  - private_url: "http://localhost:8001"
    public_url: "https://example2.com"
struct 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

class Config
  # ...

  property invidious_companion : Array(CompanionConfig)? = nil
end

Copy link
Member

@SamantazFox SamantazFox Dec 8, 2024

Choose a reason for hiding this comment

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

I think that the simplest way to do it would be with an intermediate class, like that. Then the URIArrayConverter is not needed anymore, you can just use the simpler URIConverter

class CompanionConfig
  @[YAML::Field(converter: Preferences::URIConverter)]
  property internal_url : URI

  @[YAML::Field(converter: Preferences::URIConverter)]
  property public_url : URI
end

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


env.response.headers.add("Access-Control-Allow-Origin", "*")
env.response.content_type = "application/dash+xml"

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 (!CONFIG.invidious_companion.empty?)
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src " + video.invidious_companion.not_nil!["baseUrl"].as_s)
.gsub("connect-src", "connect-src " + video.invidious_companion.not_nil!["baseUrl"].as_s)
end
unixfox marked this conversation as resolved.
Show resolved Hide resolved

rendered "embed"
end
end
4 changes: 4 additions & 0 deletions src/invidious/routes/video_playback.cr
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,10 @@ 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?
return error_template(403, "This endpoint is not permitted because it is handled by Invidious companion.")
end

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

Expand Down
18 changes: 16 additions & 2 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 (!CONFIG.invidious_companion.empty?)
env.response.headers["Content-Security-Policy"] =
env.response.headers["Content-Security-Policy"]
.gsub("media-src", "media-src " + video.invidious_companion.not_nil!["baseUrl"].as_s)
.gsub("connect-src", "connect-src " + video.invidious_companion.not_nil!["baseUrl"].as_s)
end
unixfox marked this conversation as resolved.
Show resolved Hide resolved

templated "watch"
end

Expand Down Expand Up @@ -321,13 +328,20 @@ module Invidious::Routes::Watch

return Invidious::Routes::API::V1::Videos.captions(env)
elsif itag = download_widget["itag"]?.try &.as_i
itag = itag.to_s
unixfox marked this conversation as resolved.
Show resolved Hide resolved

# URL params specific to /latest_version
env.params.query["id"] = video_id
env.params.query["itag"] = itag.to_s
env.params.query["itag"] = itag
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.not_nil!["baseUrl"].as_s}/latest_version?id=#{video_id}&itag=#{itag}&local=true"
unixfox marked this conversation as resolved.
Show resolved Hide resolved
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
end
unixfox marked this conversation as resolved.
Show resolved Hide resolved

# 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
9 changes: 7 additions & 2 deletions src/invidious/views/components/player.ecr
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
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.not_nil!["baseUrl"].as_s + src_url if (!CONFIG.invidious_companion.empty?)

bitrate = fmt["bitrate"]
mimetype = HTML.escape(fmt["mimeType"].as_s)
Expand All @@ -34,8 +35,11 @@
<% 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.not_nil!["baseUrl"].as_s + src_url if (!CONFIG.invidious_companion.empty?)
%>
<source src="<%= src_url %>" type='application/dash+xml' label="dash">
<% end %>

<%
Expand All @@ -44,6 +48,7 @@
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.not_nil!["baseUrl"].as_s + src_url if (!CONFIG.invidious_companion.empty?)

quality = fmt["quality"]
mimetype = HTML.escape(fmt["mimeType"].as_s)
Expand Down
52 changes: 51 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,52 @@ 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,
unixfox marked this conversation as resolved.
Show resolved Hide resolved
}

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

# Send the POST request

begin
response = make_client(CONFIG.invidious_companion.sample,
&.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.to_s + " and body: " + body)
end
unixfox marked this conversation as resolved.
Show resolved Hide resolved
rescue ex
raise InfoException.new("Error while communicating with Invidious companion: " + (ex.message || "no extra info found"))
end

if body.nil?
raise InfoException.new("Error while communicating with Invidious companion: no response data.")
end
unixfox marked this conversation as resolved.
Show resolved Hide resolved

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

return initial_data
end

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