Merge fc935d5175d76c876968c5ead4d864c0af16c720 into 99390d065d69bb451dea8aedf9a1bbaa52cddf2a

This commit is contained in:
Richard Lora 2026-05-23 23:55:20 -04:00 committed by GitHub
commit 65f28e210e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1140 additions and 621 deletions

View File

@ -296,12 +296,13 @@ https_only: false
# ----------------------------- # -----------------------------
## ##
## Enable/Disable the "Popular" tab on the main page. ## Enable/Disable specific pages on the main page.
## ##
## Accepted values: true, false #pages_enabled:
## Default: true # trending: false
# popular: true
# search: true
## ##
#popular_enabled: true
## ##
## Enable/Disable statstics (available at /api/v1/stats). ## Enable/Disable statstics (available at /api/v1/stats).

View File

@ -14,10 +14,6 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:3000:3000" - "127.0.0.1:3000:3000"
depends_on:
invidious-db:
condition: service_healthy
restart: true
environment: environment:
# Please read the following file for a comprehensive list of all available # Please read the following file for a comprehensive list of all available
# configuration options and their associated syntax: # configuration options and their associated syntax:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,50 @@
require "../spec_helper"
require "../../src/invidious/jobs.cr"
require "../../src/invidious/jobs/*"
require "../../src/invidious/config.cr"
require "../../src/invidious/user/preferences.cr"
# Allow this file to be executed independently of other specs
{% if !@type.has_constant?("CONFIG") %}
CONFIG = Config.from_yaml("")
{% end %}
private def construct_config(yaml)
config = Config.from_yaml(yaml)
File.open(File::NULL, "w") { |io| config.process_deprecation(io) }
return config
end
Spectator.describe Config do
context "page_enabled" do
it "Can disable pages" do
config = construct_config <<-YAML
pages_enabled:
popular: false
search: false
YAML
expect(config.page_enabled?("trending")).to eq(false)
expect(config.page_enabled?("popular")).to eq(false)
expect(config.page_enabled?("search")).to eq(false)
end
it "Takes precedence over popular_enabled" do
config = construct_config <<-YAML
popular_enabled: false
pages_enabled:
popular: true
YAML
expect(config.page_enabled?("popular")).to eq(true)
end
end
it "Deprecated popular_enabled still works" do
config = construct_config <<-YAML
popular_enabled: false
YAML
expect(config.page_enabled?("popular")).to eq(false)
end
end

View File

@ -185,7 +185,7 @@ module Kemal
if is_dir if is_dir
if config.is_a?(Hash) && config["dir_listing"] == true if config.is_a?(Hash) && config["dir_listing"] == true
context.response.content_type = "text/html" context.response.content_type = "text/html"
directory_listing(context.response, request_path, file_path) directory_listing(context.response, Path[request_path], Path[file_path])
else else
call_next(context) call_next(context)
end end

View File

@ -178,7 +178,7 @@ if (CONFIG.use_pubsub_feeds.is_a?(Bool) && CONFIG.use_pubsub_feeds.as(Bool)) ||
Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, HMAC_KEY) Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, HMAC_KEY)
end end
if CONFIG.popular_enabled if CONFIG.page_enabled?("popular")
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
end end
@ -200,6 +200,12 @@ end
before_all do |env| before_all do |env|
Invidious::Routes::BeforeAll.handle(env) Invidious::Routes::BeforeAll.handle(env)
# If before_all flagged a halt (e.g. disabled page), stop the route handler.
if halted_status = env.get?("halted_status")
body = env.get?("halted_body").try(&.as(String)) || ""
halt env, status_code: halted_status.as(Int32), response: body
end
end end
Invidious::Routing.register_all Invidious::Routing.register_all

View File

@ -73,6 +73,31 @@ struct HTTPProxyConfig
property port : Int32 property port : Int32
end end
# Structure used for global per-page feature toggles
record PagesEnabled,
trending : Bool = false,
popular : Bool = true,
search : Bool = true do
include YAML::Serializable
def [](key : String) : Bool
fetch(key) { raise KeyError.new("Unknown page '#{key}'") }
end
def []?(key : String) : Bool
fetch(key) { nil }
end
private def fetch(key : String, &)
case key
when "trending" then @trending
when "popular" then @popular
when "search" then @search
else yield
end
end
end
class Config class Config
include YAML::Serializable include YAML::Serializable
@ -116,13 +141,37 @@ class Config
# Used to tell Invidious it is behind a proxy, so links to resources should be https:// # Used to tell Invidious it is behind a proxy, so links to resources should be https://
property https_only : Bool? property https_only : Bool?
# HMAC signing key for CSRF tokens and verifying pubsub subscriptions # HMAC signing key for CSRF tokens and verifying pubsub subscriptions
property hmac_key : String = "" property hmac_key : String = ""
# Domain to be used for links to resources on the site where an absolute URL is required # Domain to be used for links to resources on the site where an absolute URL is required
property domain : String? property domain : String?
# Subscribe to channels using PubSubHubbub (requires domain, hmac_key) # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
property use_pubsub_feeds : Bool | Int32 = false property use_pubsub_feeds : Bool | Int32 = false
# —————————————————————————————————————————————————————————————————————————————————————
# A @{{key}}_present variable is required for both fields in order to handle the precedence for
# the deprecated `popular_enabled` in relations to `pages_enabled`
# DEPRECATED: use `pages_enabled["popular"]` instead.
@[Deprecated("`popular_enabled` will be removed in a future release; use pages_enabled[\"popular\"] instead")]
@[YAML::Field(presence: true)]
property popular_enabled : Bool = true property popular_enabled : Bool = true
@[YAML::Field(ignore: true)]
property popular_enabled_present : Bool
# Global per-page feature toggles.
# Valid keys: "trending", "popular", "search"
# If someone sets both `popular_enabled` and `pages_enabled["popular"]`, the latter takes precedence.
@[YAML::Field(presence: true)]
property pages_enabled : PagesEnabled = PagesEnabled.from_yaml("")
@[YAML::Field(ignore: true)]
property pages_enabled_present : Bool
# —————————————————————————————————————————————————————————————————————————————————————
property captcha_enabled : Bool = true property captcha_enabled : Bool = true
property login_enabled : Bool = true property login_enabled : Bool = true
property registration_enabled : Bool = true property registration_enabled : Bool = true
@ -185,16 +234,17 @@ class Config
when Bool when Bool
return disabled return disabled
when Array when Array
if disabled.includes? option disabled.includes?(option)
return true
else
return false
end
else else
return false false
end end
end end
# Centralized page toggle with legacy fallback for `popular_enabled`
def page_enabled?(page : String) : Bool
return @pages_enabled[page]
end
def self.load def self.load
# Load config from file or YAML string env var # Load config from file or YAML string env var
env_config_file = "INVIDIOUS_CONFIG_FILE" env_config_file = "INVIDIOUS_CONFIG_FILE"
@ -232,6 +282,12 @@ class Config
begin begin
config.{{ivar.id}} = ivar_type.from_yaml(env_value) config.{{ivar.id}} = ivar_type.from_yaml(env_value)
success = true success = true
# Update associated _present key if any
{% other_ivar = @type.instance_vars.find { |other_ivar| other_ivar.name == ivar.name + "_present" } %}
{% if other_ivar && (ann = other_ivar.annotation(YAML::Field)) && ann[:ignore] == true %}
config.{{other_ivar.name.id}} = true
{% end %}
rescue rescue
# nop # nop
end end
@ -283,6 +339,8 @@ class Config
exit(1) exit(1)
end end
config.process_deprecation
# Build database_url from db.* if it's not set directly # Build database_url from db.* if it's not set directly
if config.database_url.to_s.empty? if config.database_url.to_s.empty?
if db = config.db if db = config.db
@ -320,4 +378,24 @@ class Config
return config return config
end end
# Processes deprecated values
#
# Warns when they are set and handles any precedence issue that may arise when present alongside a successor attribute
#
# This method is public as to allow specs to test the behavior without going through #load
#
# :nodoc:
def process_deprecation(log_io : IO = STDOUT)
# Handle deprecated popular_enabled config and warn if it is set
if self.popular_enabled_present
log_io.puts "Warning: `popular_enabled` has been deprecated and replaced by the `pages_enabled` config"
log_io.puts "If both are set `pages_enabled` will take precedence over `popular_enabled`"
# Only use popular_enabled value when pages_enabled is unset
if !self.pages_enabled_present
self.pages_enabled = self.pages_enabled.copy_with(popular: self.popular_enabled)
end
end
end
end end

View File

@ -345,36 +345,20 @@ def fetch_playlist(plid : String)
initial_data = YoutubeAPI.browse("VL" + plid, params: "") initial_data = YoutubeAPI.browse("VL" + plid, params: "")
playlist_sidebar_renderer = initial_data.dig?("sidebar", "playlistSidebarRenderer", "items") playlist_sidebar_renderer = initial_data.dig?("sidebar", "playlistSidebarRenderer", "items")
raise InfoException.new("Could not extract playlistSidebarRenderer.") if !playlist_sidebar_renderer header_renderer = initial_data.dig?("header", "playlistHeaderRenderer")
playlist_info = playlist_sidebar_renderer.dig?(0, "playlistSidebarPrimaryInfoRenderer") raise InfoException.new("Could not extract playlist data.") if playlist_sidebar_renderer.nil? && header_renderer.nil?
raise InfoException.new("Could not extract playlist info") if !playlist_info
title = playlist_info.dig?("title", "runs", 0, "text").try &.as_s || ""
desc_item = playlist_info["description"]?
description_txt = desc_item.try &.["runs"]?.try &.as_a
.map(&.["text"].as_s).join("") || desc_item.try &.["simpleText"]?.try &.as_s || ""
description_html = desc_item.try &.["runs"]?.try &.as_a
.try { |run| content_to_comment_html(run).try &.to_s } || "<p></p>"
thumbnail = playlist_info.dig?(
"thumbnailRenderer", "playlistVideoThumbnailRenderer",
"thumbnail", "thumbnails", 0, "url"
).try &.as_s || playlist_info.dig?(
"thumbnailRenderer", "playlistCustomThumbnailRenderer",
"thumbnail", "thumbnails", 0, "url"
).try &.as_s
views = 0_i64 views = 0_i64
updated = Time.utc updated = Time.utc
video_count = 0 video_count = 0
subtitle = extract_text(header_renderer.try &.["subtitle"]?)
subtitle = extract_text(initial_data.dig?("header", "playlistHeaderRenderer", "subtitle")) # stats parsing is the same format in both sidebar and header renderers
stats_source = playlist_sidebar_renderer.try(&.dig?(0, "playlistSidebarPrimaryInfoRenderer", "stats")) ||
header_renderer.try(&.["stats"]?)
playlist_info["stats"]?.try &.as_a.each do |stat| stats_source.try &.as_a.each do |stat|
text = stat["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || stat["simpleText"]?.try &.as_s text = stat["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || stat["simpleText"]?.try &.as_s
next if !text next if !text
@ -389,20 +373,63 @@ def fetch_playlist(plid : String)
end end
end end
if playlist_sidebar_renderer.size < 2 if playlist_sidebar_renderer
author = "" playlist_info = playlist_sidebar_renderer.dig?(0, "playlistSidebarPrimaryInfoRenderer")
author_thumbnail = "" raise InfoException.new("Could not extract playlist info") if !playlist_info
ucid = ""
title = playlist_info.dig?("title", "runs", 0, "text").try &.as_s || ""
desc_item = playlist_info["description"]?
description_txt = desc_item.try &.["runs"]?.try &.as_a
.map(&.["text"].as_s).join("") || desc_item.try &.["simpleText"]?.try &.as_s || ""
description_html = desc_item.try &.["runs"]?.try &.as_a
.try { |run| content_to_comment_html(run).try &.to_s } || "<p></p>"
thumbnail = playlist_info.dig?(
"thumbnailRenderer", "playlistVideoThumbnailRenderer",
"thumbnail", "thumbnails", 0, "url"
).try &.as_s || playlist_info.dig?(
"thumbnailRenderer", "playlistCustomThumbnailRenderer",
"thumbnail", "thumbnails", 0, "url"
).try &.as_s
if playlist_sidebar_renderer.size < 2
author = ""
author_thumbnail = ""
ucid = ""
else
author_info = playlist_sidebar_renderer[1].dig?(
"playlistSidebarSecondaryInfoRenderer", "videoOwner", "videoOwnerRenderer"
)
raise InfoException.new("Could not extract author info") if !author_info
author = author_info.dig?("title", "runs", 0, "text").try &.as_s || ""
author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url").try &.as_s || ""
ucid = author_info.dig?("title", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || ""
end
else else
author_info = playlist_sidebar_renderer[1].dig?( # Sidebar gone — fall back to playlistHeaderRenderer (YouTube changed response format)
"playlistSidebarSecondaryInfoRenderer", "videoOwner", "videoOwnerRenderer" hr = header_renderer.not_nil!
)
raise InfoException.new("Could not extract author info") if !author_info title = extract_text(hr["title"]?) || ""
author = author_info.dig?("title", "runs", 0, "text").try &.as_s || "" desc_item = hr["descriptionText"]? || hr["description"]?
author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url").try &.as_s || "" description_txt = desc_item.try &.["runs"]?.try &.as_a
ucid = author_info.dig?("title", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || "" .map(&.["text"].as_s).join("") || desc_item.try &.["simpleText"]?.try &.as_s || ""
description_html = desc_item.try &.["runs"]?.try &.as_a
.try { |run| content_to_comment_html(run).try &.to_s } || "<p></p>"
thumbnail = hr.dig?("thumbnail", "thumbnails", 0, "url").try &.as_s
owner_run = hr.dig?("ownerText", "runs", 0)
author = owner_run.try &.["text"]?.try &.as_s || ""
ucid = owner_run.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || ""
author_thumbnail = hr.dig?("channelAvatar", "decoratedAvatarViewModel", "avatar",
"avatarViewModel", "image", "sources", 0, "url").try &.as_s ||
hr.dig?("avatar", "thumbnails", 0, "url").try &.as_s || ""
end end
return Playlist.new({ return Playlist.new({

View File

@ -313,6 +313,11 @@ module Invidious::Routes::API::V1::Authenticated
return error_json(403, "Invalid videoId") return error_json(403, "Invalid videoId")
end end
# Prevent duplicate videos in the same playlist
if Invidious::Database::PlaylistVideos.select_index(plid, video_id)
return error_json(409, "Video already exists in this playlist")
end
begin begin
video = get_video(video_id) video = get_video(video_id)
rescue ex : NotFoundException rescue ex : NotFoundException

View File

@ -29,11 +29,6 @@ module Invidious::Routes::API::V1::Feeds
env.response.content_type = "application/json" env.response.content_type = "application/json"
if !CONFIG.popular_enabled
error_message = {"error" => "Administrator has disabled this endpoint."}.to_json
haltf env, 403, error_message
end
JSON.build do |json| JSON.build do |json|
json.array do json.array do
popular_videos.each do |video| popular_videos.each do |video|

View File

@ -115,6 +115,8 @@ module Invidious::Routes::BeforeAll
preferences.locale = locale preferences.locale = locale
env.set "preferences", preferences env.set "preferences", preferences
path = env.request.path
# Allow media resources to be loaded from google servers # Allow media resources to be loaded from google servers
# TODO: check if *.youtube.com can be removed # TODO: check if *.youtube.com can be removed
# #
@ -130,7 +132,7 @@ module Invidious::Routes::BeforeAll
env.response.headers["Content-Security-Policy"] = env.response.headers["Content-Security-Policy"].gsub("media-src", "media-src https://*.googlevideo.com:443 https://*.youtube.com:443") env.response.headers["Content-Security-Policy"] = env.response.headers["Content-Security-Policy"].gsub("media-src", "media-src https://*.googlevideo.com:443 https://*.youtube.com:443")
end end
current_page = env.request.path current_page = path
if env.request.query if env.request.query
query = HTTP::Params.parse(env.request.query.not_nil!) query = HTTP::Params.parse(env.request.query.not_nil!)
@ -142,5 +144,70 @@ module Invidious::Routes::BeforeAll
end end
env.set "current_page", URI.encode_www_form(current_page) env.set "current_page", URI.encode_www_form(current_page)
page_key = case path
when "/feed/popular", "/api/v1/popular"
"popular"
when "/feed/trending", "/api/v1/trending"
"trending"
when "/api/v1/search", "/api/v1/search/suggestions"
"search"
when .starts_with?("/api/v1/hashtag/")
"search"
when "/search", "/results"
# Handled by the search route (subscription-only mode when search disabled)
nil
when .starts_with?("/hashtag/")
"search"
else
nil
end
if page_key && !CONFIG.page_enabled?(page_key)
if path.starts_with?("/api/")
env.response.content_type = "application/json"
env.set "halted_body", {error: "Administrator has disabled this endpoint."}.to_json
else
preferences = env.get("preferences").as(Preferences)
locale = preferences.locale
dark_mode = preferences.dark_mode
theme_class = dark_mode.blank? ? "no" : dark_mode
error_message = I18n.translate(locale, "#{page_key}_page_disabled")
env.response.content_type = "text/html"
env.set "halted_body", <<-HTML
<!DOCTYPE html>
<html lang="#{locale}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Error - Invidious</title>
<link rel="stylesheet" href="/css/pure-min.css">
<link rel="stylesheet" href="/css/grids-responsive-min.css">
<link rel="stylesheet" href="/css/ionicons.min.css">
<link rel="stylesheet" href="/css/default.css">
</head>
<body class="#{theme_class}-theme">
<div class="pure-g">
<div class="pure-u-1 pure-u-xl-20-24" id="contents">
<div class="pure-g navbar h-box">
<div class="pure-u-1 pure-u-md-16-24">
<a href="/" class="index-link pure-menu-heading">Invidious</a>
</div>
</div>
<div class="h-box" style="margin-top: 2em;">
<p>#{error_message}</p>
<p><a href="/"> #{I18n.translate(locale, "Back")}</a></p>
</div>
</div>
</div>
</body>
</html>
HTML
end
env.set "halted_status", 403
return
end
end end
end end

View File

@ -0,0 +1,114 @@
# Debug route to verify configuration is loaded correctly
# This can help diagnose issues with pages_enabled configuration
module Invidious::Routes::DebugConfig
def self.show(env)
# Only allow access to admins or in development mode
if CONFIG.admins.empty? || (user = env.get? "user")
admin_user = user.try &.as(User)
if !admin_user || !CONFIG.admins.includes?(admin_user.email)
return error_template(403, "Administrator privileges required")
end
else
# If no user is logged in and admins are configured, deny access
return error_template(403, "Administrator privileges required")
end
html = <<-HTML
<!DOCTYPE html>
<html>
<head>
<title>Configuration Debug - Invidious</title>
<style>
body { font-family: monospace; padding: 20px; }
.enabled { color: green; }
.disabled { color: red; }
table { border-collapse: collapse; margin: 20px 0; }
td, th { border: 1px solid #ccc; padding: 8px; text-align: left; }
th { background: #f0f0f0; }
pre { background: #f5f5f5; padding: 10px; overflow-x: auto; }
</style>
</head>
<body>
<h1>Invidious Configuration Debug</h1>
<h2>Pages Configuration</h2>
<table>
<tr>
<th>Page</th>
<th>Status</th>
<th>Configuration Value</th>
</tr>
<tr>
<td>Popular</td>
<td class="#{CONFIG.page_enabled?("popular") ? "enabled" : "disabled"}">
#{CONFIG.page_enabled?("popular") ? "ENABLED" : "DISABLED"}
</td>
<td>#{CONFIG.pages_enabled.popular}</td>
</tr>
<tr>
<td>Trending</td>
<td class="#{CONFIG.page_enabled?("trending") ? "enabled" : "disabled"}">
#{CONFIG.page_enabled?("trending") ? "ENABLED" : "DISABLED"}
</td>
<td>#{CONFIG.pages_enabled.trending}</td>
</tr>
<tr>
<td>Search</td>
<td class="#{CONFIG.page_enabled?("search") ? "enabled" : "disabled"}">
#{CONFIG.page_enabled?("search") ? "ENABLED" : "DISABLED"}
</td>
<td>#{CONFIG.pages_enabled.search}</td>
</tr>
</table>
<h2>Configuration Flags</h2>
<table>
<tr>
<th>Flag</th>
<th>Value</th>
</tr>
<tr>
<td>pages_enabled_present</td>
<td>#{CONFIG.pages_enabled_present}</td>
</tr>
<tr>
<td>popular_enabled_present (deprecated)</td>
<td>#{CONFIG.popular_enabled_present}</td>
</tr>
<tr>
<td>popular_enabled (deprecated)</td>
<td>#{CONFIG.popular_enabled}</td>
</tr>
</table>
<h2>Blocked Routes</h2>
<p>The following routes should be blocked based on current configuration:</p>
<ul>
#{!CONFIG.page_enabled?("popular") ? "<li>/feed/popular</li><li>/api/v1/popular</li>" : ""}
#{!CONFIG.page_enabled?("trending") ? "<li>/feed/trending</li><li>/api/v1/trending</li>" : ""}
#{!CONFIG.page_enabled?("search") ? "<li>/search</li><li>/api/v1/search</li>" : ""}
</ul>
<h2>Test Links</h2>
<p>Click these links to verify they are properly blocked:</p>
<ul>
<li><a href="/feed/popular">/feed/popular</a> - #{CONFIG.page_enabled?("popular") ? "Should work" : "Should be blocked"}</li>
<li><a href="/feed/trending">/feed/trending</a> - #{CONFIG.page_enabled?("trending") ? "Should work" : "Should be blocked"}</li>
<li><a href="/search">/search</a> - #{CONFIG.page_enabled?("search") ? "Should work" : "Should be blocked"}</li>
</ul>
<h2>Raw Configuration</h2>
<pre>pages_enabled: #{CONFIG.pages_enabled.inspect}</pre>
<h2>Environment Check</h2>
<pre>INVIDIOUS_CONFIG present: #{ENV.has_key?("INVIDIOUS_CONFIG")}</pre>
<pre>INVIDIOUS_PAGES_ENABLED present: #{ENV.has_key?("INVIDIOUS_PAGES_ENABLED")}</pre>
</body>
</html>
HTML
env.response.content_type = "text/html"
env.response.print html
end
end

View File

@ -330,6 +330,15 @@ module Invidious::Routes::Playlists
video_id = env.params.query["video_id"] video_id = env.params.query["video_id"]
# Prevent duplicate videos in the same playlist
if Invidious::Database::PlaylistVideos.select_index(playlist_id, video_id)
if redirect
return env.redirect referer
else
return error_json(409, "Video already exists in this playlist")
end
end
begin begin
video = get_video(video_id) video = get_video(video_id)
rescue ex : NotFoundException rescue ex : NotFoundException

View File

@ -201,9 +201,11 @@ module Invidious::Routes::PreferencesRoute
end end
CONFIG.default_user_preferences.feed_menu = admin_feed_menu CONFIG.default_user_preferences.feed_menu = admin_feed_menu
popular_enabled = env.params.body["popular_enabled"]?.try &.as(String) CONFIG.pages_enabled = PagesEnabled.new(
popular_enabled ||= "off" popular: (env.params.body["popular_enabled"]?.try &.as(String) || "off") == "on",
CONFIG.popular_enabled = popular_enabled == "on" trending: (env.params.body["trending_enabled"]?.try &.as(String) || "off") == "on",
search: (env.params.body["search_enabled"]?.try &.as(String) || "off") == "on",
)
captcha_enabled = env.params.body["captcha_enabled"]?.try &.as(String) captcha_enabled = env.params.body["captcha_enabled"]?.try &.as(String)
captcha_enabled ||= "off" captcha_enabled ||= "off"

View File

@ -40,52 +40,78 @@ module Invidious::Routes::Search
preferences = env.get("preferences").as(Preferences) preferences = env.get("preferences").as(Preferences)
locale = preferences.locale locale = preferences.locale
search_disabled = !CONFIG.page_enabled?("search")
region = env.params.query["region"]? || preferences.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)
# empty query → show homepage
if query.empty? if query.empty?
# Display the full page search box implemented in #1977
env.set "search", "" env.set "search", ""
templated "search_homepage", navbar_search: false return templated "search_homepage", navbar_search: false
else end
user = env.get? "user"
# An URL was copy/pasted in the search box. # When YouTube search is disabled, force subscription-only mode
# Redirect the user to the appropriate page. if search_disabled
if query.url? user = env.get?("user")
return env.redirect UrlSanitizer.process(query.text).to_s if !user
return error_template(403, I18n.translate(locale, "search_subscriptions_login_required"))
end end
user = user.as(User)
begin begin
if user items = Invidious::Search::Processors.subscriptions_and_playlists(query, user)
items = query.process(user.as(User))
else
items = query.process
end
rescue ex : ChannelSearchException
return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.")
rescue ex rescue ex
return error_template(500, ex) return error_template 500, ex
end end
redirect_url = Invidious::Frontend::Misc.redirect_url(env) redirect_url = Invidious::Frontend::Misc.redirect_url(env)
# Pagination
page_nav_html = Frontend::Pagination.nav_numeric(locale, page_nav_html = Frontend::Pagination.nav_numeric(locale,
base_url: "/search?#{query.to_http_params}", base_url: "/search?#{query.to_http_params}",
current_page: query.page, current_page: query.page,
show_next: (items.size >= 20) show_next: (items.size >= 20)
) )
if query.type == Invidious::Search::Query::Type::Channel env.set "search", query.text
env.set "search", "channel:#{query.channel} #{query.text}" env.set "subscription_only_search", true
else
env.set "search", query.text
end
templated "search" return templated "search"
end end
# nonempty query → process it
user = env.get?("user")
if query.url?
return env.redirect UrlSanitizer.process(query.text).to_s
end
begin
items = user ? query.process(user.as(User)) : query.process
rescue ex : ChannelSearchException
return error_template 404, "Unable to find channel with id "#{HTML.escape(ex.channel)}"…"
rescue ex
return error_template 500, ex
end
redirect_url = Invidious::Frontend::Misc.redirect_url(env)
# Pagination
page_nav_html = Frontend::Pagination.nav_numeric(locale,
base_url: "/search?#{query.to_http_params}",
current_page: query.page,
show_next: (items.size >= 20)
)
# If it's a channel search, prefix the box; otherwise just show the text
if query.type == Invidious::Search::Query::Type::Channel
env.set "search", "channel:#{query.channel} #{query.text}"
else
env.set "search", query.text
end
templated "search"
end end
def self.hashtag(env : HTTP::Server::Context) def self.hashtag(env : HTTP::Server::Context)

View File

@ -52,5 +52,57 @@ module Invidious::Search
as: ChannelVideo as: ChannelVideo
) )
end end
# Search subscriptions AND saved playlists (used when YouTube search is disabled)
def subscriptions_and_playlists(query : Query, user : Invidious::User) : Array(ChannelVideo)
view_name = "subscriptions_#{sha256(user.email)}"
offset = (query.page - 1) * 20
# Search subscription videos via the materialized view
sub_results = PG_DB.query_all("
SELECT id, title, published, updated, ucid, author, length_seconds
FROM (
SELECT *,
to_tsvector(#{view_name}.title) ||
to_tsvector(#{view_name}.author)
as document
FROM #{view_name}
) v_search WHERE v_search.document @@ plainto_tsquery($1)
ORDER BY published DESC
LIMIT 20 OFFSET $2;",
query.text, offset,
as: ChannelVideo
)
# Search playlist videos from user's playlists (both created and saved)
playlist_results = PG_DB.query_all("
SELECT pv.id, pv.title, pv.published, pv.published AS updated,
pv.ucid, pv.author, pv.length_seconds
FROM playlist_videos pv
INNER JOIN playlists p ON p.id = pv.plid
WHERE p.author = $1
AND (to_tsvector(pv.title) || to_tsvector(pv.author)) @@ plainto_tsquery($2)
ORDER BY pv.published DESC
LIMIT 20 OFFSET $3;",
user.email, query.text, offset,
as: ChannelVideo
)
# Merge, deduplicate by video ID, sort by published date, limit to 20
seen = Set(String).new
combined = [] of ChannelVideo
(sub_results + playlist_results)
.sort_by { |v| v.published }
.reverse!
.each do |video|
next if seen.includes?(video.id)
seen.add(video.id)
combined << video
break if combined.size >= 20
end
return combined
end
end end
end end

View File

@ -3,6 +3,18 @@
<% if !env.get?("user") %> <% if !env.get?("user") %>
<% feed_menu.reject! {|item| {"Subscriptions", "Playlists"}.includes? item} %> <% feed_menu.reject! {|item| {"Subscriptions", "Playlists"}.includes? item} %>
<% end %> <% end %>
<% feed_menu.reject! do |feed|
case feed
when "Popular"
!CONFIG.page_enabled?("popular")
when "Trending"
!CONFIG.page_enabled?("trending")
when ""
!CONFIG.page_enabled?("search")
else
false
end
end %>
<% feed_menu.each do |feed| %> <% feed_menu.each do |feed| %>
<a href="/feed/<%= feed.downcase %>" class="feed-menu-item pure-menu-heading"> <a href="/feed/<%= feed.downcase %>" class="feed-menu-item pure-menu-heading">
<%= I18n.translate(locale, feed) %> <%= I18n.translate(locale, feed) %>

View File

@ -1,5 +1,6 @@
<form class="pure-form" action="/search" method="get"> <form class="pure-form" action="/search" method="get">
<fieldset> <fieldset>
<% search_placeholder = CONFIG.page_enabled?("search") ? I18n.translate(locale, "search") : I18n.translate(locale, "search_subscriptions_placeholder") %>
<input type="search" id="searchbox" autocorrect="off" <input type="search" id="searchbox" autocorrect="off"
autocapitalize="none" spellcheck="false" <% if autofocus %>autofocus<% end %> autocapitalize="none" spellcheck="false" <% if autofocus %>autofocus<% end %>
name="q" placeholder="<%= I18n.translate(locale, "search") %>" name="q" placeholder="<%= I18n.translate(locale, "search") %>"

View File

@ -3,9 +3,18 @@
<link rel="stylesheet" href="/css/search.css?v=<%= ASSET_COMMIT %>"> <link rel="stylesheet" href="/css/search.css?v=<%= ASSET_COMMIT %>">
<% end %> <% end %>
<% subscription_only = env.get?("subscription_only_search") %>
<% if subscription_only %>
<div class="h-box" style="margin-bottom: 0.5em;">
<p style="color: var(--text-color-muted, #888);"><i class="icon ion-ios-lock"></i> <%= I18n.translate(locale, "search_subscriptions_mode_notice") %></p>
</div>
<hr/>
<% else %>
<!-- Search redirection and filtering UI --> <!-- Search redirection and filtering UI -->
<%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %> <%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
<hr/> <hr/>
<% end %>
<%- if items.empty? -%> <%- if items.empty? -%>

View File

@ -17,4 +17,11 @@
<% autofocus = true %><%= rendered "components/search_box" %> <% autofocus = true %><%= rendered "components/search_box" %>
</div> </div>
<div class="pure-u-1-4"></div> <div class="pure-u-1-4"></div>
<% if !CONFIG.page_enabled?("search") %>
<div class="pure-u-1-4"></div>
<div class="pure-u-1 pure-u-md-12-24" style="text-align: center; margin-top: 0.5em;">
<p style="color: #888;"><%= I18n.translate(locale, "search_subscriptions_hint") %></p>
</div>
<div class="pure-u-1-4"></div>
<% end %>
</div> </div>

View File

@ -182,11 +182,20 @@
<input name="thin_mode" id="thin_mode" type="checkbox" <% if preferences.thin_mode %>checked<% end %>> <input name="thin_mode" id="thin_mode" type="checkbox" <% if preferences.thin_mode %>checked<% end %>>
</div> </div>
<% if env.get?("user") %> <%
<% feed_options = {"", "Popular", "Trending", "Subscriptions", "Playlists"} %> # Build feed options based on enabled pages
<% else %> feed_options = [] of String
<% feed_options = {"", "Popular", "Trending"} %> # Empty string represents Search page
<% end %> feed_options << "" if CONFIG.page_enabled?("search")
feed_options << "Popular" if CONFIG.page_enabled?("popular")
feed_options << "Trending" if CONFIG.page_enabled?("trending")
if env.get?("user")
feed_options << "Subscriptions"
feed_options << "Playlists"
end
# Always add "none" option as fallback
feed_options << "<none>" if !feed_options.includes?("<none>")
%>
<div class="pure-control-group"> <div class="pure-control-group">
<label for="default_home"><%= I18n.translate(locale, "preferences_default_home_label") %></label> <label for="default_home"><%= I18n.translate(locale, "preferences_default_home_label") %></label>
@ -305,6 +314,10 @@
<input name="popular_enabled" id="popular_enabled" type="checkbox" <% if CONFIG.popular_enabled %>checked<% end %>> <input name="popular_enabled" id="popular_enabled" type="checkbox" <% if CONFIG.popular_enabled %>checked<% end %>>
</div> </div>
<div class="pure-control-group">
<label for="search_enabled"><%= I18n.translate(locale, "preferences_search_enabled_label") %></label>
<input name="search_enabled" id="search_enabled" type="checkbox" <% if CONFIG.page_enabled?("search") %>checked<% end %>>
</div>
<div class="pure-control-group"> <div class="pure-control-group">
<label for="captcha_enabled"><%= I18n.translate(locale, "CAPTCHA enabled: ") %></label> <label for="captcha_enabled"><%= I18n.translate(locale, "CAPTCHA enabled: ") %></label>

View File

@ -630,11 +630,11 @@ private module Parsers
end end
end end
# Parses an InnerTube lockupViewModel into a SearchPlaylist. # Parses an InnerTube lockupViewModel into a SearchPlaylist or SearchVideo.
# Returns nil when the given object is not a lockupViewModel. # Returns nil when the given object is not a lockupViewModel.
# #
# This structure is present since November 2024 on the "podcasts" and # This structure is present since November 2024 on the "podcasts", "playlists",
# "playlists" tabs of the channel page. It is usually encapsulated in either # and "videos" tabs of the channel page. It is usually encapsulated in either
# a richItemRenderer or a richGridRenderer. # a richItemRenderer or a richGridRenderer.
# #
module LockupViewModelParser module LockupViewModelParser
@ -648,12 +648,26 @@ private module Parsers
end end
private def parse_internal(item_contents, author_fallback) private def parse_internal(item_contents, author_fallback)
playlist_id = item_contents["contentId"].as_s content_id = item_contents["contentId"].as_s
thumbnail_view_model = item_contents.dig( # A watchEndpoint with a playlistId means this item is a playlist.
# If it only has a videoId (no playlistId), it's a regular video.
# Fall back to content_id length: video IDs are always exactly 11 chars.
watch_endpoint = item_contents.dig?(
"rendererContext", "commandContext", "onTap",
"innertubeCommand", "watchEndpoint"
)
is_playlist = watch_endpoint.try(&.["playlistId"]?) || content_id.size != 11
thumbnail_view_model = item_contents.dig?(
"contentImage", "collectionThumbnailViewModel",
"primaryThumbnail", "thumbnailViewModel"
) || item_contents.dig?(
"contentImage", "thumbnailViewModel" "contentImage", "thumbnailViewModel"
) )
return nil if thumbnail_view_model.nil?
thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s
# This complicated sequences tries to extract the following data structure: # This complicated sequences tries to extract the following data structure:
@ -667,16 +681,10 @@ private module Parsers
# }] # }]
# } # }
# }] # }]
# all_badge_texts = thumbnail_view_model.dig("overlays").as_a
# NOTE: this simplistic `.to_i` conversion might not work on larger
# playlists and hasn't been tested.
video_count = thumbnail_view_model.dig("overlays").as_a
.compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a) .compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a)
.flatten .flatten
.find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node| .compact_map(&.dig?("thumbnailBadgeViewModel", "text").try &.as_s)
{"episodes", "videos"}.any? { |str| node.as_s.ends_with?(str) }
})
.try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false)
metadata = item_contents.dig("metadata", "lockupMetadataViewModel") metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
title = metadata.dig("title", "content").as_s title = metadata.dig("title", "content").as_s
@ -686,20 +694,51 @@ private module Parsers
# parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s) # parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s)
# One of these parts should contain a string like: "Updated 2 days ago" # One of these parts should contain a string like: "Updated 2 days ago"
# TODO: Maybe add a button to access the first video of the playlist? if is_playlist
# item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint") # NOTE: this simplistic `.to_i` conversion might not work on larger
# Available fields: "videoId", "playlistId", "params" # playlists and hasn't been tested.
return SearchPlaylist.new({ # Prefer badges explicitly labeled "videos" or "episodes" (case-insensitive)
title: title, video_count = all_badge_texts
id: playlist_id, .find { |t| {"episodes", "videos"}.any? { |s| t.downcase.ends_with?(s) } }
author: author_fallback.name, .try &.to_i(strict: false)
ucid: author_fallback.id,
video_count: video_count || -1, # Fallback: any badge whose text starts with a digit
videos: [] of SearchPlaylistVideo, video_count ||= all_badge_texts
thumbnail: thumbnail, .find { |t| t[0]?.try &.ascii_number? }
author_verified: false, .try &.to_i(strict: false)
})
return SearchPlaylist.new({
title: title,
id: content_id,
author: author_fallback.name,
ucid: author_fallback.id,
video_count: video_count || -1,
videos: [] of SearchPlaylistVideo,
thumbnail: thumbnail,
author_verified: false,
})
else
# Extract duration from a time-format badge (e.g. "5:30", "1:23:45")
length_seconds = all_badge_texts
.find { |t| /^\d+:\d+/.match(t) }
.try { |t| decode_length_seconds(t) } || 0
return SearchVideo.new({
title: title,
id: content_id,
author: author_fallback.name,
ucid: author_fallback.id,
published: Time.unix(0),
views: 0_i64,
description_html: "",
length_seconds: length_seconds,
premiere_timestamp: Time.unix(0),
author_verified: false,
author_thumbnail: nil,
badges: VideoBadges::None,
})
end
end end
def self.parser_name def self.parser_name