feat(channels): dedicated channel page + ephemeral explore UI

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.
This commit is contained in:
npeter83 2026-06-30 03:08:52 +02:00
parent bc4c362423
commit cc1e670202
18 changed files with 744 additions and 120 deletions

View file

@ -28,6 +28,7 @@ import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar"; import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed"; import Feed from "./components/Feed";
import ChannelPage from "./components/ChannelPage";
import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels"; import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels";
import Playlists from "./components/Playlists"; import Playlists from "./components/Playlists";
import Stats from "./components/Stats"; import Stats from "./components/Stats";
@ -140,6 +141,22 @@ export default function App() {
if (window.history.state?._yt) window.history.back(); if (window.history.state?._yt) window.history.back();
else setYtSearch(null); 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); 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");
@ -247,7 +264,8 @@ export default function App() {
useEffect(() => { useEffect(() => {
// Drop any stale _yt from a prior session (a reload starts on the normal feed; ytSearch // 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. // 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 }, ""); window.history.replaceState({ ...rest, sfPage: page }, "");
function onPop(e: PopStateEvent) { function onPop(e: PopStateEvent) {
const p = (e.state?.sfPage as Page) ?? "feed"; 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 // 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). // clears it to match the entry we landed on (and a player popped first leaves it intact).
setYtSearch((e.state?._yt as string) ?? null); 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); window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop);
@ -518,6 +539,18 @@ export default function App() {
language={i18n.language as LangCode} language={i18n.language as LangCode}
/> />
<div className="relative flex-1 min-w-0 flex flex-col"> <div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? (
<ChannelPage
key={channelView.id}
channelId={channelView.id}
initialName={channelView.name}
me={meQuery.data!}
view={view}
onBack={closeChannel}
onOpenChannel={openChannel}
/>
) : (
<>
<Header <Header
me={meQuery.data!} me={meQuery.data!}
filters={filters} filters={filters}
@ -556,10 +589,7 @@ export default function App() {
setView={setChannelsView} setView={setChannelsView}
filtersResetToken={channelsFilterReset} filtersResetToken={channelsFilterReset}
onOpenWizard={() => setWizardOpen(true)} onOpenWizard={() => setWizardOpen(true)}
onViewChannel={(id, name) => { onViewChannel={(id, name) => openChannel(id, name)}
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
setPage("feed");
}}
onFilterByTag={(tagId, name) => { onFilterByTag={(tagId, name) => {
setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined }); setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined });
setPage("feed"); setPage("feed");
@ -599,10 +629,13 @@ export default function App() {
ytSearch={ytSearch} ytSearch={ytSearch}
onYtSearch={enterYtSearch} onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch} onExitYtSearch={exitYtSearch}
onOpenChannel={openChannel}
/> />
)} )}
</main> </main>
</div> </div>
</>
)}
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail. {/* 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 Anchored inside the content column so they clear the sidebar automatically
(collapsed or expanded) without tracking its width. */} (collapsed or expanded) without tracking its width. */}

View file

@ -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<string | null | undefined>(undefined);
const [exploring, setExploring] = useState(false);
const autoTried = useRef<string | null>(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<FeedFilters>(() => ({
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 (
<main className="flex-1 min-w-0 overflow-y-auto">
{/* Banner + back */}
<div className="relative">
{ch?.banner_url ? (
<div
className="h-24 sm:h-36 w-full bg-cover bg-center"
style={{ backgroundImage: `url(${ch.banner_url})` }}
/>
) : (
<div className="h-14 w-full bg-surface" />
)}
<button
onClick={onBack}
className="absolute top-3 left-3 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm"
>
<ArrowLeft className="w-4 h-4" />
{t("channel.back")}
</button>
</div>
<div className="px-4 sm:px-6">
{/* Identity + actions */}
<div className="flex items-end gap-4 -mt-8">
<Avatar
src={ch?.thumbnail_url ?? null}
fallback={name}
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
/>
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-semibold truncate">{name}</h1>
{ch?.explored && !ch?.subscribed && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
{t("channel.exploringBadge")}
</span>
)}
</div>
{ch?.handle && (
<div className="text-sm text-muted truncate">@{ch.handle.replace(/^@/, "")}</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0 pb-1">
<a
href={ytUrl}
target="_blank"
rel="noreferrer"
title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg border border-border"
>
<ExternalLink className="w-4 h-4" />
</a>
{!me.is_demo &&
(ch?.subscribed ? (
<button
onClick={onUnsubscribe}
disabled={unsubscribe.isPending}
title={t("channel.unsubscribe")}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg disabled:opacity-50"
>
<Check className="w-4 h-4" />
{t("channel.subscribedState")}
</button>
) : (
<button
onClick={() => subscribe.mutate()}
disabled={subscribe.isPending}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50"
>
<Plus className="w-4 h-4" />
{t("channel.subscribe")}
</button>
))}
</div>
</div>
{/* Stats */}
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1 text-sm text-muted mt-3">
{ch?.subscriber_count != null && (
<span>{t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) })}</span>
)}
{ch?.video_count != null && <span>· {t("channel.videoCount", { count: ch.video_count })}</span>}
{ch?.total_view_count != null && (
<span>· {t("channel.totalViews", { formatted: formatViews(ch.total_view_count) })}</span>
)}
{joined && <span>· {t("channel.joined", { date: joined })}</span>}
</div>
{exploring && (
<div className="flex items-center gap-2 text-xs text-muted mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("channel.loadingVideos")}
</div>
)}
{/* Tabs */}
<div className="flex items-center gap-4 mt-4 border-b border-border">
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
{t("channel.tabVideos")}
</button>
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
{t("channel.tabAbout")}
</button>
</div>
</div>
{/* Tab content */}
{tab === "videos" ? (
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
canRead={me.can_read}
isDemo={me.is_demo}
onOpenWizard={() => {}}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
onOpenChannel={onOpenChannel}
/>
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<button
onClick={() => runExplore(nextToken)}
disabled={exploring}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border hover:bg-surface disabled:opacity-50"
>
{exploring ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
{t("channel.loadMore")}
</button>
</div>
)}
</>
) : (
<div className="px-4 sm:px-6 py-4 max-w-3xl">
{ch?.description ? (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{ch.description}</p>
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
{ch?.external_links && ch.external_links.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{ch.external_links.map((l, i) => (
<a
key={i}
href={l.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-accent hover:underline"
>
<ExternalLink className="w-3.5 h-3.5" />
{l.title || l.url}
</a>
))}
</div>
)}
</div>
)}
</main>
);
}

View file

@ -77,6 +77,7 @@ export default function Feed({
ytSearch, ytSearch,
onYtSearch, onYtSearch,
onExitYtSearch, onExitYtSearch,
onOpenChannel,
}: { }: {
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
@ -91,6 +92,9 @@ export default function Feed({
ytSearch: string | null; ytSearch: string | null;
onYtSearch: (q: string) => void; onYtSearch: (q: string) => void;
onExitYtSearch: () => 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 { t } = useTranslation();
const [overrides, setOverrides] = useState<Record<string, string>>({}); const [overrides, setOverrides] = useState<Record<string, string>>({});
@ -257,11 +261,11 @@ export default function Feed({
[qc] [qc]
); );
const onChannelFilter = useCallback( const handleOpenChannel = useCallback(
(channelId: string, channelName: string) => { (channelId: string, channelName: string) => {
setFilters({ ...filters, channelId, channelName }); onOpenChannel?.(channelId, channelName);
}, },
[filters, setFilters] [onOpenChannel]
); );
const onToggleSave = useCallback( const onToggleSave = useCallback(
@ -347,7 +351,7 @@ export default function Feed({
view={view} view={view}
onState={onState} onState={onState}
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter} onOpenChannel={handleOpenChannel}
onResetState={onResetState} onResetState={onResetState}
onOpen={openVideo} onOpen={openVideo}
hasNextPage={false} hasNextPage={false}
@ -568,7 +572,7 @@ export default function Feed({
view={view} view={view}
onState={onState} onState={onState}
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter} onOpenChannel={handleOpenChannel}
onResetState={onResetState} onResetState={onResetState}
onOpen={openVideo} onOpen={openVideo}
hasNextPage={!!hasNextPage} hasNextPage={!!hasNextPage}

View file

@ -6,7 +6,6 @@ import {
CheckCheck, CheckCheck,
Eye, Eye,
EyeOff, EyeOff,
ListFilter,
Play, Play,
RotateCcw, RotateCcw,
} from "lucide-react"; } from "lucide-react";
@ -21,13 +20,11 @@ function Actions({
onState, onState,
onResetState, onResetState,
onToggleSave, onToggleSave,
onChannelFilter,
}: { }: {
video: Video; video: Video;
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onResetState?: (id: string) => void; onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void; onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const act = (status: string) => (e: React.MouseEvent) => { const act = (status: string) => (e: React.MouseEvent) => {
@ -97,19 +94,6 @@ function Actions({
<RotateCcw className="w-4 h-4" /> <RotateCcw className="w-4 h-4" />
</button> </button>
)} )}
{onChannelFilter && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChannelFilter(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
title={t("card.onlyThisChannel")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
>
<ListFilter className="w-4 h-4" />
</button>
)}
</div> </div>
); );
} }
@ -244,7 +228,7 @@ function VideoCard({
onState, onState,
onResetState, onResetState,
onToggleSave, onToggleSave,
onChannelFilter, onOpenChannel,
onOpen, onOpen,
}: { }: {
video: Video; video: Video;
@ -252,7 +236,7 @@ function VideoCard({
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onResetState?: (id: string) => void; onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => 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; onOpen?: (v: Video, startAt?: number | null) => void;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@ -303,18 +287,18 @@ function VideoCard({
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} /> <Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{title} {title}
<a <button
href={video.channel_url} onClick={(e) => {
target="_blank" e.stopPropagation();
rel="noreferrer" onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
onClick={(e) => e.stopPropagation()} }}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg" className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
> >
{video.channel_title} {video.channel_title}
</a> </button>
<div className="text-xs text-muted mt-0.5">{meta}</div> <div className="text-xs text-muted mt-0.5">{meta}</div>
</div> </div>
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} /> <Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
</div> </div>
); );
} }
@ -335,17 +319,17 @@ function VideoCard({
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{title} {title}
<a <button
href={video.channel_url} onClick={(e) => {
target="_blank" e.stopPropagation();
rel="noreferrer" onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
onClick={(e) => e.stopPropagation()} }}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg" className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
> >
{video.channel_title} {video.channel_title}
</a> </button>
<div className="text-xs text-muted mt-0.5">{meta}</div> <div className="text-xs text-muted mt-0.5">{meta}</div>
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} /> <Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
</div> </div>
</div> </div>
</div> </div>

View file

@ -40,7 +40,7 @@ export interface VirtualFeedProps {
view: "grid" | "list"; view: "grid" | "list";
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void; onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter: (channelId: string, channelName: string) => void; onOpenChannel: (channelId: string, channelName: string) => void;
onResetState: (id: string) => void; onResetState: (id: string) => void;
onOpen: (v: Video, startAt?: number | null) => void; onOpen: (v: Video, startAt?: number | null) => void;
hasNextPage: boolean; hasNextPage: boolean;
@ -53,7 +53,7 @@ export default function VirtualFeed({
view, view,
onState, onState,
onToggleSave, onToggleSave,
onChannelFilter, onOpenChannel,
onResetState, onResetState,
onOpen, onOpen,
hasNextPage, hasNextPage,
@ -149,7 +149,7 @@ export default function VirtualFeed({
view="grid" view="grid"
onState={onState} onState={onState}
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter} onOpenChannel={onOpenChannel}
onResetState={onResetState} onResetState={onResetState}
onOpen={onOpen} onOpen={onOpen}
/> />
@ -162,7 +162,7 @@ export default function VirtualFeed({
view="list" view="list"
onState={onState} onState={onState}
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter} onOpenChannel={onOpenChannel}
onResetState={onResetState} onResetState={onResetState}
onOpen={onOpen} onOpen={onOpen}
/> />

View file

@ -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."
}

View file

@ -9,29 +9,94 @@
"quota": "Kontingent", "quota": "Kontingent",
"backfill": "Nachladen (Backfill)", "backfill": "Nachladen (Backfill)",
"shorts": "Shorts-Prüfung", "shorts": "Shorts-Prüfung",
"batch": "Stapelgrößen" "batch": "Stapelgrößen",
"explore": "Kanal erkunden"
}, },
"fields": { "fields": {
"smtp_host": { "label": "SMTP-Host", "hint": "z. B. smtp.gmail.com" }, "smtp_host": {
"smtp_port": { "label": "SMTP-Port", "hint": "Üblicherweise 587 (STARTTLS)." }, "label": "SMTP-Host",
"smtp_user": { "label": "SMTP-Benutzername", "hint": "Das sendende Konto / der Login." }, "hint": "z. B. smtp.gmail.com"
"smtp_from": { "label": "Absenderadresse", "hint": "z. B. Siftlode <addr@gmail.com>. Standard ist der Benutzername." }, },
"smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." }, "smtp_port": {
"quota_daily_budget": { "label": "Tägliches Kontingentbudget", "hint": "YouTube-Data-API-Einheiten pro Tag (kostenloses Limit 10.000). Standard 9000 lässt Puffer." }, "label": "SMTP-Port",
"backfill_quota_reserve": { "label": "Nachlade-Kontingentreserve", "hint": "Reservierte Einheiten, damit geplantes Nachladen interaktive Syncs / Anreicherung nie aushungert." }, "hint": "Üblicherweise 587 (STARTTLS)."
"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." }, "smtp_user": {
"backfill_recent_max_videos": { "label": "Aktuelles Nachladen — max. Videos", "hint": "Neueste Videos pro Kanal beim ersten Durchlauf." }, "label": "SMTP-Benutzername",
"backfill_recent_max_days": { "label": "Aktuelles Nachladen — max. Alter (Tage)", "hint": "Wie weit der erste Durchlauf pro Kanal zurückreicht." }, "hint": "Das sendende Konto / der Login."
"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." }, "smtp_from": {
"enrich_batch_size": { "label": "Anreicherungs-Stapelgröße", "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." }, "label": "Absenderadresse",
"autotag_title_sample": { "label": "Auto-Tag-Titelstichprobe", "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." }, "hint": "z. B. Siftlode <addr@gmail.com>. Standard ist der Benutzername."
"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." }, "smtp_password": {
"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)." }, "label": "SMTP-Passwort",
"google_client_secret": { "label": "Google-Client-Secret", "hint": "OAuth-2.0-Client-Secret. Verschlüsselt gespeichert; nur schreibbar." }, "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt."
"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." } },
"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", "save": "Speichern",
"saving": "Speichern…", "saving": "Speichern…",

View file

@ -13,5 +13,6 @@
"channels_subscribe": "Abonnieren", "channels_subscribe": "Abonnieren",
"channels_unsubscribe": "Abo beenden", "channels_unsubscribe": "Abo beenden",
"maintenance_revalidate": "Wartungs-Neuvalidierung", "maintenance_revalidate": "Wartungs-Neuvalidierung",
"other": "Sonstiges" "other": "Sonstiges",
"channels_explore": "Kanal erkunden"
} }

View file

@ -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.", "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.", "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.", "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": { "jobs": {
"rss_poll": "RSS-Abfrage (neue Uploads)", "rss_poll": "RSS-Abfrage (neue Uploads)",
@ -77,7 +78,8 @@
"subscriptions": "Abo-Resync", "subscriptions": "Abo-Resync",
"playlist_sync": "YouTube-Wiedergabelisten-Sync", "playlist_sync": "YouTube-Wiedergabelisten-Sync",
"maintenance": "Wartung + Validierung", "maintenance": "Wartung + Validierung",
"demo_reset": "Demo-Konto-Reset" "demo_reset": "Demo-Konto-Reset",
"explore_cleanup": "Bereinigung erkundeter Kanäle"
}, },
"queue": { "queue": {
"recentPending": "Zu synchronisierende Kanäle", "recentPending": "Zu synchronisierende Kanäle",

View file

@ -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."
}

View file

@ -9,29 +9,94 @@
"quota": "Quota", "quota": "Quota",
"backfill": "Backfill", "backfill": "Backfill",
"shorts": "Shorts probe", "shorts": "Shorts probe",
"batch": "Batch sizes" "batch": "Batch sizes",
"explore": "Channel explore"
}, },
"fields": { "fields": {
"smtp_host": { "label": "SMTP host", "hint": "e.g. smtp.gmail.com" }, "smtp_host": {
"smtp_port": { "label": "SMTP port", "hint": "Usually 587 (STARTTLS)." }, "label": "SMTP host",
"smtp_user": { "label": "SMTP username", "hint": "The sending account / login." }, "hint": "e.g. smtp.gmail.com"
"smtp_from": { "label": "From address", "hint": "e.g. Siftlode <addr@gmail.com>. Defaults to the username." }, },
"smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." }, "smtp_port": {
"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." }, "label": "SMTP port",
"backfill_quota_reserve": { "label": "Backfill quota reserve", "hint": "Units kept in reserve so scheduled backfill never starves interactive syncs / enrichment." }, "hint": "Usually 587 (STARTTLS)."
"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." }, "smtp_user": {
"backfill_recent_max_videos": { "label": "Recent backfill — max videos", "hint": "Newest videos fetched per channel on the first pass." }, "label": "SMTP username",
"backfill_recent_max_days": { "label": "Recent backfill — max age (days)", "hint": "How far back the first pass reaches per channel." }, "hint": "The sending account / login."
"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." }, "smtp_from": {
"enrich_batch_size": { "label": "Enrichment batch size", "hint": "videos.list ids per call (YouTube caps this at 50)." }, "label": "From address",
"autotag_title_sample": { "label": "Auto-tag title sample", "hint": "Recent video titles sampled per channel for language detection." }, "hint": "e.g. Siftlode <addr@gmail.com>. Defaults to the username."
"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." }, "smtp_password": {
"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)." }, "label": "SMTP password",
"google_client_secret": { "label": "Google client secret", "hint": "OAuth 2.0 client secret. Stored encrypted; write-only." }, "hint": "App password. Stored encrypted; write-only — it's never shown back."
"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." } },
"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", "save": "Save",
"saving": "Saving…", "saving": "Saving…",

View file

@ -13,5 +13,6 @@
"channels_subscribe": "Subscribe", "channels_subscribe": "Subscribe",
"channels_unsubscribe": "Unsubscribe", "channels_unsubscribe": "Unsubscribe",
"maintenance_revalidate": "Maintenance re-validation", "maintenance_revalidate": "Maintenance re-validation",
"other": "Other" "other": "Other",
"channels_explore": "Channel explore"
} }

View file

@ -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.", "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.", "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.", "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": { "jobs": {
"rss_poll": "RSS poll (new uploads)", "rss_poll": "RSS poll (new uploads)",
@ -77,7 +78,8 @@
"subscriptions": "Subscription resync", "subscriptions": "Subscription resync",
"playlist_sync": "YouTube playlist sync", "playlist_sync": "YouTube playlist sync",
"maintenance": "Maintenance + validation", "maintenance": "Maintenance + validation",
"demo_reset": "Demo account reset" "demo_reset": "Demo account reset",
"explore_cleanup": "Explored-channel cleanup"
}, },
"queue": { "queue": {
"recentPending": "Channels to sync", "recentPending": "Channels to sync",

View file

@ -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."
}

View file

@ -9,29 +9,94 @@
"quota": "Kvóta", "quota": "Kvóta",
"backfill": "Letöltés (backfill)", "backfill": "Letöltés (backfill)",
"shorts": "Shorts-vizsgálat", "shorts": "Shorts-vizsgálat",
"batch": "Kötegméretek" "batch": "Kötegméretek",
"explore": "Csatorna-felfedezés"
}, },
"fields": { "fields": {
"smtp_host": { "label": "SMTP-kiszolgáló", "hint": "pl. smtp.gmail.com" }, "smtp_host": {
"smtp_port": { "label": "SMTP-port", "hint": "Általában 587 (STARTTLS)." }, "label": "SMTP-kiszolgáló",
"smtp_user": { "label": "SMTP-felhasználónév", "hint": "A küldő fiók / bejelentkezés." }, "hint": "pl. smtp.gmail.com"
"smtp_from": { "label": "Feladó cím", "hint": "pl. Siftlode <addr@gmail.com>. 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." }, "smtp_port": {
"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." }, "label": "SMTP-port",
"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." }, "hint": "Általában 587 (STARTTLS)."
"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." }, "smtp_user": {
"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." }, "label": "SMTP-felhasználónév",
"backfill_recent_max_days": { "label": "Friss letöltés — max kor (nap)", "hint": "Az első kör csatornánként meddig nyúl vissza." }, "hint": "A küldő fiók / bejelentkezés."
"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." }, "smtp_from": {
"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)." }, "label": "Feladó cím",
"autotag_title_sample": { "label": "Auto-címke címmintavétel", "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." }, "hint": "pl. Siftlode <addr@gmail.com>. Alapból a felhasználónév."
"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." }, "smtp_password": {
"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)." }, "label": "SMTP-jelszó",
"google_client_secret": { "label": "Google kliens-titok", "hint": "OAuth 2.0 kliens-titok. Titkosítva tárolva; csak írható." }, "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra."
"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." } },
"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", "save": "Mentés",
"saving": "Mentés…", "saving": "Mentés…",

View file

@ -13,5 +13,6 @@
"channels_subscribe": "Feliratkozás", "channels_subscribe": "Feliratkozás",
"channels_unsubscribe": "Leiratkozás", "channels_unsubscribe": "Leiratkozás",
"maintenance_revalidate": "Karbantartó újraellenőrzés", "maintenance_revalidate": "Karbantartó újraellenőrzés",
"other": "Egyéb" "other": "Egyéb",
"channels_explore": "Csatorna-felfedezés"
} }

View file

@ -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.", "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.", "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.", "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": { "jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)", "rss_poll": "RSS-lekérdezés (új feltöltések)",
@ -77,7 +78,8 @@
"subscriptions": "Feliratkozások újraszinkronja", "subscriptions": "Feliratkozások újraszinkronja",
"playlist_sync": "YouTube lejátszási listák szinkronja", "playlist_sync": "YouTube lejátszási listák szinkronja",
"maintenance": "Karbantartás + ellenőrzés", "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": { "queue": {
"recentPending": "Szinkronra váró csatorna", "recentPending": "Szinkronra váró csatorna",

View file

@ -76,6 +76,37 @@ export interface VideoDetail {
duration_seconds: number | null; 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 { export interface FeedResponse {
items: Video[]; items: Video[];
has_more: boolean; has_more: boolean;
@ -628,6 +659,21 @@ export const api = {
subscribeChannel: (id: string) => subscribeChannel: (id: string) =>
req(`/api/channels/${id}/subscribe`, { method: "POST" }), req(`/api/channels/${id}/subscribe`, { method: "POST" }),
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
// --- channel page (About detail + ephemeral exploration of un-subscribed channels) ---
channelDetail: (id: string): Promise<ChannelDetail> => 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<ExploreResult> => {
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 ---
playlists: (containsVideoId?: string): Promise<Playlist[]> => playlists: (containsVideoId?: string): Promise<Playlist[]> =>