Merge 7ca2bbd7685f2695f168fb525b14337457a7b4dc into 749791cdf1316bc89415d27d503042d3f6b3f398

This commit is contained in:
Richard Lora 2026-03-22 22:04:28 -04:00 committed by GitHub
commit 0dc7099130
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1034 additions and 575 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.
# Use halt with the already-set status code to prevent the route handler from running.
if env.get?("halted")
halt env, status_code: env.response.status_code
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

@ -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,72 @@ 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)
env.response.status_code = 403
env.set "halted", true
if path.starts_with?("/api/")
env.response.content_type = "application/json"
env.response.print({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 = translate(locale, "#{page_key}_page_disabled")
env.response.content_type = "text/html"
env.response.print <<-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="/"> #{translate(locale, "Back")}</a></p>
</div>
</div>
</div>
</body>
</html>
HTML
end
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

@ -33,13 +33,7 @@ module Invidious::Routes::Feeds
def self.popular(env) def self.popular(env)
locale = env.get("preferences").as(Preferences).locale locale = env.get("preferences").as(Preferences).locale
templated "feeds/popular"
if CONFIG.popular_enabled
templated "feeds/popular"
else
message = translate(locale, "The Popular feed has been disabled by the administrator.")
templated "message"
end
end end
def self.trending(env) def self.trending(env)

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, 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">
<%= translate(locale, feed) %> <%= translate(locale, feed) %>

View File

@ -1,12 +1,13 @@
<form class="pure-form" action="/search" method="get"> <form class="pure-form" action="/search" method="get">
<fieldset> <fieldset>
<% search_placeholder = CONFIG.page_enabled?("search") ? translate(locale, "search") : 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="<%= translate(locale, "search") %>" name="q" placeholder="<%= search_placeholder %>"
title="<%= translate(locale, "search") %>" title="<%= search_placeholder %>"
value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>"> value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
</fieldset> </fieldset>
<button type="submit" id="searchbutton" aria-label="<%= translate(locale, "search") %>"> <button type="submit" id="searchbutton" aria-label="<%= search_placeholder %>">
<i class="icon ion-ios-search"></i> <i class="icon ion-ios-search"></i>
</button> </button>
</form> </form>

View File

@ -3,17 +3,31 @@
<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> <%= 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? -%>
<div class="h-box no-results-error"> <div class="h-box no-results-error">
<div> <div>
<%= translate(locale, "search_message_no_results") %><br/><br/> <% if subscription_only %>
<%= translate(locale, "search_message_change_filters_or_query") %><br/><br/> <%= translate(locale, "search_subscriptions_no_results") %><br/><br/>
<%= translate(locale, "search_message_use_another_instance", redirect_url) %> <%= translate(locale, "search_subscriptions_no_results_hint") %>
<% else %>
<%= translate(locale, "search_message_no_results") %><br/><br/>
<%= translate(locale, "search_message_change_filters_or_query") %><br/><br/>
<%= translate(locale, "search_message_use_another_instance", redirect_url) %>
<% end %>
</div> </div>
</div> </div>
<%- else -%> <%- else -%>

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;"><%= 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"><%= translate(locale, "preferences_default_home_label") %></label> <label for="default_home"><%= translate(locale, "preferences_default_home_label") %></label>
@ -302,9 +311,18 @@
<div class="pure-control-group"> <div class="pure-control-group">
<label for="popular_enabled"><%= translate(locale, "Popular enabled: ") %></label> <label for="popular_enabled"><%= translate(locale, "Popular enabled: ") %></label>
<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.page_enabled?("popular") %>checked<% end %>>
</div> </div>
<div class="pure-control-group">
<label for="trending_enabled"><%= translate(locale, "preferences_trending_enabled_label") %></label>
<input name="trending_enabled" id="trending_enabled" type="checkbox" <% if CONFIG.page_enabled?("trending") %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="search_enabled"><%= 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"><%= translate(locale, "CAPTCHA enabled: ") %></label> <label for="captcha_enabled"><%= translate(locale, "CAPTCHA enabled: ") %></label>