This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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."))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<%
|
||||
search_privacy = preferences.search_privacy
|
||||
search_query = query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "…" : HTML.escape(query.text)
|
||||
%>
|
||||
|
||||
<% content_for "header" do %>
|
||||
<title><%= query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "…" : 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 %>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user