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:
npeter83 2026-07-01 01:00:32 +02:00
parent f27f31b6db
commit 0c18845c34
21 changed files with 263 additions and 65 deletions

View file

@ -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)
) )

View file

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

View file

@ -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>
</> </>
); );

View file

@ -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>
)}
</> </>
)} )}

View file

@ -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)}

View file

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

View file

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

View file

@ -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",

View file

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

View file

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

View file

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

View file

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

View file

@ -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",

View file

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

View file

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

View file

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

View file

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

View file

@ -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",

View file

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

View file

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

View file

@ -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[]> =>