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.",
"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_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_plural": "{{count}} channels",
"generic_views_count": "{{count}} view",

View File

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

View File

@ -40,6 +40,8 @@ module Invidious::Routes::Search
preferences = env.get("preferences").as(Preferences)
locale = preferences.locale
search_disabled = !CONFIG.page_enabled?("search")
region = env.params.query["region"]? || preferences.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
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
user = env.get?("user")
if query.url?
@ -59,7 +90,7 @@ module Invidious::Routes::Search
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)}”…"
return error_template 404, "Unable to find channel with id "#{HTML.escape(ex.channel)}"…"
rescue ex
return error_template 500, ex
end

View File

@ -52,5 +52,57 @@ module Invidious::Search
as: ChannelVideo
)
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

View File

@ -1,12 +1,13 @@
<form class="pure-form" action="/search" method="get">
<fieldset>
<% search_placeholder = CONFIG.page_enabled?("search") ? translate(locale, "search") : translate(locale, "search_subscriptions_placeholder") %>
<input type="search" id="searchbox" autocorrect="off"
autocapitalize="none" spellcheck="false" <% if autofocus %>autofocus<% end %>
name="q" placeholder="<%= translate(locale, "search") %>"
title="<%= translate(locale, "search") %>"
name="q" placeholder="<%= search_placeholder %>"
title="<%= search_placeholder %>"
value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
</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>
</button>
</form>

View File

@ -3,17 +3,31 @@
<link rel="stylesheet" href="/css/search.css?v=<%= ASSET_COMMIT %>">
<% 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 -->
<%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
<hr/>
<% end %>
<%- if items.empty? -%>
<div class="h-box no-results-error">
<div>
<% if subscription_only %>
<%= translate(locale, "search_subscriptions_no_results") %><br/><br/>
<%= 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>
<%- else -%>

View File

@ -12,11 +12,14 @@
<div class="pure-u-1" id="logo">
<h1 href="/" class="pure-menu-heading">Invidious</h1>
</div>
<% if CONFIG.page_enabled?("search") %>
<div class="pure-u-1-4"></div>
<div class="pure-u-1 pure-u-md-12-24 searchbar">
<% autofocus = true %><%= rendered "components/search_box" %>
</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 %>
</div>

View File

@ -31,17 +31,13 @@
<div class="pure-g">
<div class="pure-u-1 pure-u-xl-20-24" id="contents">
<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">
<a href="/" class="index-link pure-menu-heading">Invidious</a>
</div>
<div class="pure-u-1 pure-u-md-12-24 searchbar">
<% autofocus = false %><%= rendered "components/search_box" %>
</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 %>
<div class="pure-u-1 pure-u-md-8-24 user-field">