mirror of
https://github.com/iv-org/invidious.git
synced 2026-03-31 15:18:30 -05:00
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:
parent
562389bc6d
commit
0eb941f498
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
# non‐empty 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 -%>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user