Merge remote-tracking branch 'upstream/master'
Build image / build (push) Successful in 6m31s

This commit is contained in:
2026-06-11 00:02:57 +02:00
36 changed files with 935 additions and 680 deletions
@@ -86,7 +86,7 @@ jobs:
# https://github.com/marketplace/actions/docker-manifest-create-action
- name: Create and push manifest
uses: int128/docker-manifest-create-action@v2.21.0
uses: int128/docker-manifest-create-action@v2.22.0
with:
push: true
tags: quay.io/invidious/invidious:master
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
# https://github.com/marketplace/actions/docker-manifest-create-action
- name: Create and push manifest
uses: int128/docker-manifest-create-action@v2.21.0
uses: int128/docker-manifest-create-action@v2.22.0
with:
push: true
tags: quay.io/invidious/invidious:latest
+7
View File
@@ -903,3 +903,10 @@ h1, h2, h3, h4, h5, p,
padding: 20px;
background: rgba(0, 0, 0, 0.12345);
}
.preference-description {
width: 250px;
padding-left: 10px;
display: inline-block;
vertical-align: top;
}
+1 -1
View File
@@ -133,7 +133,7 @@ var timeupdate_last_ts = 5;
player.on('timeupdate', function () {
// Only update once every second
let current_ts = Math.floor(player.currentTime());
if (current_ts > timeupdate_last_ts) timeupdate_last_ts = current_ts;
if (current_ts != timeupdate_last_ts) timeupdate_last_ts = current_ts;
else return;
// YouTube links
+20
View File
@@ -151,6 +151,26 @@ db:
##
domain:
##
## List of alternative domains where the invidious instance is being served.
## This needs to be set in order to be able to login and update user preferences
## when using a domain that is not the same as the `domain` configuration,
## like a .`onion` address, `.i2p` address, `.b32.i2p` address, etc.
##
## It will detect the alternative domain trough the `X-Forwarded-Host` header.
## https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-Host
##
## Accepted values: a list of fully qualified domain names (FQDN)
## Default: <none>
##
## Example:
## alternative_domains:
## - invidious.example.com
## - inv.example.com
## - videos.example.com
##
alternative_domains:
##
## Tell Invidious that it is behind a proxy that provides only
## HTTPS, so all links must use the https:// scheme. This
+1 -1
View File
@@ -2,7 +2,7 @@
ARG OPENSSL_VERSION='3.6.2'
ARG OPENSSL_SHA256='aaf51a1fe064384f811daeaeb4ec4dce7340ec8bd893027eee676af31e83a04f'
FROM crystallang/crystal:1.20.2-alpine AS dependabot-crystal
FROM 84codes/crystal:1.20.2-alpine AS dependabot-crystal
# We compile openssl ourselves due to a memory leak in how crystal interacts
# with openssl
+2
View File
@@ -124,6 +124,8 @@
"preferences_sort_label": "Sort videos by: ",
"preferences_default_playlist": "Default playlist: ",
"preferences_default_playlist_none": "No default playlist set",
"preferences_search_privacy_label": "Search privacy: ",
"preferences_search_privacy_description": "Enabling this preference will prevent your search queries from being saved in your browser history.",
"published": "published",
"published - reverse": "published - reverse",
"alphabetically": "alphabetically",
@@ -7,7 +7,7 @@ Spectator.describe "parse_video_info" do
_next = load_mock("video/regular_mrbeast.next")
raw_data = _player.merge!(_next)
info = parse_video_info("2isYuQZMbdU", raw_data)
info = Invidious::Videos::Parser.parse_video_info("2isYuQZMbdU", raw_data)
# Some basic verifications
expect(typeof(info)).to eq(Hash(String, JSON::Any))
@@ -88,7 +88,7 @@ Spectator.describe "parse_video_info" do
_next = load_mock("video/regular_no-description.next")
raw_data = _player.merge!(_next)
info = parse_video_info("iuevw6218F0", raw_data)
info = Invidious::Videos::Parser.parse_video_info("iuevw6218F0", raw_data)
# Some basic verifications
expect(typeof(info)).to eq(Hash(String, JSON::Any))
@@ -7,7 +7,7 @@ Spectator.describe "parse_video_info" do
_next = load_mock("video/scheduled_live_PBD-Podcast.next")
raw_data = _player.merge!(_next)
info = parse_video_info("N-yVic7BbY0", raw_data)
info = Invidious::Videos::Parser.parse_video_info("N-yVic7BbY0", raw_data)
# Some basic verifications
expect(typeof(info)).to eq(Hash(String, JSON::Any))
+7 -2
View File
@@ -83,11 +83,16 @@ def get_about_info(ucid, locale) : AboutChannel
author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s
author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s
author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s
author_verified = has_verified_badge?(initdata.dig?("header", "c4TabbedHeaderRenderer", "badges"))
author_badge = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "title", "dynamicTextViewModel", "text", "attachmentRuns", 0, "element", "type", "imageType", "image", "sources", 0, "clientResource", "imageName")
.try &.as_s
# CHECK_CIRCLE_FILLED is used for normal channels and AUDIO_BADGE if used For
# music/artist channels
# TODO: Maybe separate verified author from verified artist?
author_verified = author_badge.try { |badge| badge == "CHECK_CIRCLE_FILLED" || badge == "AUDIO_BADGE" } || false
ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s
# Raises a KeyError on failure.
# TODO: Check if `c4TabbedHeaderRenderer` still exists on some channels.
banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]?
banners ||= initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "banner", "imageBannerViewModel", "image", "sources")
banner = banners.try &.[-1]?.try &.["url"].as_s?
+3
View File
@@ -69,6 +69,7 @@ struct ConfigPreferences
property save_player_pos : Bool = false
@[YAML::Field(ignore: true)]
property default_playlist : String? = nil
property search_privacy : Bool = false
def to_tuple
{% begin %}
@@ -135,6 +136,8 @@ class Config
property hmac_key : String = ""
# Domain to be used for links to resources on the site where an absolute URL is required
property domain : String?
# Additional domain list that is going to be used for cookie domain validation
property alternative_domains : Array(String) = [] of String
# Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
property use_pubsub_feeds : Bool | Int32 = false
property popular_enabled : Bool = true
+1 -1
View File
@@ -201,7 +201,7 @@ def error_redirect_helper(env : HTTP::Server::Context)
<a href="/redirect?referer=#{env.get("current_page")}">#{switch_instance}</a>
</li>
<li>
<a rel="noreferrer noopener" href="https://youtube.com#{env.request.resource}">#{go_to_youtube}</a>
<a rel="noreferrer noopener" href="https://www.youtube.com#{env.request.resource}">#{go_to_youtube}</a>
</li>
</ul>
END_HTML
+1 -1
View File
@@ -410,7 +410,7 @@ module Invidious::Routes::API::V1::Videos
clip_title = nil
if params = response.dig?("endpoint", "watchEndpoint", "params").try &.as_s
start_time, end_time, clip_title = parse_clip_parameters(params)
start_time, end_time, clip_title = Invidious::Videos::Clip.parse_clip_parameters(params)
end
begin
+2
View File
@@ -32,6 +32,8 @@ module Invidious::Routes::BeforeAll
env.response.headers["X-XSS-Protection"] = "1; mode=block"
env.response.headers["X-Content-Type-Options"] = "nosniff"
env.set "header_x-forwarded-host", env.request.headers["X-Forwarded-Host"]?
# Only allow the pages at /embed/* to be embedded
if env.request.resource.starts_with?("/embed")
frame_ancestors = "'self' file: http: https:"
+1 -1
View File
@@ -351,7 +351,7 @@ module Invidious::Routes::Channels
invidious_url_params.delete_all("user")
begin
resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}")
resolved_url = YoutubeAPI.resolve_url("https://www.youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}")
ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"]
rescue ex : InfoException | KeyError
return error_template(404, I18n.translate(locale, "This channel does not exist."))
+1 -1
View File
@@ -122,7 +122,7 @@ module Invidious::Routes::Embed
else nil # Continue
end
params = process_video_params(env.params.query, preferences)
params = Invidious::Videos.process_video_params(env.params.query, preferences)
user = env.get?("user").try &.as(User)
if user
+1 -1
View File
@@ -8,7 +8,7 @@ module Invidious::Routes::ErrorRoutes
if md = env.request.path.match(/^\/(?<id>([a-zA-Z0-9_-]{11})|(\w+))$/)
item = md["id"]
# Check if item is branding URL e.g. https://youtube.com/gaming
# Check if item is branding URL e.g. https://www.youtube.com/gaming
response = YT_POOL.client &.get("/#{item}")
if response.status_code == 301
+1 -1
View File
@@ -320,7 +320,7 @@ module Invidious::Routes::Feeds
case attribute.name
when "url", "href"
request_target = URI.parse(node[attribute.name]).request_target
query_string_opt = request_target.starts_with?("/watch?v=") ? "&#{params}" : ""
query_string_opt = request_target.starts_with?("/watch?v=") ? ("&#{params}" if !params.empty?) : ""
node[attribute.name] = "#{HOST_URL}#{request_target}#{query_string_opt}"
else nil # Skip
end
+12 -2
View File
@@ -51,7 +51,7 @@ module Invidious::Routes::Images
end
# ??? maybe also for storyboards?
def self.s_p_image(env)
def self.s_p_image(env, authority = "i9")
id = env.params.url["id"]
name = env.params.url["name"]
url = env.request.resource
@@ -65,13 +65,23 @@ module Invidious::Routes::Images
end
begin
get_ytimg_pool("i9").client &.get(url, headers) do |resp|
get_ytimg_pool(authority).client &.get(url, headers) do |resp|
return self.proxy_image(env, resp)
end
rescue ex
end
end
# Both pl_c and tvfilm_banner use the same logic used in s_p_image(env)
# just with a different authority ("i").
def self.pl_c_image(env)
self.s_p_image(env, "i")
end
def self.tvfilm_banner_image(env)
self.s_p_image(env, "i")
end
def self.yts_image(env)
headers = HTTP::Headers.new
REQUEST_HEADERS_WHITELIST.each do |header|
+9
View File
@@ -35,6 +35,7 @@ module Invidious::Routes::Login
def self.login(env)
locale = env.get("preferences").as(Preferences).locale
host = env.get("header_x-forwarded-host")
referer = get_referer(env, "/feed/subscriptions")
@@ -82,7 +83,11 @@ module Invidious::Routes::Login
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
Invidious::Database::SessionIDs.insert(sid, email)
if alt = CONFIG.alternative_domains.index(host)
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
else
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
end
else
return error_template(401, "Wrong username or password")
end
@@ -148,7 +153,11 @@ module Invidious::Routes::Login
view_name = "subscriptions_#{sha256(user.email)}"
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
if alt = CONFIG.alternative_domains.index(host)
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], sid)
else
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
end
if env.request.cookies["PREFS"]?
user.preferences = env.get("preferences").as(Preferences)
+15
View File
@@ -145,6 +145,10 @@ module Invidious::Routes::PreferencesRoute
default_playlist = env.params.body["default_playlist"]?.try &.as(String)
search_privacy = env.params.body["search_privacy"]?.try &.as(String)
search_privacy ||= "off"
search_privacy = search_privacy == "on"
# Convert to JSON and back again to take advantage of converters used for compatibility
preferences = Preferences.from_json({
annotations: annotations,
@@ -182,6 +186,7 @@ module Invidious::Routes::PreferencesRoute
show_nick: show_nick,
save_player_pos: save_player_pos,
default_playlist: default_playlist,
search_privacy: search_privacy,
}.to_json)
if user = env.get? "user"
@@ -225,9 +230,14 @@ module Invidious::Routes::PreferencesRoute
File.write("config/config.yml", CONFIG.to_yaml)
end
else
host = env.get("header_x-forwarded-host")
if alt = CONFIG.alternative_domains.index(host)
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences)
else
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
end
end
env.redirect referer
end
@@ -261,8 +271,13 @@ module Invidious::Routes::PreferencesRoute
preferences.dark_mode = "dark"
end
host = env.get("header_x-forwarded-host")
if alt = CONFIG.alternative_domains.index(host)
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.alternative_domains[alt], preferences)
else
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
end
end
if redirect
env.redirect referer
+9 -2
View File
@@ -40,9 +40,16 @@ module Invidious::Routes::Search
preferences = env.get("preferences").as(Preferences)
locale = preferences.locale
region = env.params.query["region"]? || preferences.region
uri_params = URI::Params.new
if env.request.method == "GET"
uri_params = env.params.query
else
uri_params = env.params.body
end
query = Invidious::Search::Query.new(env.params.query, :regular, region)
region = uri_params["region"]? || preferences.region
query = Invidious::Search::Query.new(uri_params, :regular, region)
if query.empty?
# Display the full page search box implemented in #1977
+5 -2
View File
@@ -47,7 +47,7 @@ module Invidious::Routes::Watch
end
subscriptions ||= [] of String
params = process_video_params(env.params.query, preferences)
params = Invidious::Videos.process_video_params(env.params.query, preferences)
env.params.query.delete_all("listen")
begin
@@ -129,6 +129,8 @@ module Invidious::Routes::Watch
video_streams = video.video_streams
audio_streams = video.audio_streams
# Videos that are a premiere do not have audio streams.
if video.premiere_timestamp.nil?
# Older videos may not have audio sources available.
# We redirect here so they're not unplayable
if audio_streams.empty? && !video.live_now
@@ -142,6 +144,7 @@ module Invidious::Routes::Watch
return env.redirect "/watch?#{env.params.query}"
end
end
end
captions = video.captions
@@ -273,7 +276,7 @@ module Invidious::Routes::Watch
if video_id = response.dig?("endpoint", "watchEndpoint", "videoId")
if params = response.dig?("endpoint", "watchEndpoint", "params").try &.as_s
start_time, end_time, _ = parse_clip_parameters(params)
start_time, end_time, _ = Invidious::Videos::Clip.parse_clip_parameters(params)
env.params.query["start"] = start_time.to_s if start_time != nil
env.params.query["end"] = end_time.to_s if end_time != nil
end
+3
View File
@@ -186,6 +186,7 @@ module Invidious::Routing
get "/opensearch.xml", Routes::Search, :opensearch
get "/results", Routes::Search, :results
get "/search", Routes::Search, :search
post "/search", Routes::Search, :search
get "/hashtag/:hashtag", Routes::Search, :hashtag
end
@@ -223,6 +224,8 @@ module Invidious::Routing
get "/s_p/:id/:name", Routes::Images, :s_p_image
get "/yts/img/:name", Routes::Images, :yts_image
get "/vi/:id/:name", Routes::Images, :thumbnails
get "/pl_c/:id/:name", Routes::Images, :pl_c_image
get "/tvfilm_banner/:id/:name", Routes::Images, :tvfilm_banner_image
end
def register_companion_routes
+17 -3
View File
@@ -6,17 +6,24 @@ struct Invidious::User
# Note: we use ternary operator because the two variables
# used in here are not booleans.
SECURE = (Kemal.config.ssl || CONFIG.https_only) ? true : false
@@secure = (Kemal.config.ssl || CONFIG.https_only) ? true : false
# Session ID (SID) cookie
# Parameter "domain" comes from the global config
def sid(domain : String?, sid) : HTTP::Cookie
# Not secure if it's being accessed from I2P
# Browsers expect the domain to include https. On I2P there is no HTTPS
# Tor browser works fine with secure being true
if (domain.try &.split(".").last == "i2p") && @@secure
@@secure = false
end
return HTTP::Cookie.new(
name: "SID",
domain: domain,
value: sid,
expires: Time.utc + 2.years,
secure: SECURE,
secure: @@secure,
http_only: true,
samesite: HTTP::Cookie::SameSite::Lax
)
@@ -25,12 +32,19 @@ struct Invidious::User
# Preferences (PREFS) cookie
# Parameter "domain" comes from the global config
def prefs(domain : String?, preferences : Preferences) : HTTP::Cookie
# Not secure if it's being accessed from I2P
# Browsers expect the domain to include https. On I2P there is no HTTPS
# Tor browser works fine with secure being true
if (domain.try &.split(".").last == "i2p") && @@secure
@@secure = false
end
return HTTP::Cookie.new(
name: "PREFS",
domain: domain,
value: URI.encode_www_form(preferences.to_json),
expires: Time.utc + 2.years,
secure: SECURE,
secure: @@secure,
http_only: false,
samesite: HTTP::Cookie::SameSite::Lax
)
+1
View File
@@ -57,6 +57,7 @@ struct Preferences
property volume : Int32 = CONFIG.default_user_preferences.volume
property save_player_pos : Bool = CONFIG.default_user_preferences.save_player_pos
property default_playlist : String? = nil
property search_privacy : Bool = CONFIG.default_user_preferences.search_privacy
module BoolToString
def self.to_json(value : String, json : JSON::Builder)
+4 -3
View File
@@ -81,10 +81,11 @@ struct Video
end
def premiere_timestamp : Time?
info
.dig?("microformat", "playerMicroformatRenderer", "liveBroadcastDetails", "startTimestamp")
if self.video_type == VideoType::Scheduled
return info["published"]?
.try { |t| Time.parse_rfc3339(t.as_s) }
end
end
def related_videos
info["relatedVideos"]?.try &.as_a.map { |h| h.as_h.transform_values &.as_s } || [] of Hash(String, String)
@@ -324,7 +325,7 @@ rescue DB::Error
end
def fetch_video(id, region)
info = extract_video_info(video_id: id)
info = Invidious::Videos::Parser.extract_video_info(video_id: id)
if info.nil?
raise InfoException.new("Invidious companion is not available. \
+6 -2
View File
@@ -1,7 +1,10 @@
require "json"
# returns start_time, end_time and clip_title
def parse_clip_parameters(params) : {Float64?, Float64?, String?}
module Invidious::Videos::Clip
extend self
# returns start_time, end_time and clip_title
def parse_clip_parameters(params) : {Float64?, Float64?, String?}
decoded_protobuf = params.try { |i| URI.decode_www_form(i) }
.try { |i| Base64.decode(i) }
.try { |i| IO::Memory.new(i) }
@@ -19,4 +22,5 @@ def parse_clip_parameters(params) : {Float64?, Float64?, String?}
.try(&.["50:0:embedded"]["4:3:string"].as_s)
return start_time, end_time, clip_title
end
end
-6
View File
@@ -21,8 +21,6 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
str << cp.chr
end
# A codepoint from the SMP counts twice
copied += 1 if cp > 0xFFFF
copied += 1
end
@@ -44,10 +42,6 @@ def parse_description(desc, video_id : String) : String?
end
end
# Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
# (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are
# automatically decoded by the JSON parser. It means that we need to count
# copied byte in a special manner, preventing the use of regular string copy.
iter = content.each_codepoint
index = 0
+23 -19
View File
@@ -1,12 +1,15 @@
require "json"
# Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer".
# The former is preferred as it has more videos in it. The second has
# the same 11 first entries as the compact rendered.
#
# TODO: "compactRadioRenderer" (Mix) and
# TODO: Use a proper struct/class instead of a hacky JSON object
def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
module Invidious::Videos::Parser
extend self
# Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer".
# The former is preferred as it has more videos in it. The second has
# the same 11 first entries as the compact rendered.
#
# TODO: "compactRadioRenderer" (Mix) and
# TODO: Use a proper struct/class instead of a hacky JSON object
def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
return nil if !related["videoId"]?
# The compact renderer has video length in seconds, where the end
@@ -50,9 +53,9 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)?
"author_verified" => JSON::Any.new(author_verified),
"published" => JSON::Any.new(published || ""),
}
end
end
def extract_video_info(video_id : String)
def extract_video_info(video_id : String)
# Fetch data from the player endpoint
player_response = YoutubeAPI.player(video_id: video_id)
@@ -103,7 +106,7 @@ def extract_video_info(video_id : String)
player_response = player_response.merge(next_response)
end
params = parse_video_info(video_id, player_response)
params = self.parse_video_info(video_id, player_response)
params["reason"] = JSON::Any.new(reason) if reason
{"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f|
@@ -129,9 +132,9 @@ def extract_video_info(video_id : String)
params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64)
return params
end
end
def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)?
def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)?
LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.")
response = YoutubeAPI.player(video_id: id)
@@ -149,9 +152,9 @@ def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConf
else
return nil
end
end
end
def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any)
def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any)
# Top level elements
main_results = player_response.dig?("contents", "twoColumnWatchNextResults")
@@ -242,7 +245,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
.dig?("secondaryResults", "secondaryResults", "results")
secondary_results.try &.as_a.each do |element|
if item = element["compactVideoRenderer"]?
related_video = parse_related_video(item)
related_video = self.parse_related_video(item)
related << JSON::Any.new(related_video) if related_video
end
end
@@ -257,7 +260,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
player_overlays.try &.as_a.each do |element|
if item = element["endScreenVideoRenderer"]?
related_video = parse_related_video(item)
related_video = self.parse_related_video(item)
related << JSON::Any.new(related_video) if related_video
end
end
@@ -441,9 +444,9 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any
}
return params
end
end
private def convert_url(fmt)
private def convert_url(fmt)
if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) }
url = URI.parse(cfr["url"])
params = url.query_params
@@ -458,8 +461,9 @@ private def convert_url(fmt)
LOGGER.trace("convert_url: new url is '#{url}'")
return url.to_s
rescue ex
rescue ex
LOGGER.debug("convert_url: Error when parsing video URL")
LOGGER.trace(ex.inspect_with_backtrace)
return ""
end
end
+7 -3
View File
@@ -1,4 +1,7 @@
struct VideoPreferences
module Invidious::Videos
extend self
struct VideoPreferences
include JSON::Serializable
property annotations : Bool
@@ -25,9 +28,9 @@ struct VideoPreferences
property volume : Int32
property vr_mode : Bool
property save_player_pos : Bool
end
end
def process_video_params(query, preferences)
def process_video_params(query, preferences)
annotations = query["iv_load_policy"]?.try &.to_i?
preload = query["preload"]?.try { |q| (q == "true" || q == "1").to_unsafe }
autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe }
@@ -159,4 +162,5 @@ def process_video_params(query, preferences)
})
return params
end
end
@@ -1,4 +1,12 @@
<%
search_privacy = preferences.search_privacy
%>
<% if search_privacy %>
<form class="pure-form" action="/search" method="post">
<% else %>
<form class="pure-form" action="/search" method="get">
<% end %>
<fieldset>
<input type="search" id="searchbox" autocorrect="off"
autocapitalize="none" spellcheck="false" <% if autofocus %>autofocus<% end %>
+6 -1
View File
@@ -1,5 +1,10 @@
<%
search_privacy = preferences.search_privacy
search_query = query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "&hellip;" : HTML.escape(query.text)
%>
<% content_for "header" do %>
<title><%= query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "&hellip;" : HTML.escape(query.text) %> - Invidious</title>
<title><%= search_privacy ? "Search" : search_query %> - Invidious</title>
<link rel="stylesheet" href="/css/search.css?v=<%= ASSET_COMMIT %>">
<% end %>
+6
View File
@@ -221,6 +221,12 @@
<input name="automatic_instance_redirect" id="automatic_instance_redirect" type="checkbox" <% if preferences.automatic_instance_redirect %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="search_privacy"><%= I18n.translate(locale, "preferences_search_privacy_label") %></label>
<input name="search_privacy" id="search_privacy" type="checkbox" <% if preferences.search_privacy %>checked<% end %>>
<span class="preference-description"><%= I18n.translate(locale, "preferences_search_privacy_description") %></span>
</div>
<% if env.get? "user" %>
<legend><%= I18n.translate(locale, "preferences_category_subscription") %></legend>
+141 -13
View File
@@ -630,13 +630,14 @@ private module Parsers
end
end
# Parses an InnerTube lockupViewModel into a SearchPlaylist.
# Parses an InnerTube lockupViewModel into a SearchPlaylist or a SearchVideo
# Returns nil when the given object is not a lockupViewModel.
#
# This structure is present since November 2024 on the "podcasts" and
# "playlists" tabs of the channel page. It is usually encapsulated in either
# a richItemRenderer or a richGridRenderer.
#
# Since 2026-05-21, now channel videos are encapsulated in a lockupViewModel.
module LockupViewModelParser
extend self
include BaseParser
@@ -648,25 +649,90 @@ private module Parsers
end
private def parse_internal(item_contents, author_fallback)
playlist_id = item_contents["contentId"].as_s
content_type = item_contents["contentType"].as_s
if content_type == "LOCKUP_CONTENT_TYPE_VIDEO"
thumbnail_view_model = item_contents.dig(
"contentImage", "thumbnailViewModel"
)
thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s
video_id = item_contents["contentId"].as_s
metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
title = metadata.dig("title", "content").as_s
# Contains the views of the video and the published time of the video.
metadata_parts = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows", 0, "metadataParts").try &.as_a
view_count_text = metadata_parts.try &.find { |item| item["icon"]?.nil? && item.dig?("text", "content").try &.as_s.includes?("views") }
.try &.dig("text", "content").as_s
published = metadata_parts.try &.find { |item| item["icon"]?.nil? && item.dig?("text", "content").try &.as_s.includes?("ago") }
.try { |item| decode_date(item.dig("text", "content").as_s) } || Time.local
view_count = short_text_to_number(view_count_text || "0")
length = thumbnail_view_model.dig("overlays", 0, "thumbnailBottomOverlayViewModel", "badges", 0, "thumbnailBadgeViewModel", "text").try &.as_s
length_seconds = decode_length_seconds(length) if length
return SearchVideo.new({
title: title,
id: video_id,
author: author_fallback.name,
ucid: author_fallback.id,
published: published,
views: view_count,
description_html: "",
length_seconds: length_seconds || 0,
premiere_timestamp: Time.unix(0),
author_verified: false,
author_thumbnail: nil,
badges: VideoBadges::None,
})
# If it's a playlist, it's content_type would be "LOCKUP_CONTENT_TYPE_PLAYLIST"
# If it's a podcast, it's content_type would be "LOCKUP_CONTENT_TYPE_PODCAST"
# Playlist and Podcasts structures are quite similar, so we can use the same logic
# we use to parse Playlists data, for Podcasts.
else
thumbnail_view_model = item_contents.dig(
"contentImage", "collectionThumbnailViewModel",
"primaryThumbnail", "thumbnailViewModel"
)
thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s
playlist_id = item_contents["contentId"].as_s
# This complicated sequences tries to extract the following data structure:
# "overlays": [{
#
# "overlays": [
# {
# "thumbnailOverlayBadgeViewModel": {
# "thumbnailBadges": [{
# "thumbnailBadges": [
# {
# "thumbnailBadgeViewModel": {
# "text": "430 episodes",
# "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT"
# "icon": {
# "sources": [
# {
# "clientResource": {
# "imageName": "BROADCAST"
# }
# }]
# }
# }]
# ]
# },
# "text": "5 episodes",
# "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT",
# "backgroundColor": {
# "lightTheme": 991526,
# "darkTheme": 991526
# }
# }
# }
# ],
# "position": "THUMBNAIL_OVERLAY_BADGE_POSITION_BOTTOM_END"
# }
# },
# ... <-- There is another item bellow the Object we use to extract episodes/videos
# ]
#
# NOTE: this simplistic `.to_i` conversion might not work on larger
# playlists and hasn't been tested.
@@ -674,13 +740,74 @@ private module Parsers
.compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a)
.flatten
.find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node|
{"episodes", "videos"}.any? { |str| node.as_s.ends_with?(str) }
{"episodes", "videos", "lessons"}.any? { |str| node.as_s.ends_with?(str) }
})
.try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false)
metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
title = metadata.dig("title", "content").as_s
# metadataParts is not always in the first place of the metadataRows array, therefore,
# we search for it iterating the array. We have only seen metadataRows with at least
# 2 items inside it.
#
# It looks like this:
# "metadataRows": [
# {}, <-- empty Object
# {
# "metadataParts": [ ... ] <-- metadataParts with the information we are searching for.
# }
# ]
#
# Playlist on channels also contain metadataRows, but not with the type of data we are searching
# for which are the channel name and channel ID, instead they have two fields depending of the playlist
# updated date:
#
# It looks like this:
# "metadataRows": [
# {
# "metadataParts": [
# {
# "text": {
# "content": "Updated 4 days ago"
# }
# } <-- This object is missing if the playlist has not been updated in around 7
# days
# ]
# },
# {
# "metadataParts": [
# {
# "text": {
# "content": "View full playlist",
# "commandRuns": [ ... ],
# "styleRuns": [ ... ].
# }
# } <-- This object is always present, so we use this to determine if the
# metadataParts can be used or not.
# ]
# }
# ]
#
metadata_rows = metadata.dig?("metadata", "contentMetadataViewModel", "metadataRows").try &.as_a
metadata_parts = metadata_rows.try &.find { |row|
parts = row["metadataParts"]?.try &.as_a
parts && !parts.any? { |item| item.dig?("text", "content").try &.as_s == "View full playlist" }
}.try &.["metadataParts"].as_a
if author_info = metadata_parts.try &.find(&.dig?("text", "commandRuns"))
.try &.["text"]
author = author_info["content"].as_s
author_id = author_info.dig?("commandRuns", 0, "onTap", "innertubeCommand", "browseEndpoint", "browseId")
.try &.as_s || author_fallback.id
author_verified = (author_info.dig?("attachmentRuns", 0, "element", "type", "imageType", "image", "sources", 0, "clientResource", "imageName")
.try &.as_s) == "CHECK_CIRCLE_FILLED" || false
else
author = author_fallback.name
author_id = author_fallback.id
author_verified = false
end
# TODO: Retrieve "updated" info from metadata parts
# rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").as_a
# parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s)
@@ -693,14 +820,15 @@ private module Parsers
return SearchPlaylist.new({
title: title,
id: playlist_id,
author: author_fallback.name,
ucid: author_fallback.id,
author: author,
ucid: author_id,
video_count: video_count || -1,
videos: [] of SearchPlaylistVideo,
thumbnail: thumbnail,
author_verified: false,
author_verified: author_verified,
})
end
end
def self.parser_name
return {{@type.name}}
+2 -2
View File
@@ -480,7 +480,7 @@ module YoutubeAPI
#
# ```
# # Valid channel "brand URL" gives the related UCID and browse ID
# channel_a = YoutubeAPI.resolve_url("https://youtube.com/c/google")
# channel_a = YoutubeAPI.resolve_url("https://www.youtube.com/c/google")
# channel_a # => {
# "endpoint": {
# "browseEndpoint": {
@@ -492,7 +492,7 @@ module YoutubeAPI
# }
#
# # Invalid URL returns throws an InfoException
# channel_b = YoutubeAPI.resolve_url("https://youtube.com/c/invalid")
# channel_b = YoutubeAPI.resolve_url("https://www.youtube.com/c/invalid")
# ```
#
def resolve_url(url : String, client_config : ClientConfig | Nil = nil)