Merge: ephemeral live-search — auto-purge, count selector, channel blocklist, new-first

Live YouTube search results are now ephemeral previews: the discovery-cleanup job auto-removes
un-kept ones (no watch state / not playlisted / channel not subscribed) after a grace period, plus a
'Clear now' button + admin 'Purge discovery' for on-demand cleanup. A results-count selector sets the
batch size (Load more pulls another batch). Per-user channel blocklist (migration 0034) excludes
blocked channels from search ingest + feed/Library/explore/channel page, with Block/Unblock on the
channel page and a manager section. Results the user already has sink below genuinely-new discoveries.
This commit is contained in:
npeter83 2026-07-01 01:18:33 +02:00
commit c1e9a3c6f9
30 changed files with 663 additions and 152 deletions

View file

@ -0,0 +1,46 @@
"""per-user blocked channels (YouTube search / feed / explore)
Revision ID: 0034_blocked_channels
Revises: 0033_channel_explore
Create Date: 2026-07-01
Adds the `blocked_channels` table: a per-user blocklist. Videos from a blocked channel are
never shown in that user's live YouTube search (and not ingested), and are hidden from their
feed / explore views. Per-user, so one user's block doesn't affect anyone else.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0034_blocked_channels"
down_revision: Union[str, None] = "0033_channel_explore"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"blocked_channels",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("channel_id", sa.String(length=32), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["channel_id"], ["channels.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "channel_id", name="uq_user_blocked_channel"),
)
op.create_index("ix_blocked_channels_user_id", "blocked_channels", ["user_id"])
op.create_index("ix_blocked_channels_channel_id", "blocked_channels", ["channel_id"])
def downgrade() -> None:
op.drop_index("ix_blocked_channels_channel_id", table_name="blocked_channels")
op.drop_index("ix_blocked_channels_user_id", table_name="blocked_channels")
op.drop_table("blocked_channels")

View file

@ -124,6 +124,9 @@ class Settings(BaseSettings):
# days after it was last explored — the grace clock is ExploredChannel.created_at.
explore_cleanup_minutes: int = 1440
explore_grace_days: int = 7
# How long an un-kept live-search result (a via_search video nobody watched / saved /
# subscribed to) is kept before the same cleanup job removes it — "as if never added".
search_grace_days: int = 7
# live_status values hidden from the feed by default. Completed-stream VODs
# ("was_live") are real watchable content and stay visible.
feed_default_hidden_live: str = "live,upcoming"

View file

@ -435,6 +435,25 @@ class ExploredChannel(Base, TimestampMixin):
)
class BlockedChannel(Base, TimestampMixin):
"""Per-user channel blocklist. Videos from a blocked channel are excluded from this user's
live YouTube search (and not ingested on their behalf) and hidden from their feed / explore
views a personal "never show me this channel again" that doesn't affect other users."""
__tablename__ = "blocked_channels"
__table_args__ = (
UniqueConstraint("user_id", "channel_id", name="uq_user_blocked_channel"),
)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
channel_id: Mapped[str] = mapped_column(
ForeignKey("channels.id", ondelete="CASCADE"), index=True
)
class ApiQuotaUsage(Base):
"""Tracks YouTube Data API units spent per Pacific-time day (the quota resets at
midnight Pacific). The whole app shares one daily budget."""

View file

@ -376,3 +376,15 @@ def reset_demo(
same automatically (admin-tunable interval); this is the admin's on-demand button."""
demo = get_or_create_demo_user(db)
return {"reset": True, "playlists_seeded": reset_demo_state(db, demo)}
@router.post("/purge-discovery")
def purge_discovery(
_: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
"""Reclaim all un-kept discovery content NOW (ignoring the grace period): live-search result
videos nobody watched/saved/subscribed to, plus explored-but-unsubscribed channels. The
scheduled discovery-cleanup job does the same on its interval; this is the on-demand button."""
from app.sync.explore import purge_unkept_explored, purge_unkept_search
return {**purge_unkept_search(db, grace_days=0), **purge_unkept_explored(db)}

View file

@ -12,6 +12,7 @@ from app import quota, sysconfig
from app.auth import current_user, has_write_scope, require_human
from app.db import get_db
from app.models import (
BlockedChannel,
Channel,
ChannelTag,
ExploredChannel,
@ -208,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,
@ -225,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,
@ -265,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)
)
@ -287,6 +294,12 @@ def explore_channel(
channel = db.get(Channel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Unknown channel")
if db.execute(
select(BlockedChannel.id).where(
BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id
)
).first() is not None:
raise HTTPException(status_code=403, detail="You've blocked this channel.")
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
raise HTTPException(
status_code=429,
@ -504,6 +517,68 @@ def unsubscribe(
return {"unsubscribed": channel_id}
@router.get("/blocked/list")
def list_blocked(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> list[dict]:
"""The user's blocked channels (videos from these are excluded from their search/feed/explore)."""
rows = db.execute(
select(Channel)
.join(BlockedChannel, BlockedChannel.channel_id == Channel.id)
.where(BlockedChannel.user_id == user.id)
.order_by(func.lower(Channel.title))
).scalars().all()
return [
{"id": c.id, "title": c.title, "handle": c.handle, "thumbnail_url": c.thumbnail_url}
for c in rows
]
@router.post("/{channel_id}/block")
def block_channel(
channel_id: str,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Block a channel for this user: its videos won't appear in their live search (and won't be
ingested for them) and are hidden from their feed / explore. Idempotent. The now-hidden
un-kept search/explore videos are reclaimed by the discovery-cleanup job in due course."""
if db.get(Channel, channel_id) is None:
raise HTTPException(status_code=404, detail="Unknown channel")
exists = db.execute(
select(BlockedChannel.id).where(
BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id
)
).first()
if exists is None:
db.add(BlockedChannel(user_id=user.id, channel_id=channel_id))
# Stop any active exploration of it so it can be cleaned up.
db.execute(
ExploredChannel.__table__.delete().where(
(ExploredChannel.user_id == user.id)
& (ExploredChannel.channel_id == channel_id)
)
)
db.commit()
return {"blocked": channel_id}
@router.delete("/{channel_id}/block")
def unblock_channel(
channel_id: str,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
db.execute(
BlockedChannel.__table__.delete().where(
(BlockedChannel.user_id == user.id)
& (BlockedChannel.channel_id == channel_id)
)
)
db.commit()
return {"unblocked": channel_id}
@router.post("/{channel_id}/tags")
def attach_tag(
channel_id: str,

View file

@ -14,6 +14,7 @@ from app import quota
from app.auth import current_user
from app.db import get_db
from app.models import (
BlockedChannel,
Channel,
ChannelTag,
ExploredChannel,
@ -198,6 +199,14 @@ def _filtered_query(
)
)
# Per-user channel blocklist: never show this user videos from a channel they blocked
# (applies to feed, Library, explore, and the channel page alike).
query = query.where(
Video.channel_id.notin_(
select(BlockedChannel.channel_id).where(BlockedChannel.user_id == user.id)
)
)
# Source filter. In the shared Library ("all" scope) it uses the GLOBAL via_search flag
# (search-discovered by anyone). In "my" scope it's the user's OWN provenance:
# "organic" = your subscriptions, "search" = videos you found via your own search,

View file

@ -16,14 +16,24 @@ from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, func, select, update
from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session, aliased
from app import quota, sysconfig
from app.auth import require_human
from app.db import get_db
from app.models import Channel, SearchFind, User, Video, VideoState
from app.models import (
BlockedChannel,
Channel,
Playlist,
PlaylistItem,
SearchFind,
Subscription,
User,
Video,
VideoState,
)
from app.routes.feed import _serialize, feed_columns
from app.sync.subscriptions import apply_channel_details
from app.sync.videos import _insert_stubs, apply_video_details, parse_dt
@ -91,18 +101,119 @@ def _serialize_ids(db: Session, user: User, ids: list[str]) -> list[dict]:
return [by_id[i] for i in ids if i in by_id]
def _ingest_candidates(
db: Session, yt: YouTubeClient, user: User, candidates: list[dict]
) -> list[str]:
"""Materialise one page of search candidates into the catalog (channel stubs + enrich,
video stubs flagged via_search, enrich, Shorts classification) and return the KEEPABLE
video ids non-Short, non-live in the input relevance order."""
ids = [it["id"] for it in candidates]
if not ids:
return []
# 1) Channels are the FK target — create stubs for unknown ones (flagged from_search),
# then enrich the new ones (channels.list, 1 unit). Stubs survive an enrich failure.
channel_ids = list({it["channel_id"] for it in candidates})
existing = set(db.scalars(select(Channel.id).where(Channel.id.in_(channel_ids))).all())
new_channel_ids = [c for c in channel_ids if c not in existing]
if new_channel_ids:
titles = {it["channel_id"]: it["channel_title"] for it in candidates}
for cid in new_channel_ids:
db.add(Channel(id=cid, title=titles.get(cid), from_search=True))
db.commit()
try:
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER):
details = yt.get_channels(new_channel_ids)
apply_channel_details(db, details)
db.commit()
except YouTubeError:
db.rollback() # details can be backfilled later; the stubs remain
# 2) Video stubs, flagged via_search. Existing rows keep their flags (ON CONFLICT DO
# NOTHING), so an organically-synced video stays organic.
_insert_stubs(
db,
[
{
"id": it["id"],
"channel_id": it["channel_id"],
"title": it["title"],
"published_at": parse_dt(it["published_at"]),
"thumbnail_url": it["thumbnail_url"],
"via_search": True,
}
for it in candidates
],
)
# 3) Enrich (videos.list, 1 unit/50) so cards have duration/stats and we can judge live.
videos = db.scalars(select(Video).where(Video.id.in_(ids))).all()
try:
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
detail_by_id = {it["id"]: it for it in yt.get_videos(ids)}
except YouTubeError:
detail_by_id = {}
now = datetime.now(timezone.utc)
for v in videos:
item = detail_by_id.get(v.id)
if item is None:
continue
apply_video_details(v, item)
v.enriched_at = now
db.commit()
# 4) Confirm/deny Shorts (no quota) so none enter via search.
_classify_shorts(db, videos)
keep = {v.id for v in videos if not v.is_short and v.live_status not in _LIVE_HIDDEN}
return [vid for vid in ids if vid in keep]
def _new_first(db: Session, user: User, ids: list[str]) -> list[str]:
"""Reorder results so genuinely-new discoveries come first and things the user already has
(a subscribed channel, or a video they've watched / in-progress / saved / playlisted) sink
to the back keeping the relevance order within each group."""
if not ids:
return ids
sub_ch = select(Subscription.channel_id).where(Subscription.user_id == user.id)
has_state = (
select(VideoState.id)
.where(VideoState.video_id == Video.id, VideoState.user_id == user.id)
.exists()
)
in_playlist = (
select(PlaylistItem.id)
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
.where(PlaylistItem.video_id == Video.id, Playlist.user_id == user.id)
.exists()
)
have = set(
db.scalars(
select(Video.id).where(
Video.id.in_(ids),
or_(Video.channel_id.in_(sub_ch), has_state, in_playlist),
)
).all()
)
return [i for i in ids if i not in have] + [i for i in ids if i in have]
@router.get("/youtube")
def search_youtube(
q: str | None = None,
cursor: str | None = None,
limit: int = 20,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""One page of live YouTube search results, materialised into the catalog. `cursor` is the
YouTube nextPageToken (each page is another 100-unit search)."""
"""Live YouTube search results materialised into the catalog. `limit` is how many results to
gather (the count selector); with the free scrape source we page through continuations until
that many are collected, so there's no manual "load more". The API source costs 100 units per
page, so it returns a single page (50) regardless. `cursor` lets the caller fetch further."""
term = (q or "").strip()
if not term:
raise HTTPException(status_code=400, detail="A search term is required.")
limit = max(1, min(limit, 100))
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
source = (sysconfig.get_str(db, "search_source") or "scrape").lower()
@ -125,108 +236,56 @@ def search_youtube(
detail="The shared YouTube quota for today is used up. Try again tomorrow.",
)
# Channels this user has blocked are dropped before ingestion — never shown, never stored.
blocked = set(
db.scalars(
select(BlockedChannel.channel_id).where(BlockedChannel.user_id == user.id)
).all()
)
collected: list[str] = []
next_cursor = cursor
MAX_PAGES = 12 # scrape safety bound
with YouTubeClient(db, user) as yt:
try:
if source == "api":
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_SEARCH):
page = yt.search_videos(term, page_token=cursor)
else:
page = search_scrape.search(term, continuation=cursor)
# Zero-cost, but logged so the per-user daily cap above keeps counting it.
quota.log_action(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
except (YouTubeError, search_scrape.ScrapeError) as exc:
raise HTTPException(status_code=502, detail=f"YouTube search failed: {exc}")
# Drop currently-live/upcoming results (search can't filter them and we never ingest
# them this way) and anything missing a channel id.
candidates = [
it
for it in page["items"]
if it["live_broadcast"] == "none" and it["channel_id"]
]
ids = [it["id"] for it in candidates]
if not ids:
return {
"items": [],
"next_cursor": page["next_page_token"],
"has_more": bool(page["next_page_token"]),
"source": source,
}
# 1) Channels are the FK target — create stubs for unknown ones (flagged from_search),
# then enrich the new ones (channels.list, 1 unit). Stubs survive an enrich failure.
channel_ids = list({it["channel_id"] for it in candidates})
existing = set(
db.scalars(select(Channel.id).where(Channel.id.in_(channel_ids))).all()
)
new_channel_ids = [c for c in channel_ids if c not in existing]
if new_channel_ids:
titles = {it["channel_id"]: it["channel_title"] for it in candidates}
for cid in new_channel_ids:
db.add(Channel(id=cid, title=titles.get(cid), from_search=True))
db.commit()
for _ in range(MAX_PAGES):
try:
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER):
details = yt.get_channels(new_channel_ids)
apply_channel_details(db, details)
db.commit()
except YouTubeError:
db.rollback() # details can be backfilled later; the stubs remain
if source == "api":
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_SEARCH):
page = yt.search_videos(term, page_token=next_cursor)
else:
page = search_scrape.search(term, continuation=next_cursor)
# Zero-cost, but logged so the per-user daily cap above keeps counting it.
quota.log_action(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
except (YouTubeError, search_scrape.ScrapeError) as exc:
if collected:
break # keep what we already gathered
raise HTTPException(status_code=502, detail=f"YouTube search failed: {exc}")
# 2) Video stubs, flagged via_search. Existing rows keep their flags (ON CONFLICT DO
# NOTHING), so an organically-synced video stays organic.
_insert_stubs(
db,
[
{
"id": it["id"],
"channel_id": it["channel_id"],
"title": it["title"],
"published_at": parse_dt(it["published_at"]),
"thumbnail_url": it["thumbnail_url"],
"via_search": True,
}
for it in candidates
],
)
# Drop currently-live/upcoming, channel-less, and blocked-channel results.
candidates = [
it
for it in page["items"]
if it["live_broadcast"] == "none"
and it["channel_id"]
and it["channel_id"] not in blocked
]
collected.extend(_ingest_candidates(db, yt, user, candidates))
next_cursor = page["next_page_token"]
# The API source pages cost 100 units each — never auto-page; one page per request.
if source == "api" or len(collected) >= limit or not next_cursor:
break
# 3) Enrich (videos.list, 1 unit/50) so cards have duration/stats and we can judge live.
videos = db.scalars(select(Video).where(Video.id.in_(ids))).all()
try:
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
detail_by_id = {it["id"]: it for it in yt.get_videos(ids)}
except YouTubeError:
detail_by_id = {}
now = datetime.now(timezone.utc)
for v in videos:
item = detail_by_id.get(v.id)
if item is None:
continue
apply_video_details(v, item)
v.enriched_at = now
db.commit()
ordered = collected[:limit]
# 4) Confirm/deny Shorts (no quota) so none enter via search.
_classify_shorts(db, videos)
# 5) Exclude Shorts and still-live results; keep the search's relevance order.
keep = {
v.id for v in videos if not v.is_short and v.live_status not in _LIVE_HIDDEN
}
ordered = [vid for vid in ids if vid in keep]
# 6) Record these as THIS user's search finds so they surface in their own Mine feed
# (and the Mine Source filter), independent of the global via_search flag. Idempotent.
if ordered:
# Record these as THIS user's search finds (Mine feed / Source filter), and fold the
# query into each result's search_terms so local full-text search inherits YouTube's
# relevance. Idempotent; the generated search_vector updates automatically.
db.execute(
pg_insert(SearchFind)
.values([{"user_id": user.id, "video_id": vid} for vid in ordered])
.on_conflict_do_nothing(index_elements=["user_id", "video_id"])
)
# 7) Fold the query into each result's search_terms (shared across users) so local
# full-text search inherits YouTube's relevance — a video YT returned for this query
# becomes findable by it even when its title doesn't contain the words. Skip rows
# that already include the term; the generated search_vector updates automatically.
db.execute(
update(Video)
.where(Video.id.in_(ordered))
@ -239,9 +298,49 @@ def search_youtube(
)
db.commit()
ordered = _new_first(db, user, ordered)
return {
"items": _serialize_ids(db, user, ordered),
"next_cursor": page["next_page_token"],
"has_more": bool(page["next_page_token"]),
"next_cursor": next_cursor,
"has_more": bool(next_cursor),
"source": source,
}
@router.post("/clear")
def clear_search(
payload: dict,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Discard the given live-search results "as if never added": drop this user's search-finds
for them, then hard-delete the now-orphaned ones a via_search video nobody KEPT (no watch
state, not playlisted, channel not subscribed) and that no other user still has as a search
find. Kept videos and organically-owned ones are left untouched."""
ids = [str(v) for v in (payload.get("video_ids") or [])]
if not ids:
return {"removed": 0}
db.execute(
delete(SearchFind).where(
SearchFind.user_id == user.id, SearchFind.video_id.in_(ids)
)
)
db.commit()
has_state = select(VideoState.id).where(VideoState.video_id == Video.id).exists()
in_playlist = select(PlaylistItem.id).where(PlaylistItem.video_id == Video.id).exists()
subscribed = (
select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists()
)
other_find = select(SearchFind.id).where(SearchFind.video_id == Video.id).exists()
removed = db.execute(
delete(Video).where(
Video.id.in_(ids),
Video.via_search.is_(True),
~has_state,
~in_playlist,
~subscribed,
~other_find,
)
).rowcount
db.commit()
return {"removed": removed}

View file

@ -16,7 +16,7 @@ from app.models import SchedulerSetting
from app.notifications import create_notification
from app.state import is_sync_paused
from app.sync.autotag import run_autotag_all
from app.sync.explore import purge_unkept_explored
from app.sync.explore import purge_ephemeral
from app.sync.maintenance import run_maintenance
from app.sync.playlists import sync_all_playlists
from app.sync.runner import (
@ -271,9 +271,9 @@ def _demo_reset_job() -> None:
def _explore_cleanup_job() -> None:
# Hard-delete explored-but-never-kept channels and their untouched ephemeral videos after
# the grace period. 0 quota (pure DB), so no quota attribution.
_job("explore_cleanup", purge_unkept_explored)
# Reclaim un-kept ephemeral discovery content (explored channels + live-search results) after
# their grace periods. 0 quota (pure DB), so no quota attribution.
_job("explore_cleanup", purge_ephemeral)
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one,

View file

@ -143,3 +143,38 @@ def purge_unkept_explored(db: Session) -> dict:
"videos_deleted": videos_deleted,
"channels_deleted": channels_deleted,
}
def purge_unkept_search(db: Session, grace_days: int | None = None) -> dict:
"""Remove live-search result videos nobody kept, after a grace period — "as if never added".
A `via_search` video is KEPT (and spared) when anyone has watched / in-progress / hidden it
(a VideoState), saved/playlisted it (a PlaylistItem), or subscribes to its channel (it's then
organically owned). A bare SearchFind does NOT count it's the ephemeral "shown in search"
marker itself so a result you only glanced at is reclaimed. Deleting the video cascades its
SearchFinds. `grace_days=0` purges immediately (the admin "purge now" path); None uses config.
0 quota."""
grace = sysconfig.get_int(db, "search_grace_days") if grace_days is None else grace_days
cutoff = _now() - timedelta(days=grace)
state_exists = select(VideoState.id).where(VideoState.video_id == Video.id).exists()
pl_exists = select(PlaylistItem.id).where(PlaylistItem.video_id == Video.id).exists()
sub_exists = (
select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists()
)
deleted = db.execute(
delete(Video).where(
Video.via_search.is_(True),
Video.created_at < cutoff,
~state_exists,
~pl_exists,
~sub_exists,
)
).rowcount
db.commit()
return {"search_videos_deleted": deleted}
def purge_ephemeral(db: Session) -> dict:
"""The scheduler's discovery-cleanup pass: reclaim un-kept explored channels AND un-kept
live-search result videos in one run."""
return {**purge_unkept_explored(db), **purge_unkept_search(db)}

View file

@ -51,9 +51,11 @@ SPECS: tuple[ConfigSpec, ...] = (
# --- Batch sizes ---
ConfigSpec("enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50),
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
# --- Channel explore ---
# --- Discovery cleanup (search + explore ephemeral content) ---
# Days an explored-but-unsubscribed channel is kept before the cleanup job purges it.
ConfigSpec("explore_grace_days", "int", "explore", "explore_grace_days", min=0, max=3_650),
# Days an un-kept live-search result video is kept before the cleanup job removes it.
ConfigSpec("search_grace_days", "int", "explore", "search_grace_days", min=0, max=3_650),
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
# Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key).

View file

@ -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({
<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")}
{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">
{t("channel.exploringBadge")}
</span>
)
)}
</div>
<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" />
</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 &&
!ch?.blocked &&
(ch?.subscribed ? (
<button
onClick={onUnsubscribe}

View file

@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowDown,
ArrowUp,
Ban,
Check,
Eye,
EyeOff,
@ -87,7 +88,15 @@ export default function Channels({
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const blockedQuery = useQuery({ queryKey: ["blocked-channels"], queryFn: api.blockedChannels });
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 = () => {
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 && (
<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>
</>
);

View file

@ -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,75 @@ export default function Feed({
return (
<div className="p-4">
<div className="flex items-center gap-2 flex-wrap pb-3 mb-1 border-b border-border">
<button
onClick={() => {
// 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"
>
<ArrowLeft className="w-4 h-4" />
{t("feed.yt.back")}
</button>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<Youtube className="w-4 h-4 text-accent shrink-0" />
<span className="text-sm font-semibold truncate">
{t("feed.yt.resultsFor", { query: ytSearch })}
</span>
<span className="text-xs text-muted">
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
</span>
<div className="pb-3 mb-1 border-b border-border">
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => {
// 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"
>
<ArrowLeft className="w-4 h-4" />
{t("feed.yt.back")}
</button>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<Youtube className="w-4 h-4 text-accent shrink-0" />
<span className="text-sm font-semibold truncate">
{t("feed.yt.resultsFor", { query: ytSearch })}
</span>
<span className="text-xs text-muted">
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
</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))}
title={t("feed.yt.showHint")}
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>
{ytQuery.isLoading ? (

View file

@ -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 <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
@ -398,6 +408,16 @@ export default function Scheduler() {
{t("scheduler.runAll")}
</button>
</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")}>
<button
onClick={() => pauseResume.mutate(data.paused)}

View file

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

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.",
"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": {
"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",

View file

@ -48,6 +48,12 @@
"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",
"showHint": "Wie viele Ergebnisse auf einmal — Mehr laden holt einen weiteren Block dieser Größe."
}
}

View file

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

View file

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

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.",
"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": {
"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",

View file

@ -48,6 +48,12 @@
"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",
"showHint": "How many results to fetch at a time — Load more pulls another batch of this size."
}
}

View file

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

View file

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

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.",
"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": {
"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",

View file

@ -48,6 +48,12 @@
"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",
"showHint": "Hány találatot szerezzen egyszerre — a Több betöltése ennyivel tölt tovább."
}
}

View file

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

View file

@ -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<FeedResponse> => {
searchYoutube: (q: string, cursor: string | null, limit?: number): Promise<FeedResponse> => {
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<string, number> }> =>
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<Record<string, number>> =>
req("/api/admin/purge-discovery", { method: "POST" }),
// --- playlists ---
playlists: (containsVideoId?: string): Promise<Playlist[]> =>