Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
e613e3c
feat: optional `trending_enabled` and `search_enabled` params added
Apr 30, 2024
5fa2935
fix(feeds.cr): http status code
NorkzYT May 3, 2024
3abf216
fix(search.cr): http status code
May 3, 2024
e0f20b0
style(feeds.cr): fix code formatting
May 3, 2024
da422fa
feat(invidious): specific pages are disabled with the use of an array
Jun 30, 2024
ba7e504
chore(search.cr): add newline at end of file
Jun 30, 2024
bfe5159
chore(lint): format with crystal tool
Jul 1, 2024
2ec34f3
Update config/config.example.yml
NorkzYT Jul 1, 2024
b0cbd29
feat(en-US.json): add translation key and value
Jul 1, 2024
fdec904
Update src/invidious/views/user/preferences.ecr
NorkzYT Jul 1, 2024
209360e
Merge branch 'master' into optional-disable-api-features
NorkzYT Sep 3, 2024
b4f8a67
Merge branch 'iv-org:master' into optional-disable-api-features
NorkzYT Nov 11, 2024
a077509
Merge branch 'master' into optional-disable-api-features
NorkzYT May 21, 2025
aeb2b10
feat: test
May 21, 2025
ce0a9fa
feat: test2
May 22, 2025
171bac9
Update locales/en-US.json
NorkzYT May 24, 2025
e1d134f
Update locales/en-US.json
NorkzYT May 24, 2025
9fbce52
📝 (locales/en-US.json): remove redundant translation strings
NorkzYT May 24, 2025
3eeec86
🌐 (feeds.cr, search.cr): update translation keys for disabled feeds a…
NorkzYT May 24, 2025
e238624
feat(config.cr): introduce PagesEnabled struct for managing feature t…
NorkzYT Jun 7, 2025
116a5db
Merge branch 'iv-org:master' into optional-disable-api-features
NorkzYT Jun 7, 2025
ba65e4f
Config: Use from_yaml constructor for PagesEnabled
syeopite Aug 25, 2025
d496b6e
Use `PagesEnabled` struct when setting pages_enabled
syeopite Aug 25, 2025
f978c2b
Fix config precedence with popular_enabled
syeopite Aug 25, 2025
245ffc8
Mark attributes set over env var as present if needed
syeopite Aug 25, 2025
20e4e52
Add tests for `popular_enabled` deprecation logic
syeopite Aug 25, 2025
24d0724
chore: disable trending by default
NorkzYT Oct 28, 2025
dee1bd6
chore: missing hash key issue
NorkzYT Oct 28, 2025
ef59312
latest from master
NorkzYT Nov 16, 2025
03634db
Merge branch 'master' into optional-disable-api-features
NorkzYT Nov 16, 2025
8c922d0
revert docker compose file
NorkzYT Nov 16, 2025
2933c7e
Merge branch 'master' into optional-disable-api-features
NorkzYT Feb 28, 2026
bd6dd9c
feat: add search_page_disabled locale + preferences page filtering + …
NorkzYT Mar 1, 2026
ea36b2b
fix(preferences): unchecked pages_enabled checkboxes now persist corr…
NorkzYT Mar 22, 2026
562389b
fix(pages_enabled): hide search UI and block all search routes when d…
NorkzYT Mar 22, 2026
0eb941f
feat(search): subscription-only search mode when YouTube search is di…
NorkzYT Mar 22, 2026
58ced28
fix(playlists): prevent duplicate videos in the same playlist
NorkzYT Mar 23, 2026
8cd5f0f
fix(search_homepage): center subscription hint text under search bar
NorkzYT Mar 23, 2026
7ca2bbd
fix(before_all): halt response after rendering disabled page error
NorkzYT Mar 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
## Default: true
#pages_enabled:
# trending: false
# popular: true
# search: true
##
#popular_enabled: true

##
## Enable/Disable statstics (available at /api/v1/stats).
Expand Down
4 changes: 0 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ services:
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
depends_on:
invidious-db:
condition: service_healthy
restart: true
environment:
# Please read the following file for a comprehensive list of all available
# configuration options and their associated syntax:
Expand Down
1,026 changes: 518 additions & 508 deletions locales/en-US.json

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions spec/invidious/config_spec.cr
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/ext/kemal_static_file_handler.cr
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ module Kemal
if is_dir
if config.is_a?(Hash) && config["dir_listing"] == true
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
call_next(context)
end
Expand Down
8 changes: 7 additions & 1 deletion src/invidious.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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)
end

if CONFIG.popular_enabled
if CONFIG.page_enabled?("popular")
Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB)
end

Expand All @@ -200,6 +200,12 @@ end

before_all do |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

Invidious::Routing.register_all
Expand Down
90 changes: 84 additions & 6 deletions src/invidious/config.cr
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,31 @@ struct HTTPProxyConfig
property port : Int32
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
include YAML::Serializable

Expand Down Expand Up @@ -116,13 +141,37 @@ class Config

# Used to tell Invidious it is behind a proxy, so links to resources should be https://
property https_only : Bool?

# HMAC signing key for CSRF tokens and verifying pubsub subscriptions
property hmac_key : String = ""
# Domain to be used for links to resources on the site where an absolute URL is required
property domain : String?
# Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
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

@[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 login_enabled : Bool = true
property registration_enabled : Bool = true
Expand Down Expand Up @@ -185,16 +234,17 @@ class Config
when Bool
return disabled
when Array
if disabled.includes? option
return true
else
return false
end
disabled.includes?(option)
else
return false
false
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
# Load config from file or YAML string env var
env_config_file = "INVIDIOUS_CONFIG_FILE"
Expand Down Expand Up @@ -232,6 +282,12 @@ class Config
begin
config.{{ivar.id}} = ivar_type.from_yaml(env_value)
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
# nop
end
Expand Down Expand Up @@ -283,6 +339,8 @@ class Config
exit(1)
end

config.process_deprecation

# Build database_url from db.* if it's not set directly
if config.database_url.to_s.empty?
if db = config.db
Expand Down Expand Up @@ -320,4 +378,24 @@ class Config

return config
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
5 changes: 5 additions & 0 deletions src/invidious/routes/api/v1/authenticated.cr
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,11 @@ module Invidious::Routes::API::V1::Authenticated
return error_json(403, "Invalid videoId")
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
video = get_video(video_id)
rescue ex : NotFoundException
Expand Down
5 changes: 0 additions & 5 deletions src/invidious/routes/api/v1/feeds.cr
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,6 @@ module Invidious::Routes::API::V1::Feeds

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.array do
popular_videos.each do |video|
Expand Down
71 changes: 70 additions & 1 deletion src/invidious/routes/before_all.cr
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ module Invidious::Routes::BeforeAll
preferences.locale = locale
env.set "preferences", preferences

path = env.request.path

# Allow media resources to be loaded from google servers
# TODO: check if *.youtube.com can be removed
#
Expand All @@ -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")
end

current_page = env.request.path
current_page = path
if env.request.query
query = HTTP::Params.parse(env.request.query.not_nil!)

Expand All @@ -142,5 +144,72 @@ module Invidious::Routes::BeforeAll
end

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
Loading