feat(search): subscription-only search mode when YouTube search is disabled

When pages_enabled.search is false, instead of blocking search entirely,
the search bar now searches within the user's subscriptions and saved
playlists only. YouTube's search API is never called.

Changes:
- routes/search.cr: detect search_disabled, force subscription+playlist
  search for logged-in users, 403 for anonymous users
- search/processors.cr: new subscriptions_and_playlists() method that
  queries both the subscription materialized view and playlist_videos
  table, merges and deduplicates results
- before_all.cr: let /search through to the route handler (APIs still
  blocked), keep /hashtag/* blocked
- search_box.ecr: dynamic placeholder ("Search subscriptions & playlists")
- search_homepage.ecr: hint text when in subscription-only mode
- search.ecr: hide YouTube filters, show lock icon + notice, custom
  empty-results message
- template.ecr: always show search bar (subscription search needs it)
- en-US.json: 6 new locale strings for subscription-only search UX
This commit is contained in:
NorkzYT 2026-03-22 22:27:17 +00:00
parent 562389bc6d
commit 0eb941f498
8 changed files with 123 additions and 15 deletions

View File

@ -6,6 +6,12 @@
"popular_page_disabled": "The Popular feed has been disabled by the administrator.", "popular_page_disabled": "The Popular feed has been disabled by the administrator.",
"trending_page_disabled": "The Trending feed has been disabled by the administrator.", "trending_page_disabled": "The Trending feed has been disabled by the administrator.",
"search_page_disabled": "The Search feature has been disabled by the administrator.", "search_page_disabled": "The Search feature has been disabled by the administrator.",
"search_subscriptions_placeholder": "Search subscriptions & playlists...",
"search_subscriptions_hint": "YouTube search is disabled. Search within your subscriptions and playlists.",
"search_subscriptions_login_required": "Please log in to search your subscriptions and playlists.",
"search_subscriptions_mode_notice": "Searching within your subscriptions and playlists only.",
"search_subscriptions_no_results": "No results found in your subscriptions or playlists.",
"search_subscriptions_no_results_hint": "Try a different search term, or make sure the channel is in your subscriptions.",
"generic_channels_count": "{{count}} channel", "generic_channels_count": "{{count}} channel",
"generic_channels_count_plural": "{{count}} channels", "generic_channels_count_plural": "{{count}} channels",
"generic_views_count": "{{count}} view", "generic_views_count": "{{count}} view",

View File

@ -150,9 +150,14 @@ module Invidious::Routes::BeforeAll
"popular" "popular"
when "/feed/trending", "/api/v1/trending" when "/feed/trending", "/api/v1/trending"
"trending" "trending"
when "/search", "/api/v1/search", "/api/v1/search/suggestions", "/results" when "/api/v1/search", "/api/v1/search/suggestions"
"search" "search"
when .starts_with?("/hashtag/"), .starts_with?("/api/v1/hashtag/") 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" "search"
else else
nil nil

View File

@ -40,6 +40,8 @@ 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)
@ -50,6 +52,35 @@ module Invidious::Routes::Search
return templated "search_homepage", navbar_search: false return templated "search_homepage", navbar_search: false
end end
# When YouTube search is disabled, force subscription-only mode
if search_disabled
user = env.get?("user")
if !user
return error_template(403, translate(locale, "search_subscriptions_login_required"))
end
user = user.as(User)
begin
items = Invidious::Search::Processors.subscriptions_and_playlists(query, user)
rescue ex
return error_template 500, ex
end
redirect_url = Invidious::Frontend::Misc.redirect_url(env)
page_nav_html = Frontend::Pagination.nav_numeric(locale,
base_url: "/search?#{query.to_http_params}",
current_page: query.page,
show_next: (items.size >= 20)
)
env.set "search", query.text
env.set "subscription_only_search", true
return templated "search"
end
# nonempty query → process it # nonempty query → process it
user = env.get?("user") user = env.get?("user")
if query.url? if query.url?
@ -59,7 +90,7 @@ module Invidious::Routes::Search
begin begin
items = user ? query.process(user.as(User)) : query.process items = user ? query.process(user.as(User)) : query.process
rescue ex : ChannelSearchException rescue ex : ChannelSearchException
return error_template 404, "Unable to find channel with id #{HTML.escape(ex.channel)}”…" return error_template 404, "Unable to find channel with id "#{HTML.escape(ex.channel)}"…"
rescue ex rescue ex
return error_template 500, ex return error_template 500, ex
end end

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

@ -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

@ -12,11 +12,14 @@
<div class="pure-u-1" id="logo"> <div class="pure-u-1" id="logo">
<h1 href="/" class="pure-menu-heading">Invidious</h1> <h1 href="/" class="pure-menu-heading">Invidious</h1>
</div> </div>
<% if CONFIG.page_enabled?("search") %>
<div class="pure-u-1-4"></div> <div class="pure-u-1-4"></div>
<div class="pure-u-1 pure-u-md-12-24 searchbar"> <div class="pure-u-1 pure-u-md-12-24 searchbar">
<% 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" style="text-align: center; margin-top: 0.5em;">
<p style="color: var(--text-color-muted, #888);"><%= translate(locale, "search_subscriptions_hint") %></p>
</div>
<% end %> <% end %>
</div> </div>

View File

@ -31,17 +31,13 @@
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-1 pure-u-xl-20-24" id="contents"> <div class="pure-u-1 pure-u-xl-20-24" id="contents">
<div class="pure-g navbar h-box"> <div class="pure-g navbar h-box">
<% if navbar_search && CONFIG.page_enabled?("search") %> <% if navbar_search %>
<div class="pure-u-1 pure-u-md-4-24"> <div class="pure-u-1 pure-u-md-4-24">
<a href="/" class="index-link pure-menu-heading">Invidious</a> <a href="/" class="index-link pure-menu-heading">Invidious</a>
</div> </div>
<div class="pure-u-1 pure-u-md-12-24 searchbar"> <div class="pure-u-1 pure-u-md-12-24 searchbar">
<% autofocus = false %><%= rendered "components/search_box" %> <% autofocus = false %><%= rendered "components/search_box" %>
</div> </div>
<% elsif navbar_search %>
<div class="pure-u-1 pure-u-md-16-24">
<a href="/" class="index-link pure-menu-heading">Invidious</a>
</div>
<% end %> <% end %>
<div class="pure-u-1 pure-u-md-8-24 user-field"> <div class="pure-u-1 pure-u-md-8-24 user-field">