feat(search): live YouTube search UI

Surface live YouTube search in the existing feed, triggered explicitly so the
expensive API call is never per-keystroke.

- Header: the search box still filters the local catalog as you type; Enter or a
  YouTube button escalates the term to a live search (hidden for the demo account).
- Feed: a dedicated infinite query renders results in the same VirtualFeed cards +
  in-app player, under a banner with a back button and a quota note. No auto-paginate
  (each page spends 100 units) — an explicit 'Load more (uses quota)' button instead;
  quota/limit errors (incl. 429) shown inline. The empty local feed offers a
  'Search YouTube for <q>' CTA.
- Library: a 'Search-discovered' toggle reveals search-ingested videos (hidden by
  default); sent as exclude_search_discovered.
- Admin: search_daily_limit_per_user config field; new videos_search quota label.
- All new strings translated in HU/EN/DE.
This commit is contained in:
npeter83 2026-06-29 02:01:44 +02:00
parent 9b1bdb6b42
commit 546be57963
16 changed files with 240 additions and 15 deletions

View file

@ -141,6 +141,9 @@ export interface FeedFilters {
includeShorts: boolean;
includeLive: boolean;
show: string;
// Library (scope "all") only: when set, also show videos that entered the catalog via a
// live YouTube search. Default (falsy) hides that search-discovered "noise".
showSearchDiscovered?: boolean;
channelId?: string;
channelName?: string;
maxAgeDays?: number;
@ -306,6 +309,8 @@ function filterParams(f: FeedFilters): URLSearchParams {
if (f.dateTo) p.set("published_before", f.dateTo);
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
// Only relevant in Library scope; the backend ignores it for "my". Default = hide.
p.set("exclude_search_discovered", String(!f.showSearchDiscovered));
return p;
}
@ -558,6 +563,14 @@ export const api = {
req(`/api/feed?${feedQuery(f, cursor, limit)}`),
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
req(`/api/feed/count?${filterParams(f).toString()}`),
// Live YouTube search: results are materialised into the catalog and returned as feed cards.
// `quiet` so the YT-search panel can render quota/limit errors (incl. 429) inline instead of
// popping the global modal. `cursor` is the YouTube nextPageToken (each page spends 100 units).
searchYoutube: (q: string, cursor: string | null): Promise<FeedResponse> => {
const p = new URLSearchParams({ q });
if (cursor) p.set("cursor", cursor);
return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true });
},
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
req(`/api/facets?${filterParams(f).toString()}`),
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these