From cc1e670202ae0d1041594bab2368c42446034f5d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 30 Jun 2026 03:08:52 +0200 Subject: [PATCH] feat(channels): dedicated channel page + ephemeral explore UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend for the channel-explore feature: - ChannelPage: banner/avatar/stats header, Subscribe/unsubscribe, an "exploring" badge while browsing an un-subscribed channel, Videos/About tabs. Reuses Feed scoped to the channel (scope=all + source=all so the per-user ephemeral videos show). Auto-ingests recent uploads on first visit (background, with a loading note) + "Load more from YouTube" to page deeper; skipped for demo / already-subscribed channels. - App: openChannel/closeChannel as a Back-aware sub-view (history.state._chan, mirrors the YT-search _yt pattern); ChannelPage takes over the content column, nav rail stays. - ChannelLink/cards/player: the channel name now opens our channel page (onChannelFilter → onOpenChannel); the in-card "only this channel" filter button is dropped (the page subsumes it). PlayerModal channel-name wiring follows in the next commit. - api: channelDetail + exploreChannel; ChannelDetail/ExploreResult types. - i18n EN/HU/DE: channel namespace, explore_cleanup scheduler labels, explore config group, channels_explore quota label. --- frontend/src/App.tsx | 43 ++- frontend/src/components/ChannelPage.tsx | 293 ++++++++++++++++++ frontend/src/components/Feed.tsx | 14 +- frontend/src/components/VideoCard.tsx | 52 ++-- frontend/src/components/VirtualFeed.tsx | 8 +- frontend/src/i18n/locales/de/channel.json | 20 ++ frontend/src/i18n/locales/de/config.json | 107 +++++-- .../src/i18n/locales/de/quotaActions.json | 3 +- frontend/src/i18n/locales/de/scheduler.json | 6 +- frontend/src/i18n/locales/en/channel.json | 20 ++ frontend/src/i18n/locales/en/config.json | 107 +++++-- .../src/i18n/locales/en/quotaActions.json | 3 +- frontend/src/i18n/locales/en/scheduler.json | 6 +- frontend/src/i18n/locales/hu/channel.json | 20 ++ frontend/src/i18n/locales/hu/config.json | 107 +++++-- .../src/i18n/locales/hu/quotaActions.json | 3 +- frontend/src/i18n/locales/hu/scheduler.json | 6 +- frontend/src/lib/api.ts | 46 +++ 18 files changed, 744 insertions(+), 120 deletions(-) create mode 100644 frontend/src/components/ChannelPage.tsx create mode 100644 frontend/src/i18n/locales/de/channel.json create mode 100644 frontend/src/i18n/locales/en/channel.json create mode 100644 frontend/src/i18n/locales/hu/channel.json diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b777c3a..5c4b503 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,7 @@ import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; import Sidebar from "./components/Sidebar"; import Feed from "./components/Feed"; +import ChannelPage from "./components/ChannelPage"; import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels"; import Playlists from "./components/Playlists"; import Stats from "./components/Stats"; @@ -140,6 +141,22 @@ export default function App() { if (window.history.state?._yt) window.history.back(); else setYtSearch(null); }, []); + // The open channel page (null = no channel page). Like the YouTube-search sub-view it owns a + // browser-history entry (history.state._chan = channel id, _chanName = a display name to show + // before the detail loads), so Back closes the channel page (after any player opened over it) + // and reload returns to the normal app. Channels/videos are reached by id, so it's not in the URL. + const [channelView, setChannelView] = useState<{ id: string; name?: string } | null>(null); + const openChannel = useCallback((id: string, name?: string) => { + setChannelView({ id, name }); + const st = window.history.state || {}; + if (st._chan) + window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, ""); + else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, ""); + }, []); + const closeChannel = useCallback(() => { + if (window.history.state?._chan) window.history.back(); + else setChannelView(null); + }, []); const [wizardOpen, setWizardOpen] = useState(false); // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render. const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all"); @@ -247,7 +264,8 @@ export default function App() { useEffect(() => { // Drop any stale _yt from a prior session (a reload starts on the normal feed; ytSearch // begins null), so the first Back doesn't resurrect a search we're no longer showing. - const { _yt: _staleYt, ...rest } = window.history.state || {}; + const { _yt: _staleYt, _chan: _staleChan, _chanName: _staleChanName, ...rest } = + window.history.state || {}; window.history.replaceState({ ...rest, sfPage: page }, ""); function onPop(e: PopStateEvent) { const p = (e.state?.sfPage as Page) ?? "feed"; @@ -273,6 +291,9 @@ export default function App() { // The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or // clears it to match the entry we landed on (and a player popped first leaves it intact). setYtSearch((e.state?._yt as string) ?? null); + // The channel page rides in history.state._chan the same way. + const chan = e.state?._chan as string | undefined; + setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null); } window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); @@ -518,6 +539,18 @@ export default function App() { language={i18n.language as LangCode} />
+ {channelView ? ( + + ) : ( + <>
setWizardOpen(true)} - onViewChannel={(id, name) => { - setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); - setPage("feed"); - }} + onViewChannel={(id, name) => openChannel(id, name)} onFilterByTag={(tagId, name) => { setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined }); setPage("feed"); @@ -599,10 +629,13 @@ export default function App() { ytSearch={ytSearch} onYtSearch={enterYtSearch} onExitYtSearch={exitYtSearch} + onOpenChannel={openChannel} /> )}
+ + )} {/* Toasts rise from the bottom-left, by the notification bell in the nav rail. Anchored inside the content column so they clear the sidebar automatically (collapsed or expanded) without tracking its width. */} diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx new file mode 100644 index 0000000..fef3c01 --- /dev/null +++ b/frontend/src/components/ChannelPage.tsx @@ -0,0 +1,293 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, Check, ExternalLink, Loader2, Plus, RefreshCw } from "lucide-react"; +import Avatar from "./Avatar"; +import Feed from "./Feed"; +import { useConfirm } from "./ConfirmProvider"; +import { notify } from "../lib/notifications"; +import { api, type FeedFilters, type Me } from "../lib/api"; +import { channelYouTubeUrl, formatViews } from "../lib/format"; + +// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the +// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it +// auto-ingests recent uploads in the background ("explore") so they're browsable immediately, +// and offers "load more" to page deeper — without permanently polluting everyone's library +// (the backend keeps explored videos private to this user until they subscribe). +export default function ChannelPage({ + channelId, + initialName, + me, + view, + onBack, + onOpenChannel, +}: { + channelId: string; + initialName?: string; + me: Me; + view: "grid" | "list"; + onBack: () => void; + onOpenChannel: (id: string, name?: string) => void; +}) { + const { t, i18n } = useTranslation(); + const qc = useQueryClient(); + const confirm = useConfirm(); + const [tab, setTab] = useState<"videos" | "about">("videos"); + + const detail = useQuery({ + queryKey: ["channel", channelId], + queryFn: () => api.channelDetail(channelId), + }); + const ch = detail.data; + + // Background auto-ingest ("explore") of an un-subscribed channel's uploads. + const [nextToken, setNextToken] = useState(undefined); + const [exploring, setExploring] = useState(false); + const autoTried = useRef(null); + + const runExplore = useCallback( + (token?: string | null) => { + setExploring(true); + return api + .exploreChannel(channelId, token ?? undefined) + .then((r) => { + setNextToken(r.next_page_token); + qc.invalidateQueries({ queryKey: ["feed"] }); + }) + .finally(() => setExploring(false)); + }, + [channelId, qc] + ); + + // First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo + // account (explore spends shared quota) and when videos are already present (a revisit, or a + // subscribed channel whose uploads the scheduler backfills). + useEffect(() => { + if (!ch || me.is_demo) return; + if (autoTried.current === channelId) return; + if (ch.subscribed) return; + if (ch.explored && ch.stored_videos > 0) return; + autoTried.current = channelId; + runExplore().catch(() => {}); + }, [ch, channelId, me.is_demo, runExplore]); + + const subscribe = useMutation({ + mutationFn: () => api.subscribeChannel(channelId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["channel", channelId] }); + qc.invalidateQueries({ queryKey: ["feed"] }); + qc.invalidateQueries({ queryKey: ["channels"] }); + notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) }); + }, + }); + const unsubscribe = useMutation({ + mutationFn: () => api.unsubscribeChannel(channelId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["channel", channelId] }); + qc.invalidateQueries({ queryKey: ["feed"] }); + qc.invalidateQueries({ queryKey: ["channels"] }); + }, + }); + const onUnsubscribe = async () => { + const ok = await confirm({ + title: t("channel.unsubTitle"), + message: t("channel.unsubBody", { name: ch?.title ?? channelId }), + confirmLabel: t("channel.unsubscribe"), + danger: true, + }); + if (ok) unsubscribe.mutate(); + }; + + // Channel-scoped feed: the whole catalog filtered to this channel (scope=all + source=all so + // the explored, per-user-private videos show — the backend gates them to this explorer). + const [filters, setFilters] = useState(() => ({ + tags: [], + tagMode: "or", + q: "", + sort: "newest", + scope: "all", + librarySource: "all", + includeNormal: true, + includeShorts: false, + includeLive: true, + show: "all", + channelId, + channelName: initialName, + })); + + const name = ch?.title ?? initialName ?? channelId; + const ytUrl = channelYouTubeUrl(channelId, ch?.handle); + const joined = ch?.published_at + ? new Date(ch.published_at).toLocaleDateString(i18n.language, { year: "numeric", month: "short" }) + : null; + const tabClass = (active: boolean) => + active + ? "pb-2 text-sm font-medium border-b-2 border-fg text-fg" + : "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent"; + + return ( +
+ {/* Banner + back */} +
+ {ch?.banner_url ? ( +
+ ) : ( +
+ )} + +
+ +
+ {/* Identity + actions */} +
+ +
+
+

{name}

+ {ch?.explored && !ch?.subscribed && ( + + {t("channel.exploringBadge")} + + )} +
+ {ch?.handle && ( +
@{ch.handle.replace(/^@/, "")}
+ )} +
+
+ + + + {!me.is_demo && + (ch?.subscribed ? ( + + ) : ( + + ))} +
+
+ + {/* Stats */} +
+ {ch?.subscriber_count != null && ( + {t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) })} + )} + {ch?.video_count != null && · {t("channel.videoCount", { count: ch.video_count })}} + {ch?.total_view_count != null && ( + · {t("channel.totalViews", { formatted: formatViews(ch.total_view_count) })} + )} + {joined && · {t("channel.joined", { date: joined })}} +
+ {exploring && ( +
+ + {t("channel.loadingVideos")} +
+ )} + + {/* Tabs */} +
+ + +
+
+ + {/* Tab content */} + {tab === "videos" ? ( + <> + {}} + ytSearch={null} + onYtSearch={() => {}} + onExitYtSearch={() => {}} + onOpenChannel={onOpenChannel} + /> + {!ch?.subscribed && nextToken && ( +
+ +
+ )} + + ) : ( +
+ {ch?.description ? ( +

{ch.description}

+ ) : ( +

{t("channel.noDescription")}

+ )} + {ch?.external_links && ch.external_links.length > 0 && ( +
+ {ch.external_links.map((l, i) => ( + + + {l.title || l.url} + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 7205633..8a1062b 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -77,6 +77,7 @@ export default function Feed({ ytSearch, onYtSearch, onExitYtSearch, + onOpenChannel, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; @@ -91,6 +92,9 @@ export default function Feed({ ytSearch: string | null; onYtSearch: (q: string) => void; onExitYtSearch: () => void; + // Open a channel's dedicated page (from a card's channel name / avatar). Threaded down to + // the cards via VirtualFeed. + onOpenChannel?: (channelId: string, channelName?: string) => void; }) { const { t } = useTranslation(); const [overrides, setOverrides] = useState>({}); @@ -257,11 +261,11 @@ export default function Feed({ [qc] ); - const onChannelFilter = useCallback( + const handleOpenChannel = useCallback( (channelId: string, channelName: string) => { - setFilters({ ...filters, channelId, channelName }); + onOpenChannel?.(channelId, channelName); }, - [filters, setFilters] + [onOpenChannel] ); const onToggleSave = useCallback( @@ -347,7 +351,7 @@ export default function Feed({ view={view} onState={onState} onToggleSave={onToggleSave} - onChannelFilter={onChannelFilter} + onOpenChannel={handleOpenChannel} onResetState={onResetState} onOpen={openVideo} hasNextPage={false} @@ -568,7 +572,7 @@ export default function Feed({ view={view} onState={onState} onToggleSave={onToggleSave} - onChannelFilter={onChannelFilter} + onOpenChannel={handleOpenChannel} onResetState={onResetState} onOpen={openVideo} hasNextPage={!!hasNextPage} diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 5087913..541c86e 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -6,7 +6,6 @@ import { CheckCheck, Eye, EyeOff, - ListFilter, Play, RotateCcw, } from "lucide-react"; @@ -21,13 +20,11 @@ function Actions({ onState, onResetState, onToggleSave, - onChannelFilter, }: { video: Video; onState: (id: string, status: string) => void; onResetState?: (id: string) => void; onToggleSave: (id: string, saved: boolean) => void; - onChannelFilter?: (channelId: string, channelName: string) => void; }) { const { t } = useTranslation(); const act = (status: string) => (e: React.MouseEvent) => { @@ -97,19 +94,6 @@ function Actions({ )} - {onChannelFilter && ( - - )} ); } @@ -244,7 +228,7 @@ function VideoCard({ onState, onResetState, onToggleSave, - onChannelFilter, + onOpenChannel, onOpen, }: { video: Video; @@ -252,7 +236,7 @@ function VideoCard({ onState: (id: string, status: string) => void; onResetState?: (id: string) => void; onToggleSave: (id: string, saved: boolean) => void; - onChannelFilter?: (channelId: string, channelName: string) => void; + onOpenChannel?: (channelId: string, channelName: string) => void; onOpen?: (v: Video, startAt?: number | null) => void; }) { const { t, i18n } = useTranslation(); @@ -303,18 +287,18 @@ function VideoCard({ - + ); } @@ -335,17 +319,17 @@ function VideoCard({ /> diff --git a/frontend/src/components/VirtualFeed.tsx b/frontend/src/components/VirtualFeed.tsx index 2048cd5..d7f368b 100644 --- a/frontend/src/components/VirtualFeed.tsx +++ b/frontend/src/components/VirtualFeed.tsx @@ -40,7 +40,7 @@ export interface VirtualFeedProps { view: "grid" | "list"; onState: (id: string, status: string) => void; onToggleSave: (id: string, saved: boolean) => void; - onChannelFilter: (channelId: string, channelName: string) => void; + onOpenChannel: (channelId: string, channelName: string) => void; onResetState: (id: string) => void; onOpen: (v: Video, startAt?: number | null) => void; hasNextPage: boolean; @@ -53,7 +53,7 @@ export default function VirtualFeed({ view, onState, onToggleSave, - onChannelFilter, + onOpenChannel, onResetState, onOpen, hasNextPage, @@ -149,7 +149,7 @@ export default function VirtualFeed({ view="grid" onState={onState} onToggleSave={onToggleSave} - onChannelFilter={onChannelFilter} + onOpenChannel={onOpenChannel} onResetState={onResetState} onOpen={onOpen} /> @@ -162,7 +162,7 @@ export default function VirtualFeed({ view="list" onState={onState} onToggleSave={onToggleSave} - onChannelFilter={onChannelFilter} + onOpenChannel={onOpenChannel} onResetState={onResetState} onOpen={onOpen} /> diff --git a/frontend/src/i18n/locales/de/channel.json b/frontend/src/i18n/locales/de/channel.json new file mode 100644 index 0000000..c302589 --- /dev/null +++ b/frontend/src/i18n/locales/de/channel.json @@ -0,0 +1,20 @@ +{ + "back": "Zurück", + "exploringBadge": "Erkunden", + "subscribe": "Abonnieren", + "subscribedState": "Abonniert", + "unsubscribe": "Abbestellen", + "unsubTitle": "Abbestellen?", + "unsubBody": "{{name}} auf YouTube nicht mehr folgen?", + "subscribed": "{{name}} abonniert", + "subscribers": "{{formatted}} Abonnenten", + "videoCount_one": "{{count}} Video", + "videoCount_other": "{{count}} Videos", + "totalViews": "{{formatted}} Aufrufe", + "joined": "Beigetreten {{date}}", + "loadingVideos": "Videos dieses Kanals werden geladen…", + "tabVideos": "Videos", + "tabAbout": "Info", + "loadMore": "Mehr von YouTube laden", + "noDescription": "Dieser Kanal hat keine Beschreibung." +} diff --git a/frontend/src/i18n/locales/de/config.json b/frontend/src/i18n/locales/de/config.json index 76e68f5..e799cd9 100644 --- a/frontend/src/i18n/locales/de/config.json +++ b/frontend/src/i18n/locales/de/config.json @@ -9,29 +9,94 @@ "quota": "Kontingent", "backfill": "Nachladen (Backfill)", "shorts": "Shorts-Prüfung", - "batch": "Stapelgrößen" + "batch": "Stapelgrößen", + "explore": "Kanal erkunden" }, "fields": { - "smtp_host": { "label": "SMTP-Host", "hint": "z. B. smtp.gmail.com" }, - "smtp_port": { "label": "SMTP-Port", "hint": "Üblicherweise 587 (STARTTLS)." }, - "smtp_user": { "label": "SMTP-Benutzername", "hint": "Das sendende Konto / der Login." }, - "smtp_from": { "label": "Absenderadresse", "hint": "z. B. Siftlode . Standard ist der Benutzername." }, - "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." }, - "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. Bei der API-Quelle kostet jede Suche 100 Einheiten (das gemeinsame Budget reicht also für insgesamt nur ~80-90/Tag); bei der Scrape-Quelle ist es ein reines Missbrauchslimit. 0 = Live-Suche deaktiviert." }, - "search_source": { "label": "Quelle der Live-Suche", "hint": "Woher die Ergebnisse der Live-YouTube-Suche stammen. \"scrape\" nutzt YouTubes internen Endpunkt und verbraucht kein API-Kontingent (empfohlen). \"api\" nutzt das offizielle search.list (100 Einheiten/Seite). Auf \"api\" umstellen, falls das Scraping ausfällt oder blockiert wird." }, - "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." }, - "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_batch": { "label": "Shorts-Prüfung — Stapelgröße", "hint": "Wie viele Videos pro Lauf klassifiziert werden." }, - "enrich_batch_size": { "label": "Anreicherungs-Stapelgröße", "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." }, - "autotag_title_sample": { "label": "Auto-Tag-Titelstichprobe", "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." }, - "youtube_api_key": { "label": "YouTube-API-Schlüssel", "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." }, - "youtube_api_proxy": { "label": "YouTube-API-Egress-Proxy", "hint": "Optional. HTTP(S)-Proxy-URL für ausgehende YouTube-API-Aufrufe — über einen Host mit fester IP leiten, damit ein IP-beschränkter Schlüssel von einem Server mit dynamischer IP funktioniert. Leer = direkt." }, - "google_client_id": { "label": "Google-Client-ID", "hint": "OAuth-2.0-Client-ID für die Google-Anmeldung. Verschlüsselt gespeichert; nur schreibbar. Beide Google-Felder leer lassen, um die Google-Anmeldung zu deaktivieren (E-Mail+Passwort funktioniert weiter)." }, - "google_client_secret": { "label": "Google-Client-Secret", "hint": "OAuth-2.0-Client-Secret. Verschlüsselt gespeichert; nur schreibbar." }, - "allow_registration": { "label": "Registrierung erlauben", "hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig." } + "smtp_host": { + "label": "SMTP-Host", + "hint": "z. B. smtp.gmail.com" + }, + "smtp_port": { + "label": "SMTP-Port", + "hint": "Üblicherweise 587 (STARTTLS)." + }, + "smtp_user": { + "label": "SMTP-Benutzername", + "hint": "Das sendende Konto / der Login." + }, + "smtp_from": { + "label": "Absenderadresse", + "hint": "z. B. Siftlode . Standard ist der Benutzername." + }, + "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." + }, + "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. Bei der API-Quelle kostet jede Suche 100 Einheiten (das gemeinsame Budget reicht also für insgesamt nur ~80-90/Tag); bei der Scrape-Quelle ist es ein reines Missbrauchslimit. 0 = Live-Suche deaktiviert." + }, + "search_source": { + "label": "Quelle der Live-Suche", + "hint": "Woher die Ergebnisse der Live-YouTube-Suche stammen. \"scrape\" nutzt YouTubes internen Endpunkt und verbraucht kein API-Kontingent (empfohlen). \"api\" nutzt das offizielle search.list (100 Einheiten/Seite). Auf \"api\" umstellen, falls das Scraping ausfällt oder blockiert wird." + }, + "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." + }, + "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_batch": { + "label": "Shorts-Prüfung — Stapelgröße", + "hint": "Wie viele Videos pro Lauf klassifiziert werden." + }, + "enrich_batch_size": { + "label": "Anreicherungs-Stapelgröße", + "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." + }, + "autotag_title_sample": { + "label": "Auto-Tag-Titelstichprobe", + "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." + }, + "youtube_api_key": { + "label": "YouTube-API-Schlüssel", + "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." + }, + "youtube_api_proxy": { + "label": "YouTube-API-Egress-Proxy", + "hint": "Optional. HTTP(S)-Proxy-URL für ausgehende YouTube-API-Aufrufe — über einen Host mit fester IP leiten, damit ein IP-beschränkter Schlüssel von einem Server mit dynamischer IP funktioniert. Leer = direkt." + }, + "google_client_id": { + "label": "Google-Client-ID", + "hint": "OAuth-2.0-Client-ID für die Google-Anmeldung. Verschlüsselt gespeichert; nur schreibbar. Beide Google-Felder leer lassen, um die Google-Anmeldung zu deaktivieren (E-Mail+Passwort funktioniert weiter)." + }, + "google_client_secret": { + "label": "Google-Client-Secret", + "hint": "OAuth-2.0-Client-Secret. Verschlüsselt gespeichert; nur schreibbar." + }, + "allow_registration": { + "label": "Registrierung erlauben", + "hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig." + }, + "explore_grace_days": { + "label": "Erkunden — Karenzzeit (Tage)", + "hint": "Wie lange ein erkundeter, nicht abonnierter Kanal (und seine flüchtigen Videos) behalten wird, bevor die Aufräumaufgabe ihn entfernt. Ein Abo behält ihn dauerhaft." + } }, "save": "Speichern", "saving": "Speichern…", diff --git a/frontend/src/i18n/locales/de/quotaActions.json b/frontend/src/i18n/locales/de/quotaActions.json index 83cc503..7896ec4 100644 --- a/frontend/src/i18n/locales/de/quotaActions.json +++ b/frontend/src/i18n/locales/de/quotaActions.json @@ -13,5 +13,6 @@ "channels_subscribe": "Abonnieren", "channels_unsubscribe": "Abo beenden", "maintenance_revalidate": "Wartungs-Neuvalidierung", - "other": "Sonstiges" + "other": "Sonstiges", + "channels_explore": "Kanal erkunden" } diff --git a/frontend/src/i18n/locales/de/scheduler.json b/frontend/src/i18n/locales/de/scheduler.json index a7da5c2..29ac42e 100644 --- a/frontend/src/i18n/locales/de/scheduler.json +++ b/frontend/src/i18n/locales/de/scheduler.json @@ -66,7 +66,8 @@ "shorts": "Prüft youtube.com/shorts, um Shorts zu markieren. Fällt er aus, werden Shorts im Feed nicht von normalen Videos getrennt.", "subscriptions": "Importiert die YouTube-Abos jedes Nutzers neu. Fällt er aus, werden auf YouTube hinzugefügte oder entfernte Abos hier nicht übernommen.", "playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht.", - "maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog." + "maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog.", + "explore_cleanup": "Entfernt Kanäle, die du erkundet, aber nie abonniert hast (und ihre Videos), nach einer Karenzzeit, damit Neugier-Stöbern den Katalog nicht überfüllt. Wenn es stoppt, bleiben verlassene erkundete Kanäle bestehen." }, "jobs": { "rss_poll": "RSS-Abfrage (neue Uploads)", @@ -77,7 +78,8 @@ "subscriptions": "Abo-Resync", "playlist_sync": "YouTube-Wiedergabelisten-Sync", "maintenance": "Wartung + Validierung", - "demo_reset": "Demo-Konto-Reset" + "demo_reset": "Demo-Konto-Reset", + "explore_cleanup": "Bereinigung erkundeter Kanäle" }, "queue": { "recentPending": "Zu synchronisierende Kanäle", diff --git a/frontend/src/i18n/locales/en/channel.json b/frontend/src/i18n/locales/en/channel.json new file mode 100644 index 0000000..d7c006e --- /dev/null +++ b/frontend/src/i18n/locales/en/channel.json @@ -0,0 +1,20 @@ +{ + "back": "Back", + "exploringBadge": "Exploring", + "subscribe": "Subscribe", + "subscribedState": "Subscribed", + "unsubscribe": "Unsubscribe", + "unsubTitle": "Unsubscribe?", + "unsubBody": "Stop following {{name}} on YouTube?", + "subscribed": "Subscribed to {{name}}", + "subscribers": "{{formatted}} subscribers", + "videoCount_one": "{{count}} video", + "videoCount_other": "{{count}} videos", + "totalViews": "{{formatted}} views", + "joined": "Joined {{date}}", + "loadingVideos": "Loading this channel's videos…", + "tabVideos": "Videos", + "tabAbout": "About", + "loadMore": "Load more from YouTube", + "noDescription": "This channel has no description." +} diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json index ed003a9..40fec51 100644 --- a/frontend/src/i18n/locales/en/config.json +++ b/frontend/src/i18n/locales/en/config.json @@ -9,29 +9,94 @@ "quota": "Quota", "backfill": "Backfill", "shorts": "Shorts probe", - "batch": "Batch sizes" + "batch": "Batch sizes", + "explore": "Channel explore" }, "fields": { - "smtp_host": { "label": "SMTP host", "hint": "e.g. smtp.gmail.com" }, - "smtp_port": { "label": "SMTP port", "hint": "Usually 587 (STARTTLS)." }, - "smtp_user": { "label": "SMTP username", "hint": "The sending account / login." }, - "smtp_from": { "label": "From address", "hint": "e.g. Siftlode . Defaults to the username." }, - "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." }, - "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. With the API source each search costs 100 units (so the shared budget affords only ~80-90/day total); with the scrape source it's a pure anti-abuse limit. Set 0 to disable live search." }, - "search_source": { "label": "Live search source", "hint": "Where live YouTube search results come from. \"scrape\" uses YouTube's internal endpoint and spends no API quota (recommended). \"api\" uses the official search.list (100 units/page). Switch to \"api\" if scraping ever breaks or is blocked." }, - "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." }, - "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_batch": { "label": "Shorts probe — batch size", "hint": "How many videos to classify per run." }, - "enrich_batch_size": { "label": "Enrichment batch size", "hint": "videos.list ids per call (YouTube caps this at 50)." }, - "autotag_title_sample": { "label": "Auto-tag title sample", "hint": "Recent video titles sampled per channel for language detection." }, - "youtube_api_key": { "label": "YouTube API key", "hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only." }, - "youtube_api_proxy": { "label": "YouTube API egress proxy", "hint": "Optional. HTTP(S) proxy URL for outbound YouTube API calls — route them through a fixed-IP host so an IP-restricted key keeps working from a dynamic-IP server. Leave empty for direct." }, - "google_client_id": { "label": "Google client ID", "hint": "OAuth 2.0 client ID for Sign in with Google. Stored encrypted; write-only. Leave both Google fields empty to disable Google sign-in (email+password still works)." }, - "google_client_secret": { "label": "Google client secret", "hint": "OAuth 2.0 client secret. Stored encrypted; write-only." }, - "allow_registration": { "label": "Allow registration", "hint": "When on, anyone can submit an email+password registration. They still need to verify their email and be approved by an admin before they can sign in." } + "smtp_host": { + "label": "SMTP host", + "hint": "e.g. smtp.gmail.com" + }, + "smtp_port": { + "label": "SMTP port", + "hint": "Usually 587 (STARTTLS)." + }, + "smtp_user": { + "label": "SMTP username", + "hint": "The sending account / login." + }, + "smtp_from": { + "label": "From address", + "hint": "e.g. Siftlode . Defaults to the username." + }, + "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." + }, + "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. With the API source each search costs 100 units (so the shared budget affords only ~80-90/day total); with the scrape source it's a pure anti-abuse limit. Set 0 to disable live search." + }, + "search_source": { + "label": "Live search source", + "hint": "Where live YouTube search results come from. \"scrape\" uses YouTube's internal endpoint and spends no API quota (recommended). \"api\" uses the official search.list (100 units/page). Switch to \"api\" if scraping ever breaks or is blocked." + }, + "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." + }, + "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_batch": { + "label": "Shorts probe — batch size", + "hint": "How many videos to classify per run." + }, + "enrich_batch_size": { + "label": "Enrichment batch size", + "hint": "videos.list ids per call (YouTube caps this at 50)." + }, + "autotag_title_sample": { + "label": "Auto-tag title sample", + "hint": "Recent video titles sampled per channel for language detection." + }, + "youtube_api_key": { + "label": "YouTube API key", + "hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only." + }, + "youtube_api_proxy": { + "label": "YouTube API egress proxy", + "hint": "Optional. HTTP(S) proxy URL for outbound YouTube API calls — route them through a fixed-IP host so an IP-restricted key keeps working from a dynamic-IP server. Leave empty for direct." + }, + "google_client_id": { + "label": "Google client ID", + "hint": "OAuth 2.0 client ID for Sign in with Google. Stored encrypted; write-only. Leave both Google fields empty to disable Google sign-in (email+password still works)." + }, + "google_client_secret": { + "label": "Google client secret", + "hint": "OAuth 2.0 client secret. Stored encrypted; write-only." + }, + "allow_registration": { + "label": "Allow registration", + "hint": "When on, anyone can submit an email+password registration. They still need to verify their email and be approved by an admin before they can sign in." + }, + "explore_grace_days": { + "label": "Explore — grace period (days)", + "hint": "How long an explored-but-unsubscribed channel (and its ephemeral videos) is kept before the cleanup job removes it. Subscribing keeps it permanently." + } }, "save": "Save", "saving": "Saving…", diff --git a/frontend/src/i18n/locales/en/quotaActions.json b/frontend/src/i18n/locales/en/quotaActions.json index 1626398..2d8d98e 100644 --- a/frontend/src/i18n/locales/en/quotaActions.json +++ b/frontend/src/i18n/locales/en/quotaActions.json @@ -13,5 +13,6 @@ "channels_subscribe": "Subscribe", "channels_unsubscribe": "Unsubscribe", "maintenance_revalidate": "Maintenance re-validation", - "other": "Other" + "other": "Other", + "channels_explore": "Channel explore" } diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index fabe864..4979b2d 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -66,7 +66,8 @@ "shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the feed.", "subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.", "playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.", - "maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue." + "maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.", + "explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger." }, "jobs": { "rss_poll": "RSS poll (new uploads)", @@ -77,7 +78,8 @@ "subscriptions": "Subscription resync", "playlist_sync": "YouTube playlist sync", "maintenance": "Maintenance + validation", - "demo_reset": "Demo account reset" + "demo_reset": "Demo account reset", + "explore_cleanup": "Explored-channel cleanup" }, "queue": { "recentPending": "Channels to sync", diff --git a/frontend/src/i18n/locales/hu/channel.json b/frontend/src/i18n/locales/hu/channel.json new file mode 100644 index 0000000..5c79491 --- /dev/null +++ b/frontend/src/i18n/locales/hu/channel.json @@ -0,0 +1,20 @@ +{ + "back": "Vissza", + "exploringBadge": "Felfedezés", + "subscribe": "Feliratkozás", + "subscribedState": "Feliratkozva", + "unsubscribe": "Leiratkozás", + "unsubTitle": "Leiratkozol?", + "unsubBody": "Megszünteted a(z) {{name}} követését a YouTube-on?", + "subscribed": "Feliratkoztál: {{name}}", + "subscribers": "{{formatted}} feliratkozó", + "videoCount_one": "{{count}} videó", + "videoCount_other": "{{count}} videó", + "totalViews": "{{formatted}} megtekintés", + "joined": "Csatlakozott: {{date}}", + "loadingVideos": "A csatorna videóinak betöltése…", + "tabVideos": "Videók", + "tabAbout": "Névjegy", + "loadMore": "Több betöltése a YouTube-ról", + "noDescription": "Ennek a csatornának nincs leírása." +} diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index 4122383..3cd0fdc 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -9,29 +9,94 @@ "quota": "Kvóta", "backfill": "Letöltés (backfill)", "shorts": "Shorts-vizsgálat", - "batch": "Kötegméretek" + "batch": "Kötegméretek", + "explore": "Csatorna-felfedezés" }, "fields": { - "smtp_host": { "label": "SMTP-kiszolgáló", "hint": "pl. smtp.gmail.com" }, - "smtp_port": { "label": "SMTP-port", "hint": "Általában 587 (STARTTLS)." }, - "smtp_user": { "label": "SMTP-felhasználónév", "hint": "A küldő fiók / bejelentkezés." }, - "smtp_from": { "label": "Feladó cím", "hint": "pl. Siftlode . Alapból a felhasználónév." }, - "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." }, - "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. Az API-forrásnál 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); a scrape-forrásnál ez tiszta visszaélés-elleni limit. 0 = élő keresés kikapcsolva." }, - "search_source": { "label": "Élő keresés forrása", "hint": "Honnan jönnek az élő YouTube-keresés találatai. A „scrape” a YouTube belső végpontját használja és nem fogyaszt API-kvótát (ajánlott). Az „api” a hivatalos search.list-et (100 egység/oldal). Válts „api”-ra, ha a scrape valaha elromlik vagy letiltják." }, - "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." }, - "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_batch": { "label": "Shorts-vizsgálat — kötegméret", "hint": "Futásonként ennyi videót osztályozunk." }, - "enrich_batch_size": { "label": "Gazdagítási kötegméret", "hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)." }, - "autotag_title_sample": { "label": "Auto-címke címmintavétel", "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." }, - "youtube_api_key": { "label": "YouTube API-kulcs", "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." }, - "youtube_api_proxy": { "label": "YouTube API egress proxy", "hint": "Opcionális. HTTP(S) proxy URL a kimenő YouTube API-hívásokhoz — fix IP-jű hoston átengedve egy IP-korlátozott kulcs dinamikus IP-jű szerverről is működik. Üresen = közvetlen." }, - "google_client_id": { "label": "Google kliens-azonosító", "hint": "OAuth 2.0 kliens-azonosító a Google-bejelentkezéshez. Titkosítva tárolva; csak írható. Hagyd üresen mindkét Google-mezőt a Google-bejelentkezés kikapcsolásához (az e-mail+jelszó továbbra is működik)." }, - "google_client_secret": { "label": "Google kliens-titok", "hint": "OAuth 2.0 kliens-titok. Titkosítva tárolva; csak írható." }, - "allow_registration": { "label": "Regisztráció engedélyezése", "hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges." } + "smtp_host": { + "label": "SMTP-kiszolgáló", + "hint": "pl. smtp.gmail.com" + }, + "smtp_port": { + "label": "SMTP-port", + "hint": "Általában 587 (STARTTLS)." + }, + "smtp_user": { + "label": "SMTP-felhasználónév", + "hint": "A küldő fiók / bejelentkezés." + }, + "smtp_from": { + "label": "Feladó cím", + "hint": "pl. Siftlode . Alapból a felhasználónév." + }, + "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." + }, + "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. Az API-forrásnál 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); a scrape-forrásnál ez tiszta visszaélés-elleni limit. 0 = élő keresés kikapcsolva." + }, + "search_source": { + "label": "Élő keresés forrása", + "hint": "Honnan jönnek az élő YouTube-keresés találatai. A „scrape” a YouTube belső végpontját használja és nem fogyaszt API-kvótát (ajánlott). Az „api” a hivatalos search.list-et (100 egység/oldal). Válts „api”-ra, ha a scrape valaha elromlik vagy letiltják." + }, + "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." + }, + "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_batch": { + "label": "Shorts-vizsgálat — kötegméret", + "hint": "Futásonként ennyi videót osztályozunk." + }, + "enrich_batch_size": { + "label": "Gazdagítási kötegméret", + "hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)." + }, + "autotag_title_sample": { + "label": "Auto-címke címmintavétel", + "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." + }, + "youtube_api_key": { + "label": "YouTube API-kulcs", + "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." + }, + "youtube_api_proxy": { + "label": "YouTube API egress proxy", + "hint": "Opcionális. HTTP(S) proxy URL a kimenő YouTube API-hívásokhoz — fix IP-jű hoston átengedve egy IP-korlátozott kulcs dinamikus IP-jű szerverről is működik. Üresen = közvetlen." + }, + "google_client_id": { + "label": "Google kliens-azonosító", + "hint": "OAuth 2.0 kliens-azonosító a Google-bejelentkezéshez. Titkosítva tárolva; csak írható. Hagyd üresen mindkét Google-mezőt a Google-bejelentkezés kikapcsolásához (az e-mail+jelszó továbbra is működik)." + }, + "google_client_secret": { + "label": "Google kliens-titok", + "hint": "OAuth 2.0 kliens-titok. Titkosítva tárolva; csak írható." + }, + "allow_registration": { + "label": "Regisztráció engedélyezése", + "hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges." + }, + "explore_grace_days": { + "label": "Felfedezés — türelmi idő (nap)", + "hint": "Meddig marad meg egy felfedezett, de nem feliratkozott csatorna (és efemer videói), mielőtt a takarító feladat törli. A feliratkozás véglegesen megtartja." + } }, "save": "Mentés", "saving": "Mentés…", diff --git a/frontend/src/i18n/locales/hu/quotaActions.json b/frontend/src/i18n/locales/hu/quotaActions.json index 92f0030..bd68866 100644 --- a/frontend/src/i18n/locales/hu/quotaActions.json +++ b/frontend/src/i18n/locales/hu/quotaActions.json @@ -13,5 +13,6 @@ "channels_subscribe": "Feliratkozás", "channels_unsubscribe": "Leiratkozás", "maintenance_revalidate": "Karbantartó újraellenőrzés", - "other": "Egyéb" + "other": "Egyéb", + "channels_explore": "Csatorna-felfedezés" } diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 5e35c7a..6854a0f 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -66,7 +66,8 @@ "shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a hírfolyamban.", "subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.", "playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.", - "maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak." + "maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.", + "explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak." }, "jobs": { "rss_poll": "RSS-lekérdezés (új feltöltések)", @@ -77,7 +78,8 @@ "subscriptions": "Feliratkozások újraszinkronja", "playlist_sync": "YouTube lejátszási listák szinkronja", "maintenance": "Karbantartás + ellenőrzés", - "demo_reset": "Demo fiók visszaállítása" + "demo_reset": "Demo fiók visszaállítása", + "explore_cleanup": "Felfedezett csatornák takarítása" }, "queue": { "recentPending": "Szinkronra váró csatorna", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1ae261d..0be8293 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -76,6 +76,37 @@ export interface VideoDetail { duration_seconds: number | null; } +export interface ChannelLink { + title: string | null; + url: string; +} + +export interface ChannelDetail { + id: string; + title: string | null; + handle: string | null; + description: string | null; + thumbnail_url: string | null; + banner_url: string | null; + subscriber_count: number | null; + video_count: number | null; + total_view_count: number | null; + published_at: string | null; + external_links: ChannelLink[]; + country: string | null; + subscribed: boolean; + explored: boolean; + stored_videos: number; + from_explore: boolean; + details_synced: boolean; +} + +export interface ExploreResult { + channel_id: string; + ingested: number; + next_page_token: string | null; +} + export interface FeedResponse { items: Video[]; has_more: boolean; @@ -628,6 +659,21 @@ export const api = { subscribeChannel: (id: string) => req(`/api/channels/${id}/subscribe`, { method: "POST" }), syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), + // --- channel page (About detail + ephemeral exploration of un-subscribed channels) --- + channelDetail: (id: string): Promise => req(`/api/channels/${id}`), + // Open an un-subscribed channel for browsing: ingest a page of its uploads (newest-first, + // marked via_explore) + record the exploration. `pageToken` continues into older uploads + // ("load more"). `quiet` so a quota-reserve 429 / load error renders inline on the page. + exploreChannel: (id: string, pageToken?: string | null): Promise => { + const p = new URLSearchParams(); + if (pageToken) p.set("page_token", pageToken); + const qs = p.toString(); + return req( + `/api/channels/${id}/explore${qs ? `?${qs}` : ""}`, + { method: "POST" }, + { quiet: true } + ); + }, // --- playlists --- playlists: (containsVideoId?: string): Promise =>