feat(search): ephemeral results UX, count selector, channel blocklist (frontend)
- Live-search view: a results-count selector (20/40/60/100) replaces manual load-more (the free scrape source pages until that many are gathered); an 'these results are temporary' banner with a 'Clear now' button that discards them 'as if never added' (api.clearSearch) and returns to the feed. - Channel blocklist: a Block/Unblock toggle + 'Blocked' badge on the channel page (blocked channels don't auto-explore and their videos are hidden), and a 'Blocked channels' section in the Channel manager with one-click unblock. ChannelDetail.blocked from the backend. - Admin: a 'Purge discovery' button on the Scheduler page (immediate un-kept search/explore cleanup). EN/HU/DE throughout.
This commit is contained in:
parent
f27f31b6db
commit
0c18845c34
21 changed files with 263 additions and 65 deletions
|
|
@ -209,7 +209,7 @@ def discover_channels(
|
||||||
|
|
||||||
|
|
||||||
def _channel_detail_dict(
|
def _channel_detail_dict(
|
||||||
channel: Channel, *, subscribed: bool, explored: bool, stored: int
|
channel: Channel, *, subscribed: bool, explored: bool, blocked: bool, stored: int
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": channel.id,
|
"id": channel.id,
|
||||||
|
|
@ -226,6 +226,7 @@ def _channel_detail_dict(
|
||||||
"country": channel.country,
|
"country": channel.country,
|
||||||
"subscribed": subscribed,
|
"subscribed": subscribed,
|
||||||
"explored": explored,
|
"explored": explored,
|
||||||
|
"blocked": blocked,
|
||||||
"stored_videos": stored,
|
"stored_videos": stored,
|
||||||
"from_explore": channel.from_explore,
|
"from_explore": channel.from_explore,
|
||||||
"details_synced": channel.details_synced_at is not None,
|
"details_synced": channel.details_synced_at is not None,
|
||||||
|
|
@ -266,9 +267,14 @@ def channel_detail(
|
||||||
ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id
|
ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id
|
||||||
)
|
)
|
||||||
).first() is not None
|
).first() is not None
|
||||||
|
blocked = db.execute(
|
||||||
|
select(BlockedChannel.id).where(
|
||||||
|
BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id
|
||||||
|
)
|
||||||
|
).first() is not None
|
||||||
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0
|
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0
|
||||||
return _channel_detail_dict(
|
return _channel_detail_dict(
|
||||||
channel, subscribed=subscribed, explored=explored, stored=int(stored)
|
channel, subscribed=subscribed, explored=explored, blocked=blocked, stored=int(stored)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, Check, ExternalLink, Loader2, Plus, RefreshCw } from "lucide-react";
|
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw } from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import Feed from "./Feed";
|
import Feed from "./Feed";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
@ -68,12 +68,21 @@ export default function ChannelPage({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ch || me.is_demo) return;
|
if (!ch || me.is_demo) return;
|
||||||
if (autoTried.current === channelId) return;
|
if (autoTried.current === channelId) return;
|
||||||
if (ch.subscribed) return;
|
if (ch.subscribed || ch.blocked) return;
|
||||||
if (ch.explored && ch.stored_videos > 0) return;
|
if (ch.explored && ch.stored_videos > 0) return;
|
||||||
autoTried.current = channelId;
|
autoTried.current = channelId;
|
||||||
runExplore().catch(() => {});
|
runExplore().catch(() => {});
|
||||||
}, [ch, channelId, me.is_demo, runExplore]);
|
}, [ch, channelId, me.is_demo, runExplore]);
|
||||||
|
|
||||||
|
const block = useMutation({
|
||||||
|
mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["channel", channelId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const subscribe = useMutation({
|
const subscribe = useMutation({
|
||||||
mutationFn: () => api.subscribeChannel(channelId),
|
mutationFn: () => api.subscribeChannel(channelId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -178,10 +187,17 @@ export default function ChannelPage({
|
||||||
<div className="flex-1 min-w-0 pb-1">
|
<div className="flex-1 min-w-0 pb-1">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h1 className="text-xl font-semibold truncate">{name}</h1>
|
<h1 className="text-xl font-semibold truncate">{name}</h1>
|
||||||
{ch?.explored && !ch?.subscribed && (
|
{ch?.blocked ? (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
|
||||||
|
{t("channel.blockedBadge")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
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">
|
<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")}
|
{t("channel.exploringBadge")}
|
||||||
</span>
|
</span>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
|
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
|
||||||
|
|
@ -204,7 +220,23 @@ export default function ChannelPage({
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
|
{!me.is_demo && (
|
||||||
|
<button
|
||||||
|
onClick={() => block.mutate()}
|
||||||
|
disabled={block.isPending}
|
||||||
|
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||||
|
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||||
|
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
|
||||||
|
ch?.blocked
|
||||||
|
? "border-danger text-danger"
|
||||||
|
: "border-border text-muted hover:text-danger hover:border-danger"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Ban className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{!me.is_demo &&
|
{!me.is_demo &&
|
||||||
|
!ch?.blocked &&
|
||||||
(ch?.subscribed ? (
|
(ch?.subscribed ? (
|
||||||
<button
|
<button
|
||||||
onClick={onUnsubscribe}
|
onClick={onUnsubscribe}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
|
Ban,
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
|
@ -87,7 +88,15 @@ export default function Channels({
|
||||||
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
||||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||||
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
||||||
|
const blockedQuery = useQuery({ queryKey: ["blocked-channels"], queryFn: api.blockedChannels });
|
||||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||||
|
const unblock = useMutation({
|
||||||
|
mutationFn: (id: string) => api.unblockChannel(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
qc.invalidateQueries({ queryKey: ["channels"] });
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
|
@ -567,6 +576,37 @@ export default function Channels({
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */}
|
||||||
|
{(blockedQuery.data?.length ?? 0) > 0 && (
|
||||||
|
<div className="mt-6 border-t border-border pt-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Ban className="w-4 h-4 text-muted" />
|
||||||
|
<span className="text-xs uppercase tracking-wide text-muted">
|
||||||
|
{t("channels.blocked.title")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{blockedQuery.data!.map((c) => (
|
||||||
|
<span
|
||||||
|
key={c.id}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
|
||||||
|
>
|
||||||
|
{c.title ?? c.id}
|
||||||
|
<button
|
||||||
|
onClick={() => unblock.mutate(c.id)}
|
||||||
|
title={t("channel.unblock")}
|
||||||
|
aria-label={t("channel.unblock")}
|
||||||
|
className="rounded-full p-0.5 text-muted hover:text-danger transition"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowDown, ArrowUp, ArrowLeft, RefreshCw, Youtube } from "lucide-react";
|
import { ArrowDown, ArrowUp, ArrowLeft, Info, RefreshCw, Trash2, Youtube } from "lucide-react";
|
||||||
import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
|
import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
import { notify, resolveVideo } from "../lib/notifications";
|
import { notify, resolveVideo } from "../lib/notifications";
|
||||||
|
|
@ -118,6 +118,9 @@ export default function Feed({
|
||||||
);
|
);
|
||||||
|
|
||||||
const ytActive = !!ytSearch;
|
const ytActive = !!ytSearch;
|
||||||
|
// How many live-search results to gather (the count selector replaces a manual "load more";
|
||||||
|
// the free scrape source pages through continuations until this many are collected).
|
||||||
|
const [ytCount, setYtCount] = useState(20);
|
||||||
|
|
||||||
// Debounce only the search term feeding the queries: the input still updates instantly (it
|
// Debounce only the search term feeding the queries: the input still updates instantly (it
|
||||||
// owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch —
|
// owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch —
|
||||||
|
|
@ -139,8 +142,8 @@ export default function Feed({
|
||||||
// retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
|
// retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
|
||||||
// revisited search from re-spending.
|
// revisited search from re-spending.
|
||||||
const ytQuery = useInfiniteQuery({
|
const ytQuery = useInfiniteQuery({
|
||||||
queryKey: ["yt-search", ytSearch],
|
queryKey: ["yt-search", ytSearch, ytCount],
|
||||||
queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null),
|
queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
|
||||||
initialPageParam: null as string | null,
|
initialPageParam: null as string | null,
|
||||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||||
enabled: ytActive,
|
enabled: ytActive,
|
||||||
|
|
@ -316,7 +319,8 @@ export default function Feed({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center gap-2 flex-wrap pb-3 mb-1 border-b border-border">
|
<div className="pb-3 mb-1 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// The search just ingested new catalog videos, so the normal feed (which was
|
// The search just ingested new catalog videos, so the normal feed (which was
|
||||||
|
|
@ -343,6 +347,46 @@ export default function Feed({
|
||||||
<span className="text-xs text-muted">
|
<span className="text-xs text-muted">
|
||||||
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
|
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
|
||||||
</span>
|
</span>
|
||||||
|
<label className="ml-auto flex items-center gap-1.5 text-xs text-muted shrink-0">
|
||||||
|
{t("feed.yt.show")}
|
||||||
|
<select
|
||||||
|
value={ytCount}
|
||||||
|
onChange={(e) => setYtCount(Number(e.target.value))}
|
||||||
|
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{[20, 40, 60, 100].map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{ytItems.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
|
||||||
|
<Info className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span>{t("feed.yt.ephemeralNote")}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const ids = ytItems.map((v) => v.id);
|
||||||
|
api
|
||||||
|
.clearSearch(ids)
|
||||||
|
.then((r) => notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }))
|
||||||
|
.catch(() => {});
|
||||||
|
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
|
||||||
|
onExitYtSearch();
|
||||||
|
qc.removeQueries({ queryKey: ["feed"] });
|
||||||
|
qc.removeQueries({ queryKey: ["feed-count"] });
|
||||||
|
qc.removeQueries({ queryKey: ["facets"] });
|
||||||
|
qc.removeQueries({ queryKey: ["yt-search"] });
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
{t("feed.yt.clearNow")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ytQuery.isLoading ? (
|
{ytQuery.isLoading ? (
|
||||||
|
|
@ -365,21 +409,6 @@ export default function Feed({
|
||||||
isFetchingNextPage={false}
|
isFetchingNextPage={false}
|
||||||
fetchNextPage={() => {}}
|
fetchNextPage={() => {}}
|
||||||
/>
|
/>
|
||||||
{ytQuery.hasNextPage && (
|
|
||||||
<div className="text-center pt-4">
|
|
||||||
<button
|
|
||||||
onClick={() => ytQuery.fetchNextPage()}
|
|
||||||
disabled={ytQuery.isFetchingNextPage}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-border bg-card text-sm font-medium hover:border-accent hover:text-accent disabled:opacity-50 transition"
|
|
||||||
>
|
|
||||||
{ytQuery.isFetchingNextPage
|
|
||||||
? t("feed.loadingMore")
|
|
||||||
: zeroQuota
|
|
||||||
? t("feed.yt.loadMoreFree")
|
|
||||||
: t("feed.yt.loadMore")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
RadioTower,
|
RadioTower,
|
||||||
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
|
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
|
||||||
|
|
@ -354,6 +355,15 @@ export default function Scheduler() {
|
||||||
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
|
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
|
||||||
});
|
});
|
||||||
|
const purgeMut = useMutation({
|
||||||
|
mutationFn: () => api.purgeDiscovery(),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
const removed =
|
||||||
|
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0);
|
||||||
|
notify({ level: "success", message: t("scheduler.purgedDiscovery", { count: removed }) });
|
||||||
|
qc.invalidateQueries({ queryKey: ["scheduler"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (q.isLoading && !data)
|
if (q.isLoading && !data)
|
||||||
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
|
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
|
||||||
|
|
@ -398,6 +408,16 @@ export default function Scheduler() {
|
||||||
{t("scheduler.runAll")}
|
{t("scheduler.runAll")}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip hint={t("scheduler.purgeDiscoveryHint")}>
|
||||||
|
<button
|
||||||
|
onClick={() => purgeMut.mutate()}
|
||||||
|
disabled={purgeMut.isPending}
|
||||||
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
{t("scheduler.purgeDiscovery")}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip hint={t("scheduler.pauseHint")}>
|
<Tooltip hint={t("scheduler.pauseHint")}>
|
||||||
<button
|
<button
|
||||||
onClick={() => pauseResume.mutate(data.paused)}
|
onClick={() => pauseResume.mutate(data.paused)}
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,8 @@
|
||||||
"tabVideos": "Videos",
|
"tabVideos": "Videos",
|
||||||
"tabAbout": "Info",
|
"tabAbout": "Info",
|
||||||
"loadMore": "Mehr von YouTube laden",
|
"loadMore": "Mehr von YouTube laden",
|
||||||
"noDescription": "Dieser Kanal hat keine Beschreibung."
|
"noDescription": "Dieser Kanal hat keine Beschreibung.",
|
||||||
|
"block": "Kanal blockieren",
|
||||||
|
"unblock": "Blockierung aufheben",
|
||||||
|
"blockedBadge": "Blockiert"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,5 +123,9 @@
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.",
|
"confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.",
|
||||||
"confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent.",
|
"confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent.",
|
||||||
"searchPlaceholder": "Kanäle suchen…"
|
"searchPlaceholder": "Kanäle suchen…",
|
||||||
|
"blocked": {
|
||||||
|
"title": "Blockierte Kanäle",
|
||||||
|
"hint": "Ihre Videos sind in deinem Feed, der Bibliothek und der Live-Suche ausgeblendet."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,10 @@
|
||||||
"explore_grace_days": {
|
"explore_grace_days": {
|
||||||
"label": "Erkunden — Karenzzeit (Tage)",
|
"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."
|
"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."
|
||||||
|
},
|
||||||
|
"search_grace_days": {
|
||||||
|
"label": "Suchergebnisse — Karenzzeit (Tage)",
|
||||||
|
"hint": "Wie lange ein nicht behaltenes Live-Suchergebnis-Video (niemand hat es angesehen/gespeichert/abonniert) behalten wird, bevor die Entdeckungs-Aufräumaufgabe es entfernt."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@
|
||||||
"noResults": "Keine YouTube-Videos gefunden.",
|
"noResults": "Keine YouTube-Videos gefunden.",
|
||||||
"error": "YouTube-Suche fehlgeschlagen.",
|
"error": "YouTube-Suche fehlgeschlagen.",
|
||||||
"loadMore": "Mehr laden (verbraucht Kontingent)",
|
"loadMore": "Mehr laden (verbraucht Kontingent)",
|
||||||
"loadMoreFree": "Mehr laden"
|
"loadMoreFree": "Mehr laden",
|
||||||
|
"show": "Zeigen",
|
||||||
|
"ephemeralNote": "Diese Ergebnisse sind temporär — sie verschwinden automatisch, außer du siehst dir eines an oder speicherst es.",
|
||||||
|
"clearNow": "Jetzt löschen",
|
||||||
|
"cleared_one": "{{count}} Ergebnis gelöscht",
|
||||||
|
"cleared_other": "{{count}} Ergebnisse gelöscht"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,5 +92,8 @@
|
||||||
"shortsPendingHint": "Bereits angereicherte Videos, die noch auf die Shorts-Prüfung warten.",
|
"shortsPendingHint": "Bereits angereicherte Videos, die noch auf die Shorts-Prüfung warten.",
|
||||||
"liveRefresh": "Live zu aktualisieren",
|
"liveRefresh": "Live zu aktualisieren",
|
||||||
"liveRefreshHint": "Live-/bevorstehende Videos (und gerade beendete Streams ohne Dauer), bis sie sich stabilisieren."
|
"liveRefreshHint": "Live-/bevorstehende Videos (und gerade beendete Streams ohne Dauer), bis sie sich stabilisieren."
|
||||||
}
|
},
|
||||||
|
"purgeDiscovery": "Entdeckung leeren",
|
||||||
|
"purgeDiscoveryHint": "Alle nicht behaltenen Entdeckungsinhalte jetzt entfernen (von niemandem angesehene/gespeicherte Suchergebnisse und erkundete, nicht abonnierte Kanäle) — ohne Karenzzeit.",
|
||||||
|
"purgedDiscovery": "{{count}} Entdeckungselemente entfernt"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,8 @@
|
||||||
"tabVideos": "Videos",
|
"tabVideos": "Videos",
|
||||||
"tabAbout": "About",
|
"tabAbout": "About",
|
||||||
"loadMore": "Load more from YouTube",
|
"loadMore": "Load more from YouTube",
|
||||||
"noDescription": "This channel has no description."
|
"noDescription": "This channel has no description.",
|
||||||
|
"block": "Block channel",
|
||||||
|
"unblock": "Unblock channel",
|
||||||
|
"blockedBadge": "Blocked"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,5 +123,9 @@
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.",
|
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.",
|
||||||
"confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota.",
|
"confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota.",
|
||||||
"searchPlaceholder": "Search channels…"
|
"searchPlaceholder": "Search channels…",
|
||||||
|
"blocked": {
|
||||||
|
"title": "Blocked channels",
|
||||||
|
"hint": "Their videos are hidden from your feed, Library, and live search."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,10 @@
|
||||||
"explore_grace_days": {
|
"explore_grace_days": {
|
||||||
"label": "Explore — grace period (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."
|
"hint": "How long an explored-but-unsubscribed channel (and its ephemeral videos) is kept before the cleanup job removes it. Subscribing keeps it permanently."
|
||||||
|
},
|
||||||
|
"search_grace_days": {
|
||||||
|
"label": "Search results — grace period (days)",
|
||||||
|
"hint": "How long an un-kept live-search result video (nobody watched / saved / subscribed to) is kept before the discovery-cleanup job removes it."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@
|
||||||
"noResults": "No YouTube videos found.",
|
"noResults": "No YouTube videos found.",
|
||||||
"error": "YouTube search failed.",
|
"error": "YouTube search failed.",
|
||||||
"loadMore": "Load more (uses quota)",
|
"loadMore": "Load more (uses quota)",
|
||||||
"loadMoreFree": "Load more"
|
"loadMoreFree": "Load more",
|
||||||
|
"show": "Show",
|
||||||
|
"ephemeralNote": "These results are temporary — they clear automatically unless you watch or save one.",
|
||||||
|
"clearNow": "Clear now",
|
||||||
|
"cleared_one": "Cleared {{count}} result",
|
||||||
|
"cleared_other": "Cleared {{count}} results"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,5 +92,8 @@
|
||||||
"shortsPendingHint": "Enriched videos still awaiting the Shorts probe.",
|
"shortsPendingHint": "Enriched videos still awaiting the Shorts probe.",
|
||||||
"liveRefresh": "Live to refresh",
|
"liveRefresh": "Live to refresh",
|
||||||
"liveRefreshHint": "Live/upcoming videos (and just-ended streams without a duration yet) re-checked until they settle."
|
"liveRefreshHint": "Live/upcoming videos (and just-ended streams without a duration yet) re-checked until they settle."
|
||||||
}
|
},
|
||||||
|
"purgeDiscovery": "Purge discovery",
|
||||||
|
"purgeDiscoveryHint": "Remove all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.",
|
||||||
|
"purgedDiscovery": "Removed {{count}} discovery items"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,8 @@
|
||||||
"tabVideos": "Videók",
|
"tabVideos": "Videók",
|
||||||
"tabAbout": "Névjegy",
|
"tabAbout": "Névjegy",
|
||||||
"loadMore": "Több betöltése a YouTube-ról",
|
"loadMore": "Több betöltése a YouTube-ról",
|
||||||
"noDescription": "Ennek a csatornának nincs leírása."
|
"noDescription": "Ennek a csatornának nincs leírása.",
|
||||||
|
"block": "Csatorna tiltása",
|
||||||
|
"unblock": "Tiltás feloldása",
|
||||||
|
"blockedBadge": "Tiltva"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,5 +123,9 @@
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el.",
|
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el.",
|
||||||
"confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt.",
|
"confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt.",
|
||||||
"searchPlaceholder": "Csatornák keresése…"
|
"searchPlaceholder": "Csatornák keresése…",
|
||||||
|
"blocked": {
|
||||||
|
"title": "Tiltott csatornák",
|
||||||
|
"hint": "A videóik el vannak rejtve a feededből, a Library-ből és a YouTube-keresésből."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,10 @@
|
||||||
"explore_grace_days": {
|
"explore_grace_days": {
|
||||||
"label": "Felfedezés — türelmi idő (nap)",
|
"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."
|
"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."
|
||||||
|
},
|
||||||
|
"search_grace_days": {
|
||||||
|
"label": "Keresési találatok — türelmi idő (nap)",
|
||||||
|
"hint": "Meddig marad meg egy meg nem tartott YouTube-keresési találat (amit senki nem nézett meg / mentett el / iratkozott fel rá), mielőtt a felfedezés-takarító feladat törli."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"save": "Mentés",
|
"save": "Mentés",
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@
|
||||||
"noResults": "Nincs YouTube-találat.",
|
"noResults": "Nincs YouTube-találat.",
|
||||||
"error": "A YouTube-keresés nem sikerült.",
|
"error": "A YouTube-keresés nem sikerült.",
|
||||||
"loadMore": "Továbbiak betöltése (kvótát fogyaszt)",
|
"loadMore": "Továbbiak betöltése (kvótát fogyaszt)",
|
||||||
"loadMoreFree": "Továbbiak betöltése"
|
"loadMoreFree": "Továbbiak betöltése",
|
||||||
|
"show": "Mutat",
|
||||||
|
"ephemeralNote": "Ezek a találatok ideiglenesek — maguktól eltűnnek, hacsak meg nem nézel vagy el nem mentesz egyet.",
|
||||||
|
"clearNow": "Törlés most",
|
||||||
|
"cleared_one": "{{count}} találat törölve",
|
||||||
|
"cleared_other": "{{count}} találat törölve"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,5 +92,8 @@
|
||||||
"shortsPendingHint": "Már dúsított videók, amelyek még a Shorts-próbára várnak.",
|
"shortsPendingHint": "Már dúsított videók, amelyek még a Shorts-próbára várnak.",
|
||||||
"liveRefresh": "Frissítendő élő",
|
"liveRefresh": "Frissítendő élő",
|
||||||
"liveRefreshHint": "Élő/közelgő videók (és a frissen véget ért, hossz nélküli adások), amíg le nem ülepednek."
|
"liveRefreshHint": "Élő/közelgő videók (és a frissen véget ért, hossz nélküli adások), amíg le nem ülepednek."
|
||||||
}
|
},
|
||||||
|
"purgeDiscovery": "Felfedezés ürítése",
|
||||||
|
"purgeDiscoveryHint": "Most eltávolítja az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.",
|
||||||
|
"purgedDiscovery": "{{count}} felfedezés-elem eltávolítva"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ export interface ChannelDetail {
|
||||||
country: string | null;
|
country: string | null;
|
||||||
subscribed: boolean;
|
subscribed: boolean;
|
||||||
explored: boolean;
|
explored: boolean;
|
||||||
|
blocked: boolean;
|
||||||
stored_videos: number;
|
stored_videos: number;
|
||||||
from_explore: boolean;
|
from_explore: boolean;
|
||||||
details_synced: boolean;
|
details_synced: boolean;
|
||||||
|
|
@ -601,11 +602,16 @@ export const api = {
|
||||||
// `quiet` so the YT-search panel can render quota/limit errors (incl. 429) inline instead of
|
// `quiet` so the YT-search panel can render quota/limit errors (incl. 429) inline instead of
|
||||||
// popping the global modal. `cursor` is the next-page token (an InnerTube continuation token
|
// popping the global modal. `cursor` is the next-page token (an InnerTube continuation token
|
||||||
// in scrape mode, or a Data API pageToken in api mode); the response's `source` says which.
|
// in scrape mode, or a Data API pageToken in api mode); the response's `source` says which.
|
||||||
searchYoutube: (q: string, cursor: string | null): Promise<FeedResponse> => {
|
searchYoutube: (q: string, cursor: string | null, limit?: number): Promise<FeedResponse> => {
|
||||||
const p = new URLSearchParams({ q });
|
const p = new URLSearchParams({ q });
|
||||||
if (cursor) p.set("cursor", cursor);
|
if (cursor) p.set("cursor", cursor);
|
||||||
|
if (limit) p.set("limit", String(limit));
|
||||||
return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true });
|
return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true });
|
||||||
},
|
},
|
||||||
|
// Discard a set of live-search results "as if never added" (drops your search-finds + deletes
|
||||||
|
// the now-orphaned, un-kept videos). Returns how many videos were removed.
|
||||||
|
clearSearch: (videoIds: string[]): Promise<{ removed: number }> =>
|
||||||
|
req("/api/search/clear", { method: "POST", body: JSON.stringify({ video_ids: videoIds }) }),
|
||||||
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
|
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
|
||||||
req(`/api/facets?${filterParams(f).toString()}`),
|
req(`/api/facets?${filterParams(f).toString()}`),
|
||||||
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
|
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
|
||||||
|
|
@ -674,6 +680,14 @@ export const api = {
|
||||||
{ quiet: true }
|
{ quiet: true }
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
// --- per-user channel blocklist (excluded from live search + feed/explore) ---
|
||||||
|
blockedChannels: (): Promise<{ id: string; title: string | null; handle: string | null; thumbnail_url: string | null }[]> =>
|
||||||
|
req("/api/channels/blocked/list"),
|
||||||
|
blockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "POST" }),
|
||||||
|
unblockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "DELETE" }),
|
||||||
|
// Admin: reclaim all un-kept discovery content (search results + explored channels) now.
|
||||||
|
purgeDiscovery: (): Promise<Record<string, number>> =>
|
||||||
|
req("/api/admin/purge-discovery", { method: "POST" }),
|
||||||
|
|
||||||
// --- playlists ---
|
// --- playlists ---
|
||||||
playlists: (containsVideoId?: string): Promise<Playlist[]> =>
|
playlists: (containsVideoId?: string): Promise<Playlist[]> =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue