diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py
index ed14a89..4c47b71 100644
--- a/backend/app/routes/channels.py
+++ b/backend/app/routes/channels.py
@@ -209,7 +209,7 @@ def discover_channels(
def _channel_detail_dict(
- channel: Channel, *, subscribed: bool, explored: bool, stored: int
+ channel: Channel, *, subscribed: bool, explored: bool, blocked: bool, stored: int
) -> dict:
return {
"id": channel.id,
@@ -226,6 +226,7 @@ def _channel_detail_dict(
"country": channel.country,
"subscribed": subscribed,
"explored": explored,
+ "blocked": blocked,
"stored_videos": stored,
"from_explore": channel.from_explore,
"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
)
).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
return _channel_detail_dict(
- channel, subscribed=subscribed, explored=explored, stored=int(stored)
+ channel, subscribed=subscribed, explored=explored, blocked=blocked, stored=int(stored)
)
diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx
index fc856f7..2c81407 100644
--- a/frontend/src/components/ChannelPage.tsx
+++ b/frontend/src/components/ChannelPage.tsx
@@ -1,7 +1,7 @@
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 { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw } from "lucide-react";
import Avatar from "./Avatar";
import Feed from "./Feed";
import { useConfirm } from "./ConfirmProvider";
@@ -68,12 +68,21 @@ export default function ChannelPage({
useEffect(() => {
if (!ch || me.is_demo) return;
if (autoTried.current === channelId) return;
- if (ch.subscribed) return;
+ if (ch.subscribed || ch.blocked) return;
if (ch.explored && ch.stored_videos > 0) return;
autoTried.current = channelId;
runExplore().catch(() => {});
}, [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({
mutationFn: () => api.subscribeChannel(channelId),
onSuccess: () => {
@@ -178,10 +187,17 @@ export default function ChannelPage({
{name}
- {ch?.explored && !ch?.subscribed && (
-
- {t("channel.exploringBadge")}
+ {ch?.blocked ? (
+
+ {t("channel.blockedBadge")}
+ ) : (
+ ch?.explored &&
+ !ch?.subscribed && (
+
+ {t("channel.exploringBadge")}
+
+ )
)}
@@ -204,7 +220,23 @@ export default function ChannelPage({
>
+ {!me.is_demo && (
+
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"
+ }`}
+ >
+
+
+ )}
{!me.is_demo &&
+ !ch?.blocked &&
(ch?.subscribed ? (
api.unblockChannel(id),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["blocked-channels"] });
+ qc.invalidateQueries({ queryKey: ["feed"] });
+ },
+ });
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["channels"] });
@@ -567,6 +576,37 @@ export default function Channels({
emptyText={t("channels.empty")}
/>
)}
+
+ {/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */}
+ {(blockedQuery.data?.length ?? 0) > 0 && (
+
+
+
+
+ {t("channels.blocked.title")}
+
+
+
+ {blockedQuery.data!.map((c) => (
+
+ {c.title ?? c.id}
+ 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"
+ >
+
+
+
+ ))}
+
+
{t("channels.blocked.hint")}
+
+ )}
>
);
diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx
index 6c66450..64ec348 100644
--- a/frontend/src/components/Feed.tsx
+++ b/frontend/src/components/Feed.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
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 i18n from "../i18n";
import { notify, resolveVideo } from "../lib/notifications";
@@ -118,6 +118,9 @@ export default function Feed({
);
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
// 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
// revisited search from re-spending.
const ytQuery = useInfiniteQuery({
- queryKey: ["yt-search", ytSearch],
- queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null),
+ queryKey: ["yt-search", ytSearch, ytCount],
+ queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
enabled: ytActive,
@@ -316,33 +319,74 @@ export default function Feed({
return (
-
-
{
- // The search just ingested new catalog videos, so the normal feed (which was
- // disabled and still holds its pre-search cache) is stale. Drop those caches so
- // it re-fetches fresh on return instead of showing the old (often empty) result.
- // Surface the just-searched videos in the feed you return to: switch the Source
- // filter to "search" (your own search finds) and rank them by relevance.
- setFilters({ ...filters, librarySource: "search", sort: "relevance" });
- onExitYtSearch();
- qc.removeQueries({ queryKey: ["feed"] });
- qc.removeQueries({ queryKey: ["feed-count"] });
- qc.removeQueries({ queryKey: ["facets"] });
- }}
- className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0"
- >
-
- {t("feed.yt.back")}
-
-
-
-
- {t("feed.yt.resultsFor", { query: ytSearch })}
-
-
- {zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
-
+
+
+
{
+ // The search just ingested new catalog videos, so the normal feed (which was
+ // disabled and still holds its pre-search cache) is stale. Drop those caches so
+ // it re-fetches fresh on return instead of showing the old (often empty) result.
+ // Surface the just-searched videos in the feed you return to: switch the Source
+ // filter to "search" (your own search finds) and rank them by relevance.
+ setFilters({ ...filters, librarySource: "search", sort: "relevance" });
+ onExitYtSearch();
+ qc.removeQueries({ queryKey: ["feed"] });
+ qc.removeQueries({ queryKey: ["feed-count"] });
+ qc.removeQueries({ queryKey: ["facets"] });
+ }}
+ className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0"
+ >
+
+ {t("feed.yt.back")}
+
+
+
+
+ {t("feed.yt.resultsFor", { query: ytSearch })}
+
+
+ {zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
+
+
+ {t("feed.yt.show")}
+ 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) => (
+
+ {n}
+
+ ))}
+
+
+
+ {ytItems.length > 0 && (
+
+
+ {t("feed.yt.ephemeralNote")}
+ {
+ 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"
+ >
+
+ {t("feed.yt.clearNow")}
+
+
+ )}
{ytQuery.isLoading ? (
@@ -365,21 +409,6 @@ export default function Feed({
isFetchingNextPage={false}
fetchNextPage={() => {}}
/>
- {ytQuery.hasNextPage && (
-
- 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")}
-
-
- )}
>
)}
diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx
index 394434a..ba13bfe 100644
--- a/frontend/src/components/Scheduler.tsx
+++ b/frontend/src/components/Scheduler.tsx
@@ -13,6 +13,7 @@ import {
Pencil,
Play,
RadioTower,
+ Trash2,
X,
} from "lucide-react";
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
@@ -354,6 +355,15 @@ export default function Scheduler() {
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
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)
return
{t("scheduler.loading")}
;
@@ -398,6 +408,16 @@ export default function Scheduler() {
{t("scheduler.runAll")}
+
+ 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"
+ >
+
+ {t("scheduler.purgeDiscovery")}
+
+
pauseResume.mutate(data.paused)}
diff --git a/frontend/src/i18n/locales/de/channel.json b/frontend/src/i18n/locales/de/channel.json
index c302589..ffeac3c 100644
--- a/frontend/src/i18n/locales/de/channel.json
+++ b/frontend/src/i18n/locales/de/channel.json
@@ -16,5 +16,8 @@
"tabVideos": "Videos",
"tabAbout": "Info",
"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"
}
diff --git a/frontend/src/i18n/locales/de/channels.json b/frontend/src/i18n/locales/de/channels.json
index 1fd8b16..d653eef 100644
--- a/frontend/src/i18n/locales/de/channels.json
+++ b/frontend/src/i18n/locales/de/channels.json
@@ -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.",
"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."
+ }
}
diff --git a/frontend/src/i18n/locales/de/config.json b/frontend/src/i18n/locales/de/config.json
index e799cd9..e830ef2 100644
--- a/frontend/src/i18n/locales/de/config.json
+++ b/frontend/src/i18n/locales/de/config.json
@@ -96,6 +96,10 @@
"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."
+ },
+ "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",
diff --git a/frontend/src/i18n/locales/de/feed.json b/frontend/src/i18n/locales/de/feed.json
index 47ea118..e70ee55 100644
--- a/frontend/src/i18n/locales/de/feed.json
+++ b/frontend/src/i18n/locales/de/feed.json
@@ -48,6 +48,11 @@
"noResults": "Keine YouTube-Videos gefunden.",
"error": "YouTube-Suche fehlgeschlagen.",
"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"
}
}
diff --git a/frontend/src/i18n/locales/de/scheduler.json b/frontend/src/i18n/locales/de/scheduler.json
index 29ac42e..8c68143 100644
--- a/frontend/src/i18n/locales/de/scheduler.json
+++ b/frontend/src/i18n/locales/de/scheduler.json
@@ -92,5 +92,8 @@
"shortsPendingHint": "Bereits angereicherte Videos, die noch auf die Shorts-Prüfung warten.",
"liveRefresh": "Live zu aktualisieren",
"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"
}
diff --git a/frontend/src/i18n/locales/en/channel.json b/frontend/src/i18n/locales/en/channel.json
index d7c006e..c295fb3 100644
--- a/frontend/src/i18n/locales/en/channel.json
+++ b/frontend/src/i18n/locales/en/channel.json
@@ -16,5 +16,8 @@
"tabVideos": "Videos",
"tabAbout": "About",
"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"
}
diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json
index 8d8703a..5bd7c45 100644
--- a/frontend/src/i18n/locales/en/channels.json
+++ b/frontend/src/i18n/locales/en/channels.json
@@ -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.",
"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."
+ }
}
diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json
index 40fec51..df49ea3 100644
--- a/frontend/src/i18n/locales/en/config.json
+++ b/frontend/src/i18n/locales/en/config.json
@@ -96,6 +96,10 @@
"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."
+ },
+ "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",
diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json
index dffd8ea..390cbc6 100644
--- a/frontend/src/i18n/locales/en/feed.json
+++ b/frontend/src/i18n/locales/en/feed.json
@@ -48,6 +48,11 @@
"noResults": "No YouTube videos found.",
"error": "YouTube search failed.",
"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"
}
}
diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json
index 4979b2d..9106858 100644
--- a/frontend/src/i18n/locales/en/scheduler.json
+++ b/frontend/src/i18n/locales/en/scheduler.json
@@ -92,5 +92,8 @@
"shortsPendingHint": "Enriched videos still awaiting the Shorts probe.",
"liveRefresh": "Live to refresh",
"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"
}
diff --git a/frontend/src/i18n/locales/hu/channel.json b/frontend/src/i18n/locales/hu/channel.json
index 5c79491..6357dfe 100644
--- a/frontend/src/i18n/locales/hu/channel.json
+++ b/frontend/src/i18n/locales/hu/channel.json
@@ -16,5 +16,8 @@
"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."
+ "noDescription": "Ennek a csatornának nincs leírása.",
+ "block": "Csatorna tiltása",
+ "unblock": "Tiltás feloldása",
+ "blockedBadge": "Tiltva"
}
diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json
index f0617ab..5ff911f 100644
--- a/frontend/src/i18n/locales/hu/channels.json
+++ b/frontend/src/i18n/locales/hu/channels.json
@@ -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.",
"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."
+ }
}
diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json
index 3cd0fdc..b56fdcf 100644
--- a/frontend/src/i18n/locales/hu/config.json
+++ b/frontend/src/i18n/locales/hu/config.json
@@ -96,6 +96,10 @@
"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."
+ },
+ "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",
diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json
index 90765d5..e3d7fa9 100644
--- a/frontend/src/i18n/locales/hu/feed.json
+++ b/frontend/src/i18n/locales/hu/feed.json
@@ -48,6 +48,11 @@
"noResults": "Nincs YouTube-találat.",
"error": "A YouTube-keresés nem sikerült.",
"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"
}
}
diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json
index 6854a0f..dc90f61 100644
--- a/frontend/src/i18n/locales/hu/scheduler.json
+++ b/frontend/src/i18n/locales/hu/scheduler.json
@@ -92,5 +92,8 @@
"shortsPendingHint": "Már dúsított videók, amelyek még a Shorts-próbára várnak.",
"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."
- }
+ },
+ "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"
}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 0be8293..b77c16b 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -96,6 +96,7 @@ export interface ChannelDetail {
country: string | null;
subscribed: boolean;
explored: boolean;
+ blocked: boolean;
stored_videos: number;
from_explore: 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
// 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.
- searchYoutube: (q: string, cursor: string | null): Promise => {
+ searchYoutube: (q: string, cursor: string | null, limit?: number): Promise => {
const p = new URLSearchParams({ q });
if (cursor) p.set("cursor", cursor);
+ if (limit) p.set("limit", String(limit));
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 }> =>
req(`/api/facets?${filterParams(f).toString()}`),
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
@@ -674,6 +680,14 @@ export const api = {
{ 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> =>
+ req("/api/admin/purge-discovery", { method: "POST" }),
// --- playlists ---
playlists: (containsVideoId?: string): Promise =>