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