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

@ -122,6 +122,9 @@ export default function App() {
const saveMsgTimer = useRef<number | undefined>(undefined); const saveMsgTimer = useRef<number | undefined>(undefined);
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout); const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
const [page, setPageState] = useState<Page>(loadInitialPage); const [page, setPageState] = useState<Page>(loadInitialPage);
// Live YouTube search term (null = normal feed). Ephemeral: not persisted and not in the URL,
// so a reload returns to the normal feed (a search spends quota, so we never auto-replay it).
const [ytSearch, setYtSearch] = useState<string | null>(null);
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render. // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all"); const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all");
@ -153,6 +156,8 @@ export default function App() {
}; };
useEffect(() => { useEffect(() => {
if (page !== "channels") setFocusChannelName(null); if (page !== "channels") setFocusChannelName(null);
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed.
if (page !== "feed") setYtSearch(null);
}, [page]); }, [page]);
function openReleaseNotes(highlight?: string) { function openReleaseNotes(highlight?: string) {
@ -500,6 +505,7 @@ export default function App() {
filters={filters} filters={filters}
setFilters={setFilters} setFilters={setFilters}
page={page} page={page}
onYtSearch={setYtSearch}
onGoToFullHistory={() => { onGoToFullHistory={() => {
setChannelFilter("needs_full"); setChannelFilter("needs_full");
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
@ -572,6 +578,8 @@ export default function App() {
canRead={meQuery.data!.can_read} canRead={meQuery.data!.can_read}
isDemo={meQuery.data!.is_demo} isDemo={meQuery.data!.is_demo}
onOpenWizard={() => setWizardOpen(true)} onOpenWizard={() => setWizardOpen(true)}
ytSearch={ytSearch}
setYtSearch={setYtSearch}
/> />
)} )}
</main> </main>

View file

@ -1,8 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, RefreshCw } from "lucide-react"; import { ArrowDown, ArrowUp, ArrowLeft, RefreshCw, Youtube } from "lucide-react";
import { api, type FeedFilters, type Video } from "../lib/api"; import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
import i18n from "../i18n"; import i18n from "../i18n";
import { notify, resolveVideo } from "../lib/notifications"; import { notify, resolveVideo } from "../lib/notifications";
import VirtualFeed from "./VirtualFeed"; import VirtualFeed from "./VirtualFeed";
@ -70,6 +70,8 @@ export default function Feed({
canRead, canRead,
isDemo = false, isDemo = false,
onOpenWizard, onOpenWizard,
ytSearch,
setYtSearch,
}: { }: {
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
@ -77,6 +79,10 @@ export default function Feed({
canRead: boolean; canRead: boolean;
isDemo?: boolean; isDemo?: boolean;
onOpenWizard: () => void; onOpenWizard: () => void;
// Live YouTube search: the active search term (null = normal feed), and its setter so the
// empty-state CTA / "back to feed" can toggle it. Search affordances are hidden for demo.
ytSearch: string | null;
setYtSearch: (q: string | null) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [overrides, setOverrides] = useState<Record<string, string>>({}); const [overrides, setOverrides] = useState<Record<string, string>>({});
@ -92,11 +98,27 @@ export default function Feed({
[] []
); );
const ytActive = !!ytSearch;
const query = useInfiniteQuery({ const query = useInfiniteQuery({
queryKey: ["feed", filters], queryKey: ["feed", filters],
queryFn: ({ pageParam }) => api.feed(filters, pageParam as string | null, PAGE), queryFn: ({ pageParam }) => api.feed(filters, pageParam as string | null, PAGE),
initialPageParam: null as string | null, initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined, getNextPageParam: (last) => last.next_cursor ?? undefined,
enabled: !ytActive, // don't load the normal feed while showing live YouTube results
});
// Live YouTube search results. Each page spends 100 quota units, so we never auto-paginate
// on scroll — the user pulls more with an explicit button. retry:false so a 429 (quota/limit)
// surfaces at once for inline display. staleTime keeps a revisited search from re-spending.
const ytQuery = useInfiniteQuery({
queryKey: ["yt-search", ytSearch],
queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
enabled: ytActive,
staleTime: 5 * 60_000,
retry: false,
}); });
// Drop optimistic status overrides when filters change OR fresh server data // Drop optimistic status overrides when filters change OR fresh server data
@ -116,6 +138,7 @@ export default function Feed({
queryKey: ["feed-count", filters], queryKey: ["feed-count", filters],
queryFn: () => api.feedCount(filters), queryFn: () => api.feedCount(filters),
staleTime: 30_000, staleTime: 30_000,
enabled: !ytActive,
}); });
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query; const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
@ -232,6 +255,86 @@ export default function Feed({
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v)) .map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v))
.filter((v) => matchesView(v.status, filters.show)); .filter((v) => matchesView(v.status, filters.show));
// --- Live YouTube search mode -------------------------------------------------------------
// A separate data source rendered in the same cards/player. No watch-state view filter (the
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
if (ytActive) {
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
loadedRef.current = ytLoaded;
const ytItems: Video[] = ytLoaded
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v));
const ytError =
ytQuery.error instanceof HttpError
? ytQuery.error.detail || t("feed.yt.error")
: ytQuery.isError
? t("feed.yt.error")
: null;
return (
<div className="p-4">
<div className="flex items-center gap-2 flex-wrap pb-3 mb-1 border-b border-border">
<button
onClick={() => setYtSearch(null)}
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0"
>
<ArrowLeft className="w-4 h-4" />
{t("feed.yt.back")}
</button>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<Youtube className="w-4 h-4 text-accent shrink-0" />
<span className="text-sm font-semibold truncate">
{t("feed.yt.resultsFor", { query: ytSearch })}
</span>
<span className="text-xs text-muted">{t("feed.yt.quotaNote")}</span>
</div>
{ytQuery.isLoading ? (
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
) : ytError ? (
<div className="py-16 text-center text-muted max-w-md mx-auto">{ytError}</div>
) : ytItems.length === 0 ? (
<div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div>
) : (
<>
<VirtualFeed
items={ytItems}
view={view}
onState={onState}
onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter}
onResetState={onResetState}
onOpen={openVideo}
hasNextPage={false}
isFetchingNextPage={false}
fetchNextPage={() => {}}
/>
{ytQuery.hasNextPage && (
<div className="text-center pt-4">
<button
onClick={() => ytQuery.fetchNextPage()}
disabled={ytQuery.isFetchingNextPage}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-border bg-card text-sm font-medium hover:border-accent hover:text-accent disabled:opacity-50 transition"
>
{ytQuery.isFetchingNextPage ? t("feed.loadingMore") : t("feed.yt.loadMore")}
</button>
</div>
)}
</>
)}
{activeVideo && (
<PlayerModal
video={activeVideo.video}
startAt={activeVideo.startAt}
onClose={() => setActiveVideo(null)}
onState={onState}
/>
)}
</div>
);
}
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>; if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>; if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
// Empty "my" feed with no YouTube access. The shared demo account can never connect // Empty "my" feed with no YouTube access. The shared demo account can never connect
@ -311,6 +414,25 @@ export default function Feed({
</button> </button>
); );
})} })}
{filters.scope === "all" && (
<>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<button
onClick={() =>
setFilters({ ...filters, showSearchDiscovered: !filters.showSearchDiscovered })
}
aria-pressed={!!filters.showSearchDiscovered}
title={t("feed.searchDiscoveredTip")}
className={`text-xs px-3 py-1.5 rounded-full border transition ${
filters.showSearchDiscovered
? "bg-accent text-accent-fg border-accent"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{t("feed.searchDiscovered")}
</button>
</>
)}
</div> </div>
<div className="flex items-center gap-2 border-t border-border pt-2.5"> <div className="flex items-center gap-2 border-t border-border pt-2.5">
<span className="text-sm text-muted"> <span className="text-sm text-muted">
@ -372,7 +494,18 @@ export default function Feed({
<div className="p-4"> <div className="p-4">
{toolbar} {toolbar}
{items.length === 0 ? ( {items.length === 0 ? (
<div className="py-16 text-center text-muted">{t("feed.noMatches")}</div> <div className="py-16 text-center text-muted">
<p>{t("feed.noMatches")}</p>
{filters.q.trim() && !isDemo && (
<button
onClick={() => setYtSearch(filters.q.trim())}
className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<Youtube className="w-4 h-4" />
{t("feed.yt.searchFor", { query: filters.q.trim() })}
</button>
)}
</div>
) : ( ) : (
<VirtualFeed <VirtualFeed
items={items} items={items}

View file

@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Library, Search, User } from "lucide-react"; import { Library, Search, User, Youtube } from "lucide-react";
import type { FeedFilters, Me } from "../lib/api"; import type { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import SyncStatus from "./SyncStatus"; import SyncStatus from "./SyncStatus";
@ -13,14 +13,20 @@ export default function Header({
setFilters, setFilters,
page, page,
onGoToFullHistory, onGoToFullHistory,
onYtSearch,
}: { }: {
me: Me; me: Me;
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
page: Page; page: Page;
onGoToFullHistory: () => void; onGoToFullHistory: () => void;
// Trigger a live YouTube search for the current term (hidden for the demo account, which
// can't spend the shared quota).
onYtSearch: (q: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const canSearchYt = !me.is_demo;
const trimmedQ = filters.q.trim();
return ( return (
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20"> <header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
@ -56,14 +62,31 @@ export default function Header({
)} )}
{page === "feed" ? ( {page === "feed" ? (
<div className="flex-1 max-w-xl mx-auto relative"> <div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" /> <div className="flex-1 relative">
<input <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
value={filters.q} <input
onChange={(e) => setFilters({ ...filters, q: e.target.value })} value={filters.q}
placeholder={t("header.searchPlaceholder")} onChange={(e) => setFilters({ ...filters, q: e.target.value })}
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent" onKeyDown={(e) => {
/> // Enter runs a live YouTube search for the typed term (the box itself filters
// the local catalog as you type; Enter escalates to the YouTube API).
if (e.key === "Enter" && trimmedQ && canSearchYt) onYtSearch(trimmedQ);
}}
placeholder={t("header.searchPlaceholder")}
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
/>
</div>
{trimmedQ && canSearchYt && (
<button
onClick={() => onYtSearch(trimmedQ)}
title={t("header.searchYoutubeTip")}
className="shrink-0 inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-2 text-xs font-medium text-muted hover:text-accent hover:border-accent transition"
>
<Youtube className="w-4 h-4" />
<span className="hidden md:inline">{t("header.searchYoutube")}</span>
</button>
)}
</div> </div>
) : ( ) : (
<div className="flex-1 text-center text-sm font-semibold"> <div className="flex-1 text-center text-sm font-semibold">

View file

@ -19,6 +19,7 @@
"smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." }, "smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." },
"quota_daily_budget": { "label": "Tägliches Kontingentbudget", "hint": "YouTube-Data-API-Einheiten pro Tag (kostenloses Limit 10.000). Standard 9000 lässt Puffer." }, "quota_daily_budget": { "label": "Tägliches Kontingentbudget", "hint": "YouTube-Data-API-Einheiten pro Tag (kostenloses Limit 10.000). Standard 9000 lässt Puffer." },
"backfill_quota_reserve": { "label": "Nachlade-Kontingentreserve", "hint": "Reservierte Einheiten, damit geplantes Nachladen interaktive Syncs / Anreicherung nie aushungert." }, "backfill_quota_reserve": { "label": "Nachlade-Kontingentreserve", "hint": "Reservierte Einheiten, damit geplantes Nachladen interaktive Syncs / Anreicherung nie aushungert." },
"search_daily_limit_per_user": { "label": "Live-Suche — Tageslimit pro Nutzer", "hint": "Maximale Live-YouTube-Suchen pro Nutzer und Tag. Jede Suche kostet 100 Einheiten, das gemeinsame Budget reicht also für insgesamt nur ~80-90/Tag. 0 = Live-Suche deaktiviert." },
"backfill_recent_max_videos": { "label": "Aktuelles Nachladen — max. Videos", "hint": "Neueste Videos pro Kanal beim ersten Durchlauf." }, "backfill_recent_max_videos": { "label": "Aktuelles Nachladen — max. Videos", "hint": "Neueste Videos pro Kanal beim ersten Durchlauf." },
"backfill_recent_max_days": { "label": "Aktuelles Nachladen — max. Alter (Tage)", "hint": "Wie weit der erste Durchlauf pro Kanal zurückreicht." }, "backfill_recent_max_days": { "label": "Aktuelles Nachladen — max. Alter (Tage)", "hint": "Wie weit der erste Durchlauf pro Kanal zurückreicht." },
"shorts_probe_max_seconds": { "label": "Shorts-Prüfung — max. Dauer (s)", "hint": "Nur Videos bis zu dieser Länge werden als mögliche Shorts geprüft." }, "shorts_probe_max_seconds": { "label": "Shorts-Prüfung — max. Dauer (s)", "hint": "Nur Videos bis zu dieser Länge werden als mögliche Shorts geprüft." },

View file

@ -29,5 +29,17 @@
"undo": "Rückgängig", "undo": "Rückgängig",
"markedWatchedNamed": "Als angesehen markiert: „{{title}}”", "markedWatchedNamed": "Als angesehen markiert: „{{title}}”",
"markedWatched": "Als angesehen markiert", "markedWatched": "Als angesehen markiert",
"unwatch": "Nicht mehr als angesehen" "unwatch": "Nicht mehr als angesehen",
"searchDiscovered": "Über Suche gefunden",
"searchDiscoveredTip": "Auch Videos anzeigen, die nur über eine Live-YouTube-Suche in die Bibliothek kamen.",
"yt": {
"searchFor": "Auf YouTube suchen nach „{{query}}”",
"resultsFor": "YouTube-Ergebnisse für „{{query}}”",
"quotaNote": "Live-Ergebnisse — verbraucht gemeinsames API-Kontingent",
"back": "Zurück zum Feed",
"searching": "Suche auf YouTube…",
"noResults": "Keine YouTube-Videos gefunden.",
"error": "YouTube-Suche fehlgeschlagen.",
"loadMore": "Mehr laden (verbraucht Kontingent)"
}
} }

View file

@ -1,6 +1,8 @@
{ {
"feed": "Feed", "feed": "Feed",
"searchPlaceholder": "Deine Abos durchsuchen…", "searchPlaceholder": "Deine Abos durchsuchen…",
"searchYoutube": "Auf YouTube suchen",
"searchYoutubeTip": "Live auf YouTube nach diesem Begriff suchen (verbraucht gemeinsames API-Kontingent). Du kannst auch Enter drücken.",
"channelManager": "Kanalverwaltung", "channelManager": "Kanalverwaltung",
"usageStats": "Nutzung & Statistik", "usageStats": "Nutzung & Statistik",
"scheduler": "Planer", "scheduler": "Planer",

View file

@ -8,6 +8,7 @@
"videos_backfill_full": "Vollständigen Verlauf laden", "videos_backfill_full": "Vollständigen Verlauf laden",
"videos_enrich": "Video-Anreicherung", "videos_enrich": "Video-Anreicherung",
"videos_lookup": "Video-Abfrage", "videos_lookup": "Video-Abfrage",
"videos_search": "Live-Suche",
"channels_discover": "Kanal-Entdeckung", "channels_discover": "Kanal-Entdeckung",
"channels_subscribe": "Abonnieren", "channels_subscribe": "Abonnieren",
"channels_unsubscribe": "Abo beenden", "channels_unsubscribe": "Abo beenden",

View file

@ -19,6 +19,7 @@
"smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." }, "smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." },
"quota_daily_budget": { "label": "Daily quota budget", "hint": "Total YouTube Data API units to spend per day (free tier is 10,000). Default 9000 leaves headroom." }, "quota_daily_budget": { "label": "Daily quota budget", "hint": "Total YouTube Data API units to spend per day (free tier is 10,000). Default 9000 leaves headroom." },
"backfill_quota_reserve": { "label": "Backfill quota reserve", "hint": "Units kept in reserve so scheduled backfill never starves interactive syncs / enrichment." }, "backfill_quota_reserve": { "label": "Backfill quota reserve", "hint": "Units kept in reserve so scheduled backfill never starves interactive syncs / enrichment." },
"search_daily_limit_per_user": { "label": "Live search — daily limit per user", "hint": "Max live YouTube searches per user per day. Each search costs 100 units, so the shared budget only affords ~80-90/day total. Set 0 to disable live search." },
"backfill_recent_max_videos": { "label": "Recent backfill — max videos", "hint": "Newest videos fetched per channel on the first pass." }, "backfill_recent_max_videos": { "label": "Recent backfill — max videos", "hint": "Newest videos fetched per channel on the first pass." },
"backfill_recent_max_days": { "label": "Recent backfill — max age (days)", "hint": "How far back the first pass reaches per channel." }, "backfill_recent_max_days": { "label": "Recent backfill — max age (days)", "hint": "How far back the first pass reaches per channel." },
"shorts_probe_max_seconds": { "label": "Shorts probe — max duration (s)", "hint": "Only videos at or below this length are probed as possible Shorts." }, "shorts_probe_max_seconds": { "label": "Shorts probe — max duration (s)", "hint": "Only videos at or below this length are probed as possible Shorts." },

View file

@ -29,5 +29,17 @@
"undo": "Undo", "undo": "Undo",
"markedWatchedNamed": "Marked watched “{{title}}”", "markedWatchedNamed": "Marked watched “{{title}}”",
"markedWatched": "Marked watched", "markedWatched": "Marked watched",
"unwatch": "Unwatch" "unwatch": "Unwatch",
"searchDiscovered": "Search-discovered",
"searchDiscoveredTip": "Also show videos that entered the library only through a live YouTube search.",
"yt": {
"searchFor": "Search YouTube for “{{query}}”",
"resultsFor": "YouTube results for “{{query}}”",
"quotaNote": "Live results — uses shared API quota",
"back": "Back to feed",
"searching": "Searching YouTube…",
"noResults": "No YouTube videos found.",
"error": "YouTube search failed.",
"loadMore": "Load more (uses quota)"
}
} }

View file

@ -1,6 +1,8 @@
{ {
"feed": "Feed", "feed": "Feed",
"searchPlaceholder": "Search your subscriptions…", "searchPlaceholder": "Search your subscriptions…",
"searchYoutube": "Search YouTube",
"searchYoutubeTip": "Search live on YouTube for this term (uses shared API quota). You can also press Enter.",
"channelManager": "Channel manager", "channelManager": "Channel manager",
"usageStats": "Usage & stats", "usageStats": "Usage & stats",
"scheduler": "Scheduler", "scheduler": "Scheduler",

View file

@ -8,6 +8,7 @@
"videos_backfill_full": "Full-history backfill", "videos_backfill_full": "Full-history backfill",
"videos_enrich": "Video enrichment", "videos_enrich": "Video enrichment",
"videos_lookup": "Video lookup", "videos_lookup": "Video lookup",
"videos_search": "Live search",
"channels_discover": "Channel discovery", "channels_discover": "Channel discovery",
"channels_subscribe": "Subscribe", "channels_subscribe": "Subscribe",
"channels_unsubscribe": "Unsubscribe", "channels_unsubscribe": "Unsubscribe",

View file

@ -19,6 +19,7 @@
"smtp_password": { "label": "SMTP-jelszó", "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra." }, "smtp_password": { "label": "SMTP-jelszó", "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra." },
"quota_daily_budget": { "label": "Napi kvótakeret", "hint": "Naponta elkölthető YouTube Data API-egységek (az ingyenes keret 10 000). Az alap 9000 tartalékot hagy." }, "quota_daily_budget": { "label": "Napi kvótakeret", "hint": "Naponta elkölthető YouTube Data API-egységek (az ingyenes keret 10 000). Az alap 9000 tartalékot hagy." },
"backfill_quota_reserve": { "label": "Letöltési kvótatartalék", "hint": "Tartalékban tartott egységek, hogy az ütemezett letöltés ne éheztesse ki az interaktív szinkront / gazdagítást." }, "backfill_quota_reserve": { "label": "Letöltési kvótatartalék", "hint": "Tartalékban tartott egységek, hogy az ütemezett letöltés ne éheztesse ki az interaktív szinkront / gazdagítást." },
"search_daily_limit_per_user": { "label": "Élő keresés — napi limit / felhasználó", "hint": "Maximális élő YouTube-keresés felhasználónként naponta. Egy keresés 100 egységbe kerül, így a közös büdzséből összesen csak ~80-90 fér bele naponta. 0 = élő keresés kikapcsolva." },
"backfill_recent_max_videos": { "label": "Friss letöltés — max videó", "hint": "Csatornánként az első körben letöltött legújabb videók száma." }, "backfill_recent_max_videos": { "label": "Friss letöltés — max videó", "hint": "Csatornánként az első körben letöltött legújabb videók száma." },
"backfill_recent_max_days": { "label": "Friss letöltés — max kor (nap)", "hint": "Az első kör csatornánként meddig nyúl vissza." }, "backfill_recent_max_days": { "label": "Friss letöltés — max kor (nap)", "hint": "Az első kör csatornánként meddig nyúl vissza." },
"shorts_probe_max_seconds": { "label": "Shorts-vizsgálat — max hossz (mp)", "hint": "Csak az ennél nem hosszabb videókat vizsgáljuk lehetséges Shortsként." }, "shorts_probe_max_seconds": { "label": "Shorts-vizsgálat — max hossz (mp)", "hint": "Csak az ennél nem hosszabb videókat vizsgáljuk lehetséges Shortsként." },

View file

@ -29,5 +29,17 @@
"undo": "Visszavonás", "undo": "Visszavonás",
"markedWatchedNamed": "Megnézettnek jelölve: „{{title}}”", "markedWatchedNamed": "Megnézettnek jelölve: „{{title}}”",
"markedWatched": "Megnézettnek jelölve", "markedWatched": "Megnézettnek jelölve",
"unwatch": "Megnézés visszavonása" "unwatch": "Megnézés visszavonása",
"searchDiscovered": "Keresésből talált",
"searchDiscoveredTip": "Mutasd azokat a videókat is, amelyek csak élő YouTube-keresésen át kerültek a könyvtárba.",
"yt": {
"searchFor": "Keresés a YouTube-on: „{{query}}”",
"resultsFor": "YouTube találatok erre: „{{query}}”",
"quotaNote": "Élő találatok — a közös API-kvótát fogyasztja",
"back": "Vissza a hírfolyamhoz",
"searching": "Keresés a YouTube-on…",
"noResults": "Nincs YouTube-találat.",
"error": "A YouTube-keresés nem sikerült.",
"loadMore": "Továbbiak betöltése (kvótát fogyaszt)"
}
} }

View file

@ -1,6 +1,8 @@
{ {
"feed": "Hírfolyam", "feed": "Hírfolyam",
"searchPlaceholder": "Keresés a feliratkozásaid között…", "searchPlaceholder": "Keresés a feliratkozásaid között…",
"searchYoutube": "Keresés a YouTube-on",
"searchYoutubeTip": "Élő keresés a YouTube-on erre a kifejezésre (a közös API-kvótát fogyasztja). Entert is nyomhatsz.",
"channelManager": "Csatornakezelő", "channelManager": "Csatornakezelő",
"usageStats": "Használat és statisztika", "usageStats": "Használat és statisztika",
"scheduler": "Ütemező", "scheduler": "Ütemező",

View file

@ -8,6 +8,7 @@
"videos_backfill_full": "Teljes előzmény betöltése", "videos_backfill_full": "Teljes előzmény betöltése",
"videos_enrich": "Videó-gazdagítás", "videos_enrich": "Videó-gazdagítás",
"videos_lookup": "Videó-lekérdezés", "videos_lookup": "Videó-lekérdezés",
"videos_search": "Élő keresés",
"channels_discover": "Csatorna-felfedezés", "channels_discover": "Csatorna-felfedezés",
"channels_subscribe": "Feliratkozás", "channels_subscribe": "Feliratkozás",
"channels_unsubscribe": "Leiratkozás", "channels_unsubscribe": "Leiratkozás",

View file

@ -141,6 +141,9 @@ export interface FeedFilters {
includeShorts: boolean; includeShorts: boolean;
includeLive: boolean; includeLive: boolean;
show: string; 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; channelId?: string;
channelName?: string; channelName?: string;
maxAgeDays?: number; maxAgeDays?: number;
@ -306,6 +309,8 @@ function filterParams(f: FeedFilters): URLSearchParams {
if (f.dateTo) p.set("published_before", f.dateTo); if (f.dateTo) p.set("published_before", f.dateTo);
if (f.minDuration != null) p.set("min_duration", String(f.minDuration)); if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration)); 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; return p;
} }
@ -558,6 +563,14 @@ export const api = {
req(`/api/feed?${feedQuery(f, cursor, limit)}`), req(`/api/feed?${feedQuery(f, cursor, limit)}`),
feedCount: (f: FeedFilters): Promise<{ count: number }> => feedCount: (f: FeedFilters): Promise<{ count: number }> =>
req(`/api/feed/count?${filterParams(f).toString()}`), 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> }> => facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
req(`/api/facets?${filterParams(f).toString()}`), req(`/api/facets?${filterParams(f).toString()}`),
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these // idempotent: setting a state / saving a progress checkpoint is safe to replay, so these