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:
parent
bc4c362423
commit
cc1e670202
18 changed files with 744 additions and 120 deletions
|
|
@ -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. */}
|
||||||
|
|
|
||||||
293
frontend/src/components/ChannelPage.tsx
Normal file
293
frontend/src/components/ChannelPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
20
frontend/src/i18n/locales/de/channel.json
Normal file
20
frontend/src/i18n/locales/de/channel.json
Normal 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."
|
||||||
|
}
|
||||||
|
|
@ -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…",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
20
frontend/src/i18n/locales/en/channel.json
Normal file
20
frontend/src/i18n/locales/en/channel.json
Normal 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."
|
||||||
|
}
|
||||||
|
|
@ -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…",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
20
frontend/src/i18n/locales/hu/channel.json
Normal file
20
frontend/src/i18n/locales/hu/channel.json
Normal 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."
|
||||||
|
}
|
||||||
|
|
@ -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…",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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[]> =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue