Compare commits

...

9 Commits

Author SHA1 Message Date
dependabot[bot]
9e160d45d3 Bump actions/stale from 9 to 10
Bumps [actions/stale](https://github.com/actions/stale) from 9 to 10.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v9...v10)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: '10'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 23:36:07 +02:00
Fijxu
ba02a4cdf5
Prevent player microformat from being overwritten by the next microformat (#5453)
* Prevent player microformat from being overwritten by the next microformat

Closes https://github.com/iv-org/invidious/issues/5443

The player microformat is what we need to get the published date,
premiere timestamp, allowed regions and more information of the video.

Youtube introduced a new `microformat.microformatDataRenderer` in the
next endpoint which overwrote the player microformat
`microformat.playerMicroformatRenderer` when merged

* Update src/invidious/videos/parser.cr

Co-authored-by: syeopite <70992037+syeopite@users.noreply.github.com>

---------

Co-authored-by: syeopite <70992037+syeopite@users.noreply.github.com>
2025-09-08 17:16:22 -03:00
Emilien
cf2dfbb75d chore: remove debug 2025-09-08 21:34:47 +02:00
Emilien
21c13bba9d chore: use api captions from companion when available 2025-09-08 21:34:47 +02:00
syeopite
5e9d51c06e Refactor FilteredCompressHandler to inherit from stdlib
This changes its behavior to align with the stdlib variant in that
compression is now delayed till the moment that the server begins to
send a response.

This allows the handler to avoid compressing empty responses,and
safeguards against any double compression of content that may occur
if another handler decides to compressi ts response.

This does however come at the drawback(?) of it now removing
`content-length` headers on requests if it exists; since compression
makes the value inaccurate anyway.

See: https://github.com/crystal-lang/crystal/pull/9625
2025-09-08 21:34:47 +02:00
Emilien
1653dd629e fix formatting 2025-09-08 21:34:47 +02:00
Emilien
cba2adc6ef fix csp + progress proxy + allow omit public_url 2025-09-08 21:34:47 +02:00
Emilien
42b955d713 chore: add the suggestions 2025-09-08 21:34:47 +02:00
Emilien
324a416fd4 initial support for base_url with invidious companion + proxy invidious_companion 2025-09-08 21:34:47 +02:00
13 changed files with 155 additions and 57 deletions

View File

@ -10,7 +10,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v9 - uses: actions/stale@v10
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 730 days-before-stale: 730

View File

@ -75,17 +75,25 @@ db:
## If you are using a reverse proxy then you will probably need to ## 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. ## 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). ## 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 ## Both parameter can have identical URL when Invidious is hosted in
## an internal network or at home or locally (localhost). ## 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>" ## Accepted values: "http(s)://<IP-HOSTNAME>:<Port>"
## Default: <none> ## Default: <none>
## ##
#invidious_companion: #invidious_companion:
# - private_url: "http://localhost:8282" # - private_url: "http://localhost:8282/companion"
# public_url: "http://localhost:8282" # 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 ## API key for Invidious companion, used for securing the communication

View File

@ -82,6 +82,9 @@ class Config
@[YAML::Field(converter: Preferences::URIConverter)] @[YAML::Field(converter: Preferences::URIConverter)]
property public_url : URI = URI.parse("") property public_url : URI = URI.parse("")
# Indicates if this companion instance uses the built-in proxy
property builtin_proxy : Bool = false
end end
# Number of threads to use for crawling videos from channels (for updating subscriptions) # Number of threads to use for crawling videos from channels (for updating subscriptions)
@ -271,6 +274,14 @@ class Config
puts "Config: The value of 'invidious_companion_key' needs to be a size of 16 characters." puts "Config: The value of 'invidious_companion_key' needs to be a size of 16 characters."
exit(1) exit(1)
end 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 elsif config.signature_server
puts("WARNING: inv-sig-helper is deprecated. Please switch to Invidious companion: https://docs.invidious.io/companion-installation/") puts("WARNING: inv-sig-helper is deprecated. Please switch to Invidious companion: https://docs.invidious.io/companion-installation/")
else else

View File

@ -61,28 +61,13 @@ class Kemal::ExceptionHandler
end end
end end
class FilteredCompressHandler < Kemal::Handler class FilteredCompressHandler < HTTP::CompressHandler
exclude ["/videoplayback", "/videoplayback/*", "/vi/*", "/sb/*", "/ggpht/*", "/api/v1/auth/notifications"] exclude ["/videoplayback", "/videoplayback/*", "/vi/*", "/sb/*", "/ggpht/*", "/api/v1/auth/notifications"]
exclude ["/api/v1/auth/notifications", "/data_control"], "POST" exclude ["/api/v1/auth/notifications", "/data_control"], "POST"
def call(env) def call(context)
return call_next env if exclude_match? env return call_next context if exclude_match? context
super
{% if flag?(:without_zlib) %}
call_next env
{% else %}
request_headers = env.request.headers
if request_headers.includes_word?("Accept-Encoding", "gzip")
env.response.headers["Content-Encoding"] = "gzip"
env.response.output = Compress::Gzip::Writer.new(env.response.output, sync_close: true)
elsif request_headers.includes_word?("Accept-Encoding", "deflate")
env.response.headers["Content-Encoding"] = "deflate"
env.response.output = Compress::Deflate::Writer.new(env.response.output, sync_close: true)
end
call_next env
{% end %}
end end
end end

View File

@ -63,6 +63,7 @@ module Invidious::Routes::BeforeAll
"/videoplayback", "/videoplayback",
"/latest_version", "/latest_version",
"/download", "/download",
"/companion/",
}.any? { |r| env.request.resource.starts_with? r } }.any? { |r| env.request.resource.starts_with? r }
if env.request.cookies.has_key? "SID" if env.request.cookies.has_key? "SID"

View File

@ -0,0 +1,43 @@
module Invidious::Routes::Companion
# /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|
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

View File

@ -209,10 +209,17 @@ module Invidious::Routes::Embed
if CONFIG.invidious_companion.present? if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample invidious_companion = CONFIG.invidious_companion.sample
env.response.headers["Content-Security-Policy"] = invidious_companion_urls = CONFIG.invidious_companion.reject(&.builtin_proxy).map do |companion|
env.response.headers["Content-Security-Policy"] uri =
.gsub("media-src", "media-src #{invidious_companion.public_url}") "#{companion.public_url.scheme}://#{companion.public_url.host}#{companion.public_url.port ? ":#{companion.public_url.port}" : ""}"
.gsub("connect-src", "connect-src #{invidious_companion.public_url}") 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 end
rendered "embed" rendered "embed"

View File

@ -194,10 +194,17 @@ module Invidious::Routes::Watch
if CONFIG.invidious_companion.present? if CONFIG.invidious_companion.present?
invidious_companion = CONFIG.invidious_companion.sample invidious_companion = CONFIG.invidious_companion.sample
env.response.headers["Content-Security-Policy"] = invidious_companion_urls = CONFIG.invidious_companion.reject(&.builtin_proxy).map do |companion|
env.response.headers["Content-Security-Policy"] uri =
.gsub("media-src", "media-src #{invidious_companion.public_url}") "#{companion.public_url.scheme}://#{companion.public_url.host}#{companion.public_url.port ? ":#{companion.public_url.port}" : ""}"
.gsub("connect-src", "connect-src #{invidious_companion.public_url}") 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 end
templated "watch" templated "watch"

View File

@ -46,6 +46,7 @@ module Invidious::Routing
self.register_api_v1_routes self.register_api_v1_routes
self.register_api_manifest_routes self.register_api_manifest_routes
self.register_video_playback_routes self.register_video_playback_routes
self.register_companion_routes
end end
# ------------------- # -------------------
@ -188,7 +189,7 @@ module Invidious::Routing
end end
# ------------------- # -------------------
# Media proxy routes # Proxy routes
# ------------------- # -------------------
def register_api_manifest_routes def register_api_manifest_routes
@ -223,6 +224,13 @@ module Invidious::Routing
get "/vi/:id/:name", Routes::Images, :thumbnails get "/vi/:id/:name", Routes::Images, :thumbnails
end 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 # API routes
# ------------------- # -------------------

View File

@ -102,6 +102,9 @@ def extract_video_info(video_id : String)
# Don't fetch the next endpoint if the video is unavailable. # Don't fetch the next endpoint if the video is unavailable.
if {"OK", "LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) if {"OK", "LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status)
next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) next_response = YoutubeAPI.next({"videoId": video_id, "params": ""})
# Remove the microformat returned by the /next endpoint on some videos
# to prevent player_response microformat from being overwritten.
next_response.delete("microformat")
player_response = player_response.merge(next_response) player_response = player_response.merge(next_response)
end end

View File

@ -65,12 +65,18 @@
<% end %> <% end %>
<% end %> <% end %>
<% preferred_captions.each do |caption| %> <% preferred_captions.each do |caption|
<track kind="captions" src="/api/v1/captions/<%= video.id %>?label=<%= caption.name %>" label="<%= caption.name %>"> api_captions_url = "/api/v1/captions/"
api_captions_url = invidious_companion.public_url.to_s + api_captions_url if (invidious_companion)
%>
<track kind="captions" src="<%= api_captions_url %><%= video.id %>?label=<%= caption.name %>" label="<%= caption.name %>">
<% end %> <% end %>
<% captions.each do |caption| %> <% captions.each do |caption|
<track kind="captions" src="/api/v1/captions/<%= video.id %>?label=<%= caption.name %>" label="<%= caption.name %>"> api_captions_url = "/api/v1/captions/"
api_captions_url = invidious_companion.public_url.to_s + api_captions_url if (invidious_companion)
%>
<track kind="captions" src="<%= api_captions_url %><%= video.id %>?label=<%= caption.name %>" label="<%= caption.name %>">
<% end %> <% end %>
<% end %> <% end %>
</video> </video>

View File

@ -46,8 +46,27 @@ struct YoutubeConnectionPool
end end
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 struct CompanionConnectionPool
property pool : DB::Pool(HTTP::Client) property pool : DB::Pool(CompanionWrapper)
def initialize(capacity = 5, timeout = 5.0) def initialize(capacity = 5, timeout = 5.0)
options = DB::Pool::Options.new( options = DB::Pool::Options.new(
@ -57,26 +76,28 @@ struct CompanionConnectionPool
checkout_timeout: timeout checkout_timeout: timeout
) )
@pool = DB::Pool(HTTP::Client).new(options) do @pool = DB::Pool(CompanionWrapper).new(options) do
companion = CONFIG.invidious_companion.sample 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
end end
def client(&) def client(&)
conn = pool.checkout wrapper = pool.checkout
begin begin
response = yield conn response = yield wrapper
rescue ex rescue ex
conn.close wrapper.close
companion = CONFIG.invidious_companion.sample 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 ensure
pool.release(conn) pool.release(wrapper)
end end
response response

View File

@ -701,22 +701,20 @@ module YoutubeAPI
# Send the POST request # Send the POST request
begin begin
response = COMPANION_POOL.client &.post(endpoint, headers: headers, body: data.to_json) response_body = Hash(String, JSON::Any).new
body = response.body
if (response.status_code != 200) COMPANION_POOL.client do |wrapper|
raise Exception.new( companion_base_url = wrapper.companion.private_url.path
"Error while communicating with Invidious companion: \
status code: #{response.status_code} and body: #{body.dump}" 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 end
return response_body
rescue ex rescue ex
raise InfoException.new("Error while communicating with Invidious companion: " + (ex.message || "no extra info found")) raise InfoException.new("Error while communicating with Invidious companion: " + (ex.message || "no extra info found"))
end end
# Convert result to Hash
initial_data = JSON.parse(body).as_h
return initial_data
end end
#################################################################### ####################################################################