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:
commit
c1e9a3c6f9
30 changed files with 663 additions and 152 deletions
46
backend/alembic/versions/0034_blocked_channels.py
Normal file
46
backend/alembic/versions/0034_blocked_channels.py
Normal 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")
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,74 +101,20 @@ 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]
|
||||
|
||||
|
||||
@router.get("/youtube")
|
||||
def search_youtube(
|
||||
q: str | None = None,
|
||||
cursor: str | None = None,
|
||||
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)."""
|
||||
term = (q or "").strip()
|
||||
if not term:
|
||||
raise HTTPException(status_code=400, detail="A search term is required.")
|
||||
|
||||
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
|
||||
source = (sysconfig.get_str(db, "search_source") or "scrape").lower()
|
||||
if source != "scrape":
|
||||
source = "api"
|
||||
|
||||
# Per-user daily cap applies to both sources (anti-abuse); with scrape it's the only guard
|
||||
# since the search itself spends no quota.
|
||||
daily_limit = sysconfig.get_int(db, "search_daily_limit_per_user")
|
||||
used = quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
|
||||
if used >= daily_limit:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"You've reached today's YouTube search limit ({daily_limit}). Try again tomorrow.",
|
||||
)
|
||||
# The 100-unit budget pre-check only applies to the API source.
|
||||
if source == "api" and not quota.can_spend(db, SEARCH_COST):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="The shared YouTube quota for today is used up. Try again tomorrow.",
|
||||
)
|
||||
|
||||
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"]
|
||||
]
|
||||
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 {
|
||||
"items": [],
|
||||
"next_cursor": page["next_page_token"],
|
||||
"has_more": bool(page["next_page_token"]),
|
||||
"source": source,
|
||||
}
|
||||
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()
|
||||
)
|
||||
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}
|
||||
|
|
@ -209,24 +165,127 @@ def search_youtube(
|
|||
# 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]
|
||||
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:
|
||||
"""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()
|
||||
if source != "scrape":
|
||||
source = "api"
|
||||
|
||||
# Per-user daily cap applies to both sources (anti-abuse); with scrape it's the only guard
|
||||
# since the search itself spends no quota.
|
||||
daily_limit = sysconfig.get_int(db, "search_daily_limit_per_user")
|
||||
used = quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
|
||||
if used >= daily_limit:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"You've reached today's YouTube search limit ({daily_limit}). Try again tomorrow.",
|
||||
)
|
||||
# The 100-unit budget pre-check only applies to the API source.
|
||||
if source == "api" and not quota.can_spend(db, SEARCH_COST):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
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:
|
||||
for _ in range(MAX_PAGES):
|
||||
try:
|
||||
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}")
|
||||
|
||||
# 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
|
||||
|
||||
ordered = collected[:limit]
|
||||
|
||||
# 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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
{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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,7 +319,8 @@ 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">
|
||||
<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
|
||||
|
|
@ -343,6 +347,47 @@ export default function Feed({
|
|||
<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 ? (
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]> =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue