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.",
|
"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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
# non‐empty query → process it
|
# non‐empty 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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 -%>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user