From 0eb941f498259d7fec78bb7f43e4c31ab65500d3 Mon Sep 17 00:00:00 2001 From: NorkzYT Date: Sun, 22 Mar 2026 22:27:17 +0000 Subject: [PATCH] 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 --- locales/en-US.json | 6 +++ src/invidious/routes/before_all.cr | 9 +++- src/invidious/routes/search.cr | 33 +++++++++++- src/invidious/search/processors.cr | 52 +++++++++++++++++++ src/invidious/views/components/search_box.ecr | 7 +-- src/invidious/views/search.ecr | 20 +++++-- src/invidious/views/search_homepage.ecr | 5 +- src/invidious/views/template.ecr | 6 +-- 8 files changed, 123 insertions(+), 15 deletions(-) diff --git a/locales/en-US.json b/locales/en-US.json index 738af1d4..f75cd219 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -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", diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr index 841bbab2..4cd2f470 100644 --- a/src/invidious/routes/before_all.cr +++ b/src/invidious/routes/before_all.cr @@ -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 diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index f8babb72..a8999a5e 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -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 diff --git a/src/invidious/search/processors.cr b/src/invidious/search/processors.cr index 25edb936..1416c58f 100644 --- a/src/invidious/search/processors.cr +++ b/src/invidious/search/processors.cr @@ -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 diff --git a/src/invidious/views/components/search_box.ecr b/src/invidious/views/components/search_box.ecr index 29da2c52..3fb31628 100644 --- a/src/invidious/views/components/search_box.ecr +++ b/src/invidious/views/components/search_box.ecr @@ -1,12 +1,13 @@
+ <% search_placeholder = CONFIG.page_enabled?("search") ? translate(locale, "search") : translate(locale, "search_subscriptions_placeholder") %> 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)) } %>">
-
diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index b1300214..0848bfef 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -3,17 +3,31 @@ <% end %> +<% subscription_only = env.get?("subscription_only_search") %> + +<% if subscription_only %> +
+

<%= translate(locale, "search_subscriptions_mode_notice") %>

+
+
+<% else %> <%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
+<% end %> <%- if items.empty? -%>
- <%= translate(locale, "search_message_no_results") %>

- <%= translate(locale, "search_message_change_filters_or_query") %>

- <%= translate(locale, "search_message_use_another_instance", redirect_url) %> + <% if subscription_only %> + <%= translate(locale, "search_subscriptions_no_results") %>

+ <%= translate(locale, "search_subscriptions_no_results_hint") %> + <% else %> + <%= translate(locale, "search_message_no_results") %>

+ <%= translate(locale, "search_message_change_filters_or_query") %>

+ <%= translate(locale, "search_message_use_another_instance", redirect_url) %> + <% end %>
<%- else -%> diff --git a/src/invidious/views/search_homepage.ecr b/src/invidious/views/search_homepage.ecr index f651dc5c..738809b7 100644 --- a/src/invidious/views/search_homepage.ecr +++ b/src/invidious/views/search_homepage.ecr @@ -12,11 +12,14 @@ - <% if CONFIG.page_enabled?("search") %>
+ <% if !CONFIG.page_enabled?("search") %> +
+

<%= translate(locale, "search_subscriptions_hint") %>

+
<% end %> diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 9aa45e68..40f5544f 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -31,17 +31,13 @@