Merge remote-tracking branch 'upstream'
Some checks failed
Stale issue handler / stale (push) Has been cancelled
Build and release container directly from master / release (docker/Dockerfile, AMD64, ubuntu-latest, linux/amd64, ) (push) Has been cancelled
Build and release container directly from master / release (docker/Dockerfile.arm64, ARM64, ubuntu-24.04-arm, linux/arm64/v8, -arm64) (push) Has been cancelled
Invidious CI / build - crystal: 1.12.2, stable: true (push) Has been cancelled
Invidious CI / build - crystal: 1.13.3, stable: true (push) Has been cancelled
Invidious CI / build - crystal: 1.14.1, stable: true (push) Has been cancelled
Invidious CI / build - crystal: 1.15.1, stable: true (push) Has been cancelled
Invidious CI / build - crystal: 1.16.3, stable: true (push) Has been cancelled
Invidious CI / build - crystal: nightly, stable: false (push) Has been cancelled
Invidious CI / Test AMD64 Docker build (push) Has been cancelled
Invidious CI / Test ARM64 Docker build (push) Has been cancelled
Invidious CI / lint (push) Has been cancelled

This commit is contained in:
Fijxu
2025-12-06 20:39:35 -03:00
31 changed files with 75 additions and 613 deletions

View File

@@ -36,7 +36,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3

View File

@@ -27,7 +27,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3

View File

@@ -48,7 +48,7 @@ jobs:
stable: false stable: false
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
with: with:
submodules: true submodules: true
@@ -58,7 +58,7 @@ jobs:
shell: bash shell: bash
- name: Install Crystal - name: Install Crystal
uses: crystal-lang/install-crystal@v1.8.3 uses: crystal-lang/install-crystal@v1.9.1
with: with:
crystal: ${{ matrix.crystal }} crystal: ${{ matrix.crystal }}
@@ -96,7 +96,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Use ARM64 Dockerfile if ARM64 - name: Use ARM64 Dockerfile if ARM64
if: ${{ matrix.name == 'ARM64' }} if: ${{ matrix.name == 'ARM64' }}
@@ -128,13 +128,13 @@ jobs:
continue-on-error: true continue-on-error: true
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
with: with:
submodules: true submodules: true
- name: Install Crystal - name: Install Crystal
id: lint_step_install_crystal id: lint_step_install_crystal
uses: crystal-lang/install-crystal@v1.8.3 uses: crystal-lang/install-crystal@v1.9.1
with: with:
crystal: latest crystal: latest

View File

@@ -410,8 +410,9 @@ input[type="search"]::-webkit-search-cancel-button {
.video-card-row { margin: 15px 0; } .video-card-row { margin: 15px 0; }
p.channel-name { margin: 0; } p.channel-name { margin: 0; overflow-wrap: anywhere;}
p.video-data { margin: 0; font-weight: bold; font-size: 80%; } p.video-data { margin: 0; font-weight: bold; font-size: 80%; }
.channel-profile > .channel-name { overflow-wrap: anywhere;}
/* /*

View File

@@ -40,20 +40,6 @@ db:
## ##
#check_tables: false #check_tables: false
##
## Path to an external signature resolver, used to emulate
## the Youtube client's Javascript. If no such server is
## available, some videos will not be playable.
##
## When this setting is commented out, no external
## resolver will be used.
##
## Accepted values: a path to a UNIX socket or "<IP>:<Port>"
## Default: <none>
##
#signature_server:
## ##
## Invidious companion is an external program ## Invidious companion is an external program
## for loading the video streams from YouTube servers. ## for loading the video streams from YouTube servers.
@@ -273,19 +259,6 @@ https_only: false
## ##
# use_innertube_for_captions: false # use_innertube_for_captions: false
##
## Send Google session informations. This is useful when Invidious is blocked
## by the message "This helps protect our community."
## See https://github.com/iv-org/invidious/issues/4734.
##
## Warning: These strings gives much more identifiable information to Google!
##
## Accepted values: String
## Default: <none>
##
# po_token: ""
# visitor_data: ""
# ----------------------------- # -----------------------------
# Logging # Logging
# ----------------------------- # -----------------------------

View File

@@ -541,10 +541,5 @@
"timeline_parse_error_placeholder_heading": "Unable to parse item", "timeline_parse_error_placeholder_heading": "Unable to parse item",
"timeline_parse_error_placeholder_message": "Invidious encountered an error while trying to parse this item. For more information see below:", "timeline_parse_error_placeholder_message": "Invidious encountered an error while trying to parse this item. For more information see below:",
"timeline_parse_error_show_technical_details": "Show technical details", "timeline_parse_error_show_technical_details": "Show technical details",
"video_description_toggle_transcript_widget_label": "Transcripts", "dmca_content": "This video cannot be downloaded on this instance due to a DMCA/copyright infringement letter sent to the instance administrator."
"video_description_toggle_transcript_widget_button_label_show": "Show transcript",
"video_description_toggle_transcript_widget_button_label_hide": "Hide transcript",
"error_transcripts_none_available": "No transcripts are available",
"transcript_widget_title": "Transcript",
"transcript_widget_no_js_change_transcript_btn": "Swap"
} }

View File

@@ -40,7 +40,7 @@ development_dependencies:
crystal: ">= 1.10.0, < 2.0.0" crystal: ">= 1.10.0, < 2.0.0"
license: AGPLv3 license: AGPL-3.0-only
repository: https://github.com/iv-org/invidious repository: https://github.com/iv-org/invidious
homepage: https://invidious.io homepage: https://invidious.io

View File

@@ -175,15 +175,6 @@ Invidious::Database.check_integrity(CONFIG)
{% puts "\nDone checking player dependencies, now compiling Invidious...\n" %} {% puts "\nDone checking player dependencies, now compiling Invidious...\n" %}
{% end %} {% end %}
# Misc
DECRYPT_FUNCTION =
if sig_helper_address = CONFIG.signature_server.presence
IV::DecryptFunction.new(sig_helper_address)
else
nil
end
# Start jobs # Start jobs
if CONFIG.channel_threads > 0 if CONFIG.channel_threads > 0

View File

@@ -143,7 +143,7 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_sing
case attachment.as_h case attachment.as_h
when .has_key?("videoRenderer") when .has_key?("videoRenderer")
parse_item(attachment) parse_item(attachment)
.as(SearchVideo) .as(SearchVideo | ProblematicTimelineItem)
.to_json(locale, json) .to_json(locale, json)
when .has_key?("backstageImageRenderer") when .has_key?("backstageImageRenderer")
json.object do json.object do

View File

@@ -184,9 +184,6 @@ class Config
@[YAML::Field(converter: Preferences::FamilyConverter)] @[YAML::Field(converter: Preferences::FamilyConverter)]
property force_resolve : Socket::Family = Socket::Family::UNSPEC property force_resolve : Socket::Family = Socket::Family::UNSPEC
# External signature solver server socket (either a path to a UNIX domain socket or "<IP>:<Port>")
property signature_server : String? = nil
# Port to listen for connections (overridden by command line argument) # Port to listen for connections (overridden by command line argument)
property port : Int32 = 3000 property port : Int32 = 3000
# Host to bind (overridden by command line argument) # Host to bind (overridden by command line argument)
@@ -203,11 +200,6 @@ class Config
# Use Innertube's transcripts API instead of timedtext for closed captions # Use Innertube's transcripts API instead of timedtext for closed captions
property use_innertube_for_captions : Bool = false property use_innertube_for_captions : Bool = false
# visitor data ID for Google session
property visitor_data : String? = nil
# poToken for passing bot attestation
property po_token : String? = nil
# Invidious companion # Invidious companion
property invidious_companion : Array(CompanionConfig) = [] of CompanionConfig property invidious_companion : Array(CompanionConfig) = [] of CompanionConfig
@@ -398,11 +390,7 @@ class Config
{% end %} {% end %}
if config.invidious_companion.present? if config.invidious_companion.present?
# invidious_companion and signature_server can't work together if config.invidious_companion_key.empty?
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." puts "Config: Please configure a key if you are using invidious companion."
exit(1) exit(1)
elsif config.invidious_companion_key == "CHANGE_ME!!" elsif config.invidious_companion_key == "CHANGE_ME!!"
@@ -420,8 +408,6 @@ class Config
companion.builtin_proxy = true companion.builtin_proxy = true
end end
end end
elsif config.signature_server
puts("WARNING: inv-sig-helper is deprecated. Please switch to Invidious companion: https://docs.invidious.io/installation/")
else else
puts("WARNING: Invidious companion is required to view and playback videos. For more information see https://docs.invidious.io/installation/") puts("WARNING: Invidious companion is required to view and playback videos. For more information see https://docs.invidious.io/installation/")
end end

View File

@@ -2,9 +2,9 @@ module Invidious::Frontend::Misc
extend self extend self
def redirect_url(env : HTTP::Server::Context) def redirect_url(env : HTTP::Server::Context)
prefs = env.get("preferences").as(Preferences) preferences = env.get("preferences").as(Preferences)
if prefs.automatic_instance_redirect if preferences.automatic_instance_redirect
current_page = env.get?("current_page").as(String) current_page = env.get?("current_page").as(String)
return "/redirect?referer=#{current_page}" return "/redirect?referer=#{current_page}"
else else

View File

@@ -23,6 +23,10 @@ module Invidious::Frontend::WatchPage
return "<p id=\"download\">#{translate(locale, "Download is disabled")}</p>" return "<p id=\"download\">#{translate(locale, "Download is disabled")}</p>"
end end
if CONFIG.dmca_content.includes?(video.id)
return "<p id=\"download\">#{translate(locale, "dmca_content")}</p>"
end
url = "/download" url = "/download"
if (CONFIG.invidious_companion.present?) if (CONFIG.invidious_companion.present?)
current_companion = env.get("current_companion").as(Int32) current_companion = env.get("current_companion").as(Int32)

View File

@@ -1,349 +0,0 @@
require "uri"
require "socket"
require "socket/tcp_socket"
require "socket/unix_socket"
{% if flag?(:advanced_debug) %}
require "io/hexdump"
{% end %}
private alias NetworkEndian = IO::ByteFormat::NetworkEndian
module Invidious::SigHelper
enum UpdateStatus
Updated
UpdateNotRequired
Error
end
# -------------------
# Payload types
# -------------------
abstract struct Payload
end
struct StringPayload < Payload
getter string : String
def initialize(str : String)
raise Exception.new("SigHelper: String can't be empty") if str.empty?
@string = str
end
def self.from_bytes(slice : Bytes)
size = IO::ByteFormat::NetworkEndian.decode(UInt16, slice)
if size == 0 # Error code
raise Exception.new("SigHelper: Server encountered an error")
end
if (slice.bytesize - 2) != size
raise Exception.new("SigHelper: String size mismatch")
end
if str = String.new(slice[2..])
return self.new(str)
else
raise Exception.new("SigHelper: Can't read string from socket")
end
end
def to_io(io)
# `.to_u16` raises if there is an overflow during the conversion
io.write_bytes(@string.bytesize.to_u16, NetworkEndian)
io.write(@string.to_slice)
end
end
private enum Opcode
FORCE_UPDATE = 0
DECRYPT_N_SIGNATURE = 1
DECRYPT_SIGNATURE = 2
GET_SIGNATURE_TIMESTAMP = 3
GET_PLAYER_STATUS = 4
PLAYER_UPDATE_TIMESTAMP = 5
end
private record Request,
opcode : Opcode,
payload : Payload?
# ----------------------
# High-level functions
# ----------------------
class Client
@mux : Multiplexor
def initialize(uri_or_path)
@mux = Multiplexor.new(uri_or_path)
end
# Forces the server to re-fetch the YouTube player, and extract the necessary
# components from it (nsig function code, sig function code, signature timestamp).
def force_update : UpdateStatus
request = Request.new(Opcode::FORCE_UPDATE, nil)
value = send_request(request) do |bytes|
IO::ByteFormat::NetworkEndian.decode(UInt16, bytes)
end
case value
when 0x0000 then return UpdateStatus::Error
when 0xFFFF then return UpdateStatus::UpdateNotRequired
when 0xF44F then return UpdateStatus::Updated
else
code = value.nil? ? "nil" : value.to_s(base: 16)
raise Exception.new("SigHelper: Invalid status code received #{code}")
end
end
# Decrypt a provided n signature using the server's current nsig function
# code, and return the result (or an error).
def decrypt_n_param(n : String) : String?
request = Request.new(Opcode::DECRYPT_N_SIGNATURE, StringPayload.new(n))
n_dec = self.send_request(request) do |bytes|
StringPayload.from_bytes(bytes).string
end
return n_dec
end
# Decrypt a provided s signature using the server's current sig function
# code, and return the result (or an error).
def decrypt_sig(sig : String) : String?
request = Request.new(Opcode::DECRYPT_SIGNATURE, StringPayload.new(sig))
sig_dec = self.send_request(request) do |bytes|
StringPayload.from_bytes(bytes).string
end
return sig_dec
end
# Return the signature timestamp from the server's current player
def get_signature_timestamp : UInt64?
request = Request.new(Opcode::GET_SIGNATURE_TIMESTAMP, nil)
return self.send_request(request) do |bytes|
IO::ByteFormat::NetworkEndian.decode(UInt64, bytes)
end
end
# Return the current player's version
def get_player : UInt32?
request = Request.new(Opcode::GET_PLAYER_STATUS, nil)
return self.send_request(request) do |bytes|
has_player = (bytes[0] == 0xFF)
player_version = IO::ByteFormat::NetworkEndian.decode(UInt32, bytes[1..4])
has_player ? player_version : nil
end
end
# Return when the player was last updated
def get_player_timestamp : UInt64?
request = Request.new(Opcode::PLAYER_UPDATE_TIMESTAMP, nil)
return self.send_request(request) do |bytes|
IO::ByteFormat::NetworkEndian.decode(UInt64, bytes)
end
end
private def send_request(request : Request, &)
channel = @mux.send(request)
slice = channel.receive
return yield slice
rescue ex
LOGGER.debug("SigHelper: Error when sending a request")
LOGGER.trace(ex.inspect_with_backtrace)
return nil
end
end
# ---------------------
# Low level functions
# ---------------------
class Multiplexor
alias TransactionID = UInt32
record Transaction, channel = ::Channel(Bytes).new
@prng = Random.new
@mutex = Mutex.new
@queue = {} of TransactionID => Transaction
@conn : Connection
@uri_or_path : String
def initialize(@uri_or_path)
@conn = Connection.new(uri_or_path)
listen
end
def listen : Nil
raise "Socket is closed" if @conn.closed?
LOGGER.debug("SigHelper: Multiplexor listening")
spawn do
loop do
begin
receive_data
rescue ex
LOGGER.info("SigHelper: Connection to helper died with '#{ex.message}' trying to reconnect...")
# We close the socket because for some reason is not closed.
@conn.close
loop do
begin
@conn = Connection.new(@uri_or_path)
LOGGER.info("SigHelper: Reconnected to SigHelper!")
rescue ex
LOGGER.debug("SigHelper: Reconnection to helper unsuccessful with error '#{ex.message}'. Retrying")
sleep 500.milliseconds
next
end
break if !@conn.closed?
end
end
Fiber.yield
end
end
end
def send(request : Request)
transaction = Transaction.new
transaction_id = @prng.rand(TransactionID)
# Add transaction to queue
@mutex.synchronize do
# On a 32-bits random integer, this should never happen. Though, just in case, ...
if @queue[transaction_id]?
raise Exception.new("SigHelper: Duplicate transaction ID! You got a shiny pokemon!")
end
@queue[transaction_id] = transaction
end
write_packet(transaction_id, request)
return transaction.channel
end
def receive_data
transaction_id, slice = read_packet
@mutex.synchronize do
if transaction = @queue.delete(transaction_id)
# Remove transaction from queue and send data to the channel
transaction.channel.send(slice)
LOGGER.trace("SigHelper: Transaction unqueued and data sent to channel")
else
raise Exception.new("SigHelper: Received transaction was not in queue")
end
end
end
# Read a single packet from the socket
private def read_packet : {TransactionID, Bytes}
# Header
transaction_id = @conn.read_bytes(UInt32, NetworkEndian)
length = @conn.read_bytes(UInt32, NetworkEndian)
LOGGER.trace("SigHelper: Recv transaction 0x#{transaction_id.to_s(base: 16)} / length #{length}")
if length > 67_000
raise Exception.new("SigHelper: Packet longer than expected (#{length})")
end
# Payload
slice = Bytes.new(length)
@conn.read(slice) if length > 0
LOGGER.trace("SigHelper: payload = #{slice}")
LOGGER.trace("SigHelper: Recv transaction 0x#{transaction_id.to_s(base: 16)} - Done")
return transaction_id, slice
end
# Write a single packet to the socket
private def write_packet(transaction_id : TransactionID, request : Request)
LOGGER.trace("SigHelper: Send transaction 0x#{transaction_id.to_s(base: 16)} / opcode #{request.opcode}")
io = IO::Memory.new(1024)
io.write_bytes(request.opcode.to_u8, NetworkEndian)
io.write_bytes(transaction_id, NetworkEndian)
if payload = request.payload
payload.to_io(io)
end
@conn.send(io)
@conn.flush
LOGGER.trace("SigHelper: Send transaction 0x#{transaction_id.to_s(base: 16)} - Done")
end
end
class Connection
@socket : UNIXSocket | TCPSocket
{% if flag?(:advanced_debug) %}
@io : IO::Hexdump
{% end %}
def initialize(host_or_path : String)
case host_or_path
when .starts_with?('/')
# Make sure that the file exists
if File.exists?(host_or_path)
@socket = UNIXSocket.new(host_or_path)
else
raise Exception.new("SigHelper: '#{host_or_path}' no such file")
end
when .starts_with?("tcp://")
uri = URI.parse(host_or_path)
@socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!)
else
uri = URI.parse("tcp://#{host_or_path}")
@socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!)
end
LOGGER.info("SigHelper: Using helper at '#{host_or_path}'")
{% if flag?(:advanced_debug) %}
@io = IO::Hexdump.new(@socket, output: STDERR, read: true, write: true)
{% end %}
@socket.sync = false
@socket.blocking = false
end
def closed? : Bool
return @socket.closed?
end
def close : Nil
@socket.close if !@socket.closed?
end
def flush(*args, **options)
@socket.flush(*args, **options)
end
def send(*args, **options)
@socket.send(*args, **options)
end
# Wrap IO functions, with added debug tooling if needed
{% for function in %w(read read_bytes write write_bytes) %}
def {{function.id}}(*args, **options)
{% if flag?(:advanced_debug) %}
@io.{{function.id}}(*args, **options)
{% else %}
@socket.{{function.id}}(*args, **options)
{% end %}
end
{% end %}
end
end

View File

@@ -1,53 +0,0 @@
require "http/params"
require "./sig_helper"
class Invidious::DecryptFunction
@last_update : Time = Time.utc - 42.days
def initialize(uri_or_path)
@client = SigHelper::Client.new(uri_or_path)
self.check_update
end
def check_update
# If we have updated in the last 5 minutes, do nothing
return if (Time.utc - @last_update) < 5.minutes
# Get the amount of time elapsed since when the player was updated, in the
# event where multiple invidious processes are run in parallel.
update_time_elapsed = (@client.get_player_timestamp || 301).seconds
if update_time_elapsed > 5.minutes
LOGGER.debug("Signature: Player might be outdated, updating")
@client.force_update
@last_update = Time.utc
end
end
def decrypt_nsig(n : String) : String?
self.check_update
return @client.decrypt_n_param(n)
rescue ex
LOGGER.debug(ex.message || "Signature: Unknown error")
LOGGER.trace(ex.inspect_with_backtrace)
return nil
end
def decrypt_signature(str : String) : String?
self.check_update
return @client.decrypt_sig(str)
rescue ex
LOGGER.debug(ex.message || "Signature: Unknown error")
LOGGER.trace(ex.inspect_with_backtrace)
return nil
end
def get_sts : UInt64?
self.check_update
return @client.get_signature_timestamp
rescue ex
LOGGER.debug(ex.message || "Signature: Unknown error")
LOGGER.trace(ex.inspect_with_backtrace)
return nil
end
end

View File

@@ -265,11 +265,11 @@ module Invidious::Routes::Channels
id = env.params.url["id"] id = env.params.url["id"]
ucid = env.params.query["ucid"]? ucid = env.params.query["ucid"]?
prefs = env.get("preferences").as(Preferences) preferences = env.get("preferences").as(Preferences)
locale = prefs.locale locale = preferences.locale
thin_mode = env.params.query["thin_mode"]? || prefs.thin_mode thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode
thin_mode = thin_mode == "true" thin_mode = thin_mode == "true"
nojs = env.params.query["nojs"]? nojs = env.params.query["nojs"]?

View File

@@ -33,7 +33,8 @@ module Invidious::Routes::Embed
end end
def self.show(env) def self.show(env)
locale = env.get("preferences").as(Preferences).locale preferences = env.get("preferences").as(Preferences)
locale = preferences.locale
id = env.params.url["id"] id = env.params.url["id"]
plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "")
@@ -45,8 +46,6 @@ module Invidious::Routes::Embed
env.params.query.delete("playlist") env.params.query.delete("playlist")
end end
preferences = env.get("preferences").as(Preferences)
if id.includes?("%20") || id.includes?("+") || env.params.query.to_s.includes?("%20") || env.params.query.to_s.includes?("+") if id.includes?("%20") || id.includes?("+") || env.params.query.to_s.includes?("%20") || env.params.query.to_s.includes?("+")
id = env.params.url["id"].gsub("%20", "").delete("+") id = env.params.url["id"].gsub("%20", "").delete("+")

View File

@@ -225,10 +225,10 @@ module Invidious::Routes::Playlists
end end
def self.add_playlist_items_page(env) def self.add_playlist_items_page(env)
prefs = env.get("preferences").as(Preferences) preferences = env.get("preferences").as(Preferences)
locale = prefs.locale locale = preferences.locale
region = env.params.query["region"]? || prefs.region region = env.params.query["region"]? || preferences.region
user = env.get? "user" user = env.get? "user"
sid = env.get? "sid" sid = env.get? "sid"

View File

@@ -2,13 +2,12 @@
module Invidious::Routes::PreferencesRoute module Invidious::Routes::PreferencesRoute
def self.show(env) def self.show(env)
locale = env.get("preferences").as(Preferences).locale preferences = env.get("preferences").as(Preferences)
locale = preferences.locale
referer = get_referer(env) referer = get_referer(env)
preferences = env.get("preferences").as(Preferences) templated "user/preferences"
templated "user/preferences", buffer_footer: true
end end
def self.update(env) def self.update(env)

View File

@@ -37,10 +37,10 @@ module Invidious::Routes::Search
end end
def self.search(env) def self.search(env)
prefs = env.get("preferences").as(Preferences) preferences = env.get("preferences").as(Preferences)
locale = prefs.locale locale = preferences.locale
region = env.params.query["region"]? || prefs.region region = env.params.query["region"]? || preferences.region
query = Invidious::Search::Query.new(env.params.query, :regular, region) query = Invidious::Search::Query.new(env.params.query, :regular, region)

View File

@@ -2,7 +2,8 @@
module Invidious::Routes::Watch module Invidious::Routes::Watch
def self.handle(env) def self.handle(env)
locale = env.get("preferences").as(Preferences).locale preferences = env.get("preferences").as(Preferences)
locale = preferences.locale
region = env.params.query["region"]? region = env.params.query["region"]?
if env.params.query.to_s.includes?("%20") || env.params.query.to_s.includes?("+") if env.params.query.to_s.includes?("%20") || env.params.query.to_s.includes?("+")
@@ -46,8 +47,6 @@ module Invidious::Routes::Watch
# Equal to a `caption.name` when set # Equal to a `caption.name` when set
selected_transcript = env.params.query["use_this_transcript"]? selected_transcript = env.params.query["use_this_transcript"]?
preferences = env.get("preferences").as(Preferences)
user = env.get?("user").try &.as(User) user = env.get?("user").try &.as(User)
if user if user
subscriptions = user.subscriptions subscriptions = user.subscriptions

View File

@@ -4,20 +4,21 @@ def fetch_trending(trending_type, region, locale)
plid = nil plid = nil
browse_id = "FEtrending" browse_id = ""
case trending_type.try &.downcase case trending_type.try &.downcase
when "music"
params = "4gINGgt5dG1hX2NoYXJ0cw%3D%3D"
when "gaming" when "gaming"
params = "4gIcGhpnYW1pbmdfY29ycHVzX21vc3RfcG9wdWxhcg%3D%3D" browse_id = "UCOpNcN46UbXVtpKMrmU4Abg"
when "movies" params = "Egh0cmVuZGluZw%3D%3D"
params = "4gIKGgh0cmFpbGVycw%3D%3D"
when "livestreams" when "livestreams"
browse_id = "UC4R8DWoMoI7CAwX8_LjQHig" browse_id = "UC4R8DWoMoI7CAwX8_LjQHig"
params = "EgdsaXZldGFikgEDCKEK" params = "EgdsaXZldGFikgEDCKEK"
else # Default else
params = "" # Livestreams is the default one as Youtube removed
# the aggregated trending page
# https://github.com/iv-org/invidious/issues/5397#issuecomment-3218928458
browse_id = "UC4R8DWoMoI7CAwX8_LjQHig"
params = "EgdsaXZldGFikgEDCKEK"
end end
client_config = YoutubeAPI::ClientConfig.new(region: region) client_config = YoutubeAPI::ClientConfig.new(region: region)

View File

@@ -335,6 +335,14 @@ end
def fetch_video(id, region, env) def fetch_video(id, region, env)
info = extract_video_info(video_id: id, env: env) info = extract_video_info(video_id: id, env: env)
if info.nil?
raise InfoException.new("Invidious companion is not available. \
Video playback cannot continue. \
If you are the administrator of this instance, install Invidious companion \
following the installation instructions \
<a href=\"https://docs.invidious.io/installation/\">https://docs.invidious.io/installation/</a>")
end
if reason = info["reason"]? if reason = info["reason"]?
if reason == "Video unavailable" if reason == "Video unavailable"
raise NotFoundException.new(reason.as_s || "") raise NotFoundException.new(reason.as_s || "")

View File

@@ -53,11 +53,12 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
end end
def extract_video_info(video_id : String, env : HTTP::Server::Context | Nil = nil) def extract_video_info(video_id : String, env : HTTP::Server::Context | Nil = nil)
# Init client config for the API
client_config = YoutubeAPI::ClientConfig.new
# Fetch data from the player endpoint # Fetch data from the player endpoint
player_response = YoutubeAPI.player(env: env, video_id: video_id, params: "2AMB", client_config: client_config) player_response = YoutubeAPI.player(video_id: video_id, env: env)
if player_response.nil?
return nil
end
playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s
@@ -105,37 +106,6 @@ def extract_video_info(video_id : String, env : HTTP::Server::Context | Nil = ni
params = parse_video_info(video_id, player_response) params = parse_video_info(video_id, player_response)
params["reason"] = JSON::Any.new(reason) if reason params["reason"] = JSON::Any.new(reason) if reason
if !CONFIG.invidious_companion.present?
if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil?
LOGGER.warn("Missing URLs for adaptive formats, falling back to other YT clients.")
players_fallback = {YoutubeAPI::ClientType::TvSimply, YoutubeAPI::ClientType::WebMobile}
players_fallback.each do |player_fallback|
client_config.client_type = player_fallback
next if !(player_fallback_response = try_fetch_streaming_data(video_id, client_config, env))
adaptive_formats = player_fallback_response.dig?("streamingData", "adaptiveFormats")
if adaptive_formats && (adaptive_formats.dig?(0, "url") || adaptive_formats.dig?(0, "signatureCipher"))
streaming_data = player_response["streamingData"].as_h
streaming_data["adaptiveFormats"] = adaptive_formats
player_response["streamingData"] = JSON::Any.new(streaming_data)
break
end
rescue InfoException
next LOGGER.warn("Failed to fetch streams with #{player_fallback}")
end
end
# Seems like video page can still render even without playable streams.
# its better than nothing.
#
# # Were we able to find playable video streams?
# if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil?
# # No :(
# end
end
{"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f| {"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f|
params[f] = player_response[f] if player_response[f]? params[f] = player_response[f] if player_response[f]?
end end
@@ -163,7 +133,7 @@ end
def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig, env : HTTP::Server::Context | Nil = nil) : Hash(String, JSON::Any)? def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig, env : HTTP::Server::Context | Nil = nil) : Hash(String, JSON::Any)?
LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.") LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.")
response = YoutubeAPI.player(video_id: id, params: "2AMB", client_config: client_config, env: env) response = YoutubeAPI.player(video_id: id, env: env)
playability_status = response["playabilityStatus"]["status"] playability_status = response["playabilityStatus"]["status"]
LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.") LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.")
@@ -486,26 +456,15 @@ end
private def convert_url(fmt) private def convert_url(fmt)
if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) } if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) }
sp = cfr["sp"]
url = URI.parse(cfr["url"]) url = URI.parse(cfr["url"])
params = url.query_params params = url.query_params
LOGGER.debug("convert_url: Decoding '#{cfr}'") LOGGER.debug("convert_url: Decoding '#{cfr}'")
unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"])
params[sp] = unsig if unsig
else else
url = URI.parse(fmt["url"].as_s) url = URI.parse(fmt["url"].as_s)
params = url.query_params params = url.query_params
end end
n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"])
params["n"] = n if n
if token = CONFIG.po_token
params["pot"] = token
end
url.query_params = params url.query_params = params
LOGGER.trace("convert_url: new url is '#{url}'") LOGGER.trace("convert_url: new url is '#{url}'")

View File

@@ -12,7 +12,7 @@
<div class="pure-u-1-2 flex-left flexible"> <div class="pure-u-1-2 flex-left flexible">
<div class="channel-profile"> <div class="channel-profile">
<img src="/ggpht<%= channel_profile_pic %>" alt="" /> <img src="/ggpht<%= channel_profile_pic %>" alt="" />
<span><%= author %></span><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %> <span class="channel-name"><%= author %></span><% if !channel.verified.nil? && channel.verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="<%= env.get("preferences").as(Preferences).locale %>"> <html lang="<%= preferences.locale %>">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">

View File

@@ -21,9 +21,7 @@
</div> </div>
<div class="pure-u-1-3"> <div class="pure-u-1-3">
<div class="pure-g" style="text-align:right"> <div class="pure-g" style="text-align:right">
<% TrendingTypes.each do |option| <% {"Livestreams", "Gaming"}.each do |option| %>
option = option.to_s
%>
<div class="pure-u-1 pure-md-1-3"> <div class="pure-u-1 pure-md-1-3">
<% if trending_type == option %> <% if trending_type == option %>
<b><%= translate(locale, option) %></b> <b><%= translate(locale, option) %></b>

View File

@@ -38,7 +38,7 @@
"params" => { "params" => {
"comments": ["youtube"] "comments": ["youtube"]
}, },
"preferences" => prefs, "preferences" => preferences,
"base_url" => "/api/v1/post/#{URI.encode_www_form(id)}/comments", "base_url" => "/api/v1/post/#{URI.encode_www_form(id)}/comments",
"ucid" => ucid "ucid" => ucid
}.to_pretty_json }.to_pretty_json

View File

@@ -1,6 +1,7 @@
<% <%
locale = env.get("preferences").as(Preferences).locale preferences = env.get("preferences").as(Preferences)
dark_mode = env.get("preferences").as(Preferences).dark_mode locale = preferences.locale
dark_mode = preferences.dark_mode
%> %>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="<%= locale %>"> <html lang="<%= locale %>">

View File

@@ -268,7 +268,7 @@ we're going to need to do it here in order to allow for translations.
<% if !video.author_thumbnail.empty? %> <% if !video.author_thumbnail.empty? %>
<img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>" alt="" /> <img src="/ggpht<%= URI.parse(video.author_thumbnail).request_target %>" alt="" />
<% end %> <% end %>
<span id="channel-name"><%= author %><% if !video.author_verified.nil? && video.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></span> <span id="channel-name" class="channel-name"><%= author %><% if !video.author_verified.nil? && video.author_verified %>&nbsp;<i class="icon ion ion-md-checkmark-circle"></i><% end %></span>
</div> </div>
</a> </a>
</div> </div>

View File

@@ -442,6 +442,7 @@ private module Parsers
if content_container = special_category_container["horizontalListRenderer"]? if content_container = special_category_container["horizontalListRenderer"]?
elsif content_container = special_category_container["expandedShelfContentsRenderer"]? elsif content_container = special_category_container["expandedShelfContentsRenderer"]?
elsif content_container = special_category_container["verticalListRenderer"]? elsif content_container = special_category_container["verticalListRenderer"]?
elsif content_container = special_category_container["gridRenderer"]?
else else
# Anything else, such as `horizontalMovieListRenderer` is currently unsupported. # Anything else, such as `horizontalMovieListRenderer` is currently unsupported.
return return

View File

@@ -199,10 +199,6 @@ module YoutubeAPI
# conf_1 = ClientConfig.new(region: "NO") # conf_1 = ClientConfig.new(region: "NO")
# YoutubeAPI::search("Kollektivet", params: "", client_config: conf_1) # YoutubeAPI::search("Kollektivet", params: "", client_config: conf_1)
# #
# # Use the Android client to request video streams URLs
# conf_2 = ClientConfig.new(client_type: ClientType::Android)
# YoutubeAPI::player(video_id: "dQw4w9WgXcQ", client_config: conf_2)
#
# #
struct ClientConfig struct ClientConfig
# Type of client to emulate. # Type of client to emulate.
@@ -335,10 +331,6 @@ module YoutubeAPI
client_context["client"]["platform"] = platform client_context["client"]["platform"] = platform
end end
if CONFIG.visitor_data.is_a?(String)
client_context["client"]["visitorData"] = CONFIG.visitor_data.as(String)
end
return client_context return client_context
end end
@@ -455,62 +447,23 @@ module YoutubeAPI
end end
#################################################################### ####################################################################
# player(video_id, params, client_config?) # player(video_id)
# #
# Requests the youtubei/v1/player endpoint with the required headers # Requests the youtubei/v1/player Invidious Companion endpoint with
# and POST data in order to get a JSON reply. # the requested video ID.
# #
# The requested data is a video ID (`v=` parameter), with some # The requested data is a video ID (`v=` parameter).
# additional parameters, formatted as a base64 string.
# #
# An optional ClientConfig parameter can be passed, too (see def player(video_id : String, env : HTTP::Server::Context | Nil)
# `struct ClientConfig` above for more details). # JSON Request data, required by Invidious Companion
#
def player(
video_id : String,
*, # Force the following parameters to be passed by name
params : String,
client_config : ClientConfig | Nil = nil,
env : HTTP::Server::Context | Nil = nil,
)
# Playback context, separate because it can be different between clients
playback_ctx = {
"html5Preference" => "HTML5_PREF_WANTS",
"referer" => "https://www.youtube.com/watch?v=#{video_id}",
} of String => String | Int64
if {"WEB", "TVHTML5"}.any? { |s| client_config.name.starts_with? s }
if sts = DECRYPT_FUNCTION.try &.get_sts
playback_ctx["signatureTimestamp"] = sts.to_i64
end
end
# JSON Request data, required by the API
data = { data = {
"contentCheckOk" => true, "videoId" => video_id,
"videoId" => video_id,
"context" => self.make_context(client_config, video_id),
"racyCheckOk" => true,
"user" => {
"lockedSafetyMode" => false,
},
"playbackContext" => {
"contentPlaybackContext" => playback_ctx,
},
"serviceIntegrityDimensions" => {
"poToken" => CONFIG.po_token,
},
} }
# Append the additional parameters if those were provided
if params != ""
data["params"] = params
end
if CONFIG.invidious_companion.present? if CONFIG.invidious_companion.present?
return self._post_invidious_companion("/youtubei/v1/player", data, env) return self._post_invidious_companion("/youtubei/v1/player", data, env)
else else
return self._post_json("/youtubei/v1/player", data, client_config) return nil
end end
end end
@@ -636,10 +589,6 @@ module YoutubeAPI
headers["User-Agent"] = user_agent headers["User-Agent"] = user_agent
end end
if CONFIG.visitor_data.is_a?(String)
headers["X-Goog-Visitor-Id"] = CONFIG.visitor_data.as(String)
end
# Logging # Logging
LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"") LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"")
LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config}") LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config}")