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.
|
# days after it was last explored — the grace clock is ExploredChannel.created_at.
|
||||||
explore_cleanup_minutes: int = 1440
|
explore_cleanup_minutes: int = 1440
|
||||||
explore_grace_days: int = 7
|
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
|
# live_status values hidden from the feed by default. Completed-stream VODs
|
||||||
# ("was_live") are real watchable content and stay visible.
|
# ("was_live") are real watchable content and stay visible.
|
||||||
feed_default_hidden_live: str = "live,upcoming"
|
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):
|
class ApiQuotaUsage(Base):
|
||||||
"""Tracks YouTube Data API units spent per Pacific-time day (the quota resets at
|
"""Tracks YouTube Data API units spent per Pacific-time day (the quota resets at
|
||||||
midnight Pacific). The whole app shares one daily budget."""
|
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."""
|
same automatically (admin-tunable interval); this is the admin's on-demand button."""
|
||||||
demo = get_or_create_demo_user(db)
|
demo = get_or_create_demo_user(db)
|
||||||
return {"reset": True, "playlists_seeded": reset_demo_state(db, demo)}
|
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.auth import current_user, has_write_scope, require_human
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
BlockedChannel,
|
||||||
Channel,
|
Channel,
|
||||||
ChannelTag,
|
ChannelTag,
|
||||||
ExploredChannel,
|
ExploredChannel,
|
||||||
|
|
@ -208,7 +209,7 @@ def discover_channels(
|
||||||
|
|
||||||
|
|
||||||
def _channel_detail_dict(
|
def _channel_detail_dict(
|
||||||
channel: Channel, *, subscribed: bool, explored: bool, stored: int
|
channel: Channel, *, subscribed: bool, explored: bool, blocked: bool, stored: int
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": channel.id,
|
"id": channel.id,
|
||||||
|
|
@ -225,6 +226,7 @@ def _channel_detail_dict(
|
||||||
"country": channel.country,
|
"country": channel.country,
|
||||||
"subscribed": subscribed,
|
"subscribed": subscribed,
|
||||||
"explored": explored,
|
"explored": explored,
|
||||||
|
"blocked": blocked,
|
||||||
"stored_videos": stored,
|
"stored_videos": stored,
|
||||||
"from_explore": channel.from_explore,
|
"from_explore": channel.from_explore,
|
||||||
"details_synced": channel.details_synced_at is not None,
|
"details_synced": channel.details_synced_at is not None,
|
||||||
|
|
@ -265,9 +267,14 @@ def channel_detail(
|
||||||
ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id
|
ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id
|
||||||
)
|
)
|
||||||
).first() is not None
|
).first() is not None
|
||||||
|
blocked = db.execute(
|
||||||
|
select(BlockedChannel.id).where(
|
||||||
|
BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id
|
||||||
|
)
|
||||||
|
).first() is not None
|
||||||
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0
|
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0
|
||||||
return _channel_detail_dict(
|
return _channel_detail_dict(
|
||||||
channel, subscribed=subscribed, explored=explored, stored=int(stored)
|
channel, subscribed=subscribed, explored=explored, blocked=blocked, stored=int(stored)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -287,6 +294,12 @@ def explore_channel(
|
||||||
channel = db.get(Channel, channel_id)
|
channel = db.get(Channel, channel_id)
|
||||||
if channel is None:
|
if channel is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown channel")
|
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"):
|
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
|
|
@ -504,6 +517,68 @@ def unsubscribe(
|
||||||
return {"unsubscribed": channel_id}
|
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")
|
@router.post("/{channel_id}/tags")
|
||||||
def attach_tag(
|
def attach_tag(
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from app import quota
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
BlockedChannel,
|
||||||
Channel,
|
Channel,
|
||||||
ChannelTag,
|
ChannelTag,
|
||||||
ExploredChannel,
|
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
|
# 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:
|
# (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,
|
# "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 datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
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.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app import quota, sysconfig
|
from app import quota, sysconfig
|
||||||
from app.auth import require_human
|
from app.auth import require_human
|
||||||
from app.db import get_db
|
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.routes.feed import _serialize, feed_columns
|
||||||
from app.sync.subscriptions import apply_channel_details
|
from app.sync.subscriptions import apply_channel_details
|
||||||
from app.sync.videos import _insert_stubs, apply_video_details, parse_dt
|
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]
|
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")
|
@router.get("/youtube")
|
||||||
def search_youtube(
|
def search_youtube(
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
|
limit: int = 20,
|
||||||
user: User = Depends(require_human),
|
user: User = Depends(require_human),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""One page of live YouTube search results, materialised into the catalog. `cursor` is the
|
"""Live YouTube search results materialised into the catalog. `limit` is how many results to
|
||||||
YouTube nextPageToken (each page is another 100-unit search)."""
|
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()
|
term = (q or "").strip()
|
||||||
if not term:
|
if not term:
|
||||||
raise HTTPException(status_code=400, detail="A search term is required.")
|
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: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
|
||||||
source = (sysconfig.get_str(db, "search_source") or "scrape").lower()
|
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.",
|
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:
|
with YouTubeClient(db, user) as yt:
|
||||||
try:
|
for _ in range(MAX_PAGES):
|
||||||
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()
|
|
||||||
try:
|
try:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER):
|
if source == "api":
|
||||||
details = yt.get_channels(new_channel_ids)
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_SEARCH):
|
||||||
apply_channel_details(db, details)
|
page = yt.search_videos(term, page_token=next_cursor)
|
||||||
db.commit()
|
else:
|
||||||
except YouTubeError:
|
page = search_scrape.search(term, continuation=next_cursor)
|
||||||
db.rollback() # details can be backfilled later; the stubs remain
|
# 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
|
# Drop currently-live/upcoming, channel-less, and blocked-channel results.
|
||||||
# NOTHING), so an organically-synced video stays organic.
|
candidates = [
|
||||||
_insert_stubs(
|
it
|
||||||
db,
|
for it in page["items"]
|
||||||
[
|
if it["live_broadcast"] == "none"
|
||||||
{
|
and it["channel_id"]
|
||||||
"id": it["id"],
|
and it["channel_id"] not in blocked
|
||||||
"channel_id": it["channel_id"],
|
]
|
||||||
"title": it["title"],
|
collected.extend(_ingest_candidates(db, yt, user, candidates))
|
||||||
"published_at": parse_dt(it["published_at"]),
|
next_cursor = page["next_page_token"]
|
||||||
"thumbnail_url": it["thumbnail_url"],
|
# The API source pages cost 100 units each — never auto-page; one page per request.
|
||||||
"via_search": True,
|
if source == "api" or len(collected) >= limit or not next_cursor:
|
||||||
}
|
break
|
||||||
for it in candidates
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3) Enrich (videos.list, 1 unit/50) so cards have duration/stats and we can judge live.
|
ordered = collected[:limit]
|
||||||
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)
|
|
||||||
|
|
||||||
# 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:
|
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(
|
db.execute(
|
||||||
pg_insert(SearchFind)
|
pg_insert(SearchFind)
|
||||||
.values([{"user_id": user.id, "video_id": vid} for vid in ordered])
|
.values([{"user_id": user.id, "video_id": vid} for vid in ordered])
|
||||||
.on_conflict_do_nothing(index_elements=["user_id", "video_id"])
|
.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(
|
db.execute(
|
||||||
update(Video)
|
update(Video)
|
||||||
.where(Video.id.in_(ordered))
|
.where(Video.id.in_(ordered))
|
||||||
|
|
@ -239,9 +298,49 @@ def search_youtube(
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
ordered = _new_first(db, user, ordered)
|
||||||
return {
|
return {
|
||||||
"items": _serialize_ids(db, user, ordered),
|
"items": _serialize_ids(db, user, ordered),
|
||||||
"next_cursor": page["next_page_token"],
|
"next_cursor": next_cursor,
|
||||||
"has_more": bool(page["next_page_token"]),
|
"has_more": bool(next_cursor),
|
||||||
"source": source,
|
"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.notifications import create_notification
|
||||||
from app.state import is_sync_paused
|
from app.state import is_sync_paused
|
||||||
from app.sync.autotag import run_autotag_all
|
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.maintenance import run_maintenance
|
||||||
from app.sync.playlists import sync_all_playlists
|
from app.sync.playlists import sync_all_playlists
|
||||||
from app.sync.runner import (
|
from app.sync.runner import (
|
||||||
|
|
@ -271,9 +271,9 @@ def _demo_reset_job() -> None:
|
||||||
|
|
||||||
|
|
||||||
def _explore_cleanup_job() -> None:
|
def _explore_cleanup_job() -> None:
|
||||||
# Hard-delete explored-but-never-kept channels and their untouched ephemeral videos after
|
# Reclaim un-kept ephemeral discovery content (explored channels + live-search results) after
|
||||||
# the grace period. 0 quota (pure DB), so no quota attribution.
|
# their grace periods. 0 quota (pure DB), so no quota attribution.
|
||||||
_job("explore_cleanup", purge_unkept_explored)
|
_job("explore_cleanup", purge_ephemeral)
|
||||||
|
|
||||||
|
|
||||||
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
|
# 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,
|
"videos_deleted": videos_deleted,
|
||||||
"channels_deleted": channels_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 ---
|
# --- Batch sizes ---
|
||||||
ConfigSpec("enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50),
|
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),
|
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.
|
# 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),
|
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) ---
|
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
|
||||||
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
|
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).
|
# 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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, Check, ExternalLink, Loader2, Plus, RefreshCw } from "lucide-react";
|
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw } from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import Feed from "./Feed";
|
import Feed from "./Feed";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
@ -68,12 +68,21 @@ export default function ChannelPage({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ch || me.is_demo) return;
|
if (!ch || me.is_demo) return;
|
||||||
if (autoTried.current === channelId) return;
|
if (autoTried.current === channelId) return;
|
||||||
if (ch.subscribed) return;
|
if (ch.subscribed || ch.blocked) return;
|
||||||
if (ch.explored && ch.stored_videos > 0) return;
|
if (ch.explored && ch.stored_videos > 0) return;
|
||||||
autoTried.current = channelId;
|
autoTried.current = channelId;
|
||||||
runExplore().catch(() => {});
|
runExplore().catch(() => {});
|
||||||
}, [ch, channelId, me.is_demo, runExplore]);
|
}, [ch, channelId, me.is_demo, runExplore]);
|
||||||
|
|
||||||
|
const block = useMutation({
|
||||||
|
mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["channel", channelId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const subscribe = useMutation({
|
const subscribe = useMutation({
|
||||||
mutationFn: () => api.subscribeChannel(channelId),
|
mutationFn: () => api.subscribeChannel(channelId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -178,10 +187,17 @@ export default function ChannelPage({
|
||||||
<div className="flex-1 min-w-0 pb-1">
|
<div className="flex-1 min-w-0 pb-1">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h1 className="text-xl font-semibold truncate">{name}</h1>
|
<h1 className="text-xl font-semibold truncate">{name}</h1>
|
||||||
{ch?.explored && !ch?.subscribed && (
|
{ch?.blocked ? (
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
|
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
|
||||||
{t("channel.exploringBadge")}
|
{t("channel.blockedBadge")}
|
||||||
</span>
|
</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>
|
||||||
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
|
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
|
||||||
|
|
@ -204,7 +220,23 @@ export default function ChannelPage({
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
|
{!me.is_demo && (
|
||||||
|
<button
|
||||||
|
onClick={() => block.mutate()}
|
||||||
|
disabled={block.isPending}
|
||||||
|
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||||
|
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||||
|
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
|
||||||
|
ch?.blocked
|
||||||
|
? "border-danger text-danger"
|
||||||
|
: "border-border text-muted hover:text-danger hover:border-danger"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Ban className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{!me.is_demo &&
|
{!me.is_demo &&
|
||||||
|
!ch?.blocked &&
|
||||||
(ch?.subscribed ? (
|
(ch?.subscribed ? (
|
||||||
<button
|
<button
|
||||||
onClick={onUnsubscribe}
|
onClick={onUnsubscribe}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
|
Ban,
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
|
@ -87,7 +88,15 @@ export default function Channels({
|
||||||
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
||||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||||
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
||||||
|
const blockedQuery = useQuery({ queryKey: ["blocked-channels"], queryFn: api.blockedChannels });
|
||||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||||
|
const unblock = useMutation({
|
||||||
|
mutationFn: (id: string) => api.unblockChannel(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
qc.invalidateQueries({ queryKey: ["channels"] });
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
|
@ -567,6 +576,37 @@ export default function Channels({
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */}
|
||||||
|
{(blockedQuery.data?.length ?? 0) > 0 && (
|
||||||
|
<div className="mt-6 border-t border-border pt-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Ban className="w-4 h-4 text-muted" />
|
||||||
|
<span className="text-xs uppercase tracking-wide text-muted">
|
||||||
|
{t("channels.blocked.title")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{blockedQuery.data!.map((c) => (
|
||||||
|
<span
|
||||||
|
key={c.id}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
|
||||||
|
>
|
||||||
|
{c.title ?? c.id}
|
||||||
|
<button
|
||||||
|
onClick={() => unblock.mutate(c.id)}
|
||||||
|
title={t("channel.unblock")}
|
||||||
|
aria-label={t("channel.unblock")}
|
||||||
|
className="rounded-full p-0.5 text-muted hover:text-danger transition"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowDown, ArrowUp, ArrowLeft, RefreshCw, Youtube } from "lucide-react";
|
import { ArrowDown, ArrowUp, ArrowLeft, Info, RefreshCw, Trash2, Youtube } from "lucide-react";
|
||||||
import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
|
import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
import { notify, resolveVideo } from "../lib/notifications";
|
import { notify, resolveVideo } from "../lib/notifications";
|
||||||
|
|
@ -118,6 +118,9 @@ export default function Feed({
|
||||||
);
|
);
|
||||||
|
|
||||||
const ytActive = !!ytSearch;
|
const ytActive = !!ytSearch;
|
||||||
|
// How many live-search results to gather (the count selector replaces a manual "load more";
|
||||||
|
// the free scrape source pages through continuations until this many are collected).
|
||||||
|
const [ytCount, setYtCount] = useState(20);
|
||||||
|
|
||||||
// Debounce only the search term feeding the queries: the input still updates instantly (it
|
// Debounce only the search term feeding the queries: the input still updates instantly (it
|
||||||
// owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch —
|
// owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch —
|
||||||
|
|
@ -139,8 +142,8 @@ export default function Feed({
|
||||||
// retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
|
// retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
|
||||||
// revisited search from re-spending.
|
// revisited search from re-spending.
|
||||||
const ytQuery = useInfiniteQuery({
|
const ytQuery = useInfiniteQuery({
|
||||||
queryKey: ["yt-search", ytSearch],
|
queryKey: ["yt-search", ytSearch, ytCount],
|
||||||
queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null),
|
queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
|
||||||
initialPageParam: null as string | null,
|
initialPageParam: null as string | null,
|
||||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||||
enabled: ytActive,
|
enabled: ytActive,
|
||||||
|
|
@ -316,33 +319,75 @@ export default function Feed({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center gap-2 flex-wrap pb-3 mb-1 border-b border-border">
|
<div className="pb-3 mb-1 border-b border-border">
|
||||||
<button
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
onClick={() => {
|
<button
|
||||||
// The search just ingested new catalog videos, so the normal feed (which was
|
onClick={() => {
|
||||||
// disabled and still holds its pre-search cache) is stale. Drop those caches so
|
// The search just ingested new catalog videos, so the normal feed (which was
|
||||||
// it re-fetches fresh on return instead of showing the old (often empty) result.
|
// disabled and still holds its pre-search cache) is stale. Drop those caches so
|
||||||
// Surface the just-searched videos in the feed you return to: switch the Source
|
// it re-fetches fresh on return instead of showing the old (often empty) result.
|
||||||
// filter to "search" (your own search finds) and rank them by relevance.
|
// Surface the just-searched videos in the feed you return to: switch the Source
|
||||||
setFilters({ ...filters, librarySource: "search", sort: "relevance" });
|
// filter to "search" (your own search finds) and rank them by relevance.
|
||||||
onExitYtSearch();
|
setFilters({ ...filters, librarySource: "search", sort: "relevance" });
|
||||||
qc.removeQueries({ queryKey: ["feed"] });
|
onExitYtSearch();
|
||||||
qc.removeQueries({ queryKey: ["feed-count"] });
|
qc.removeQueries({ queryKey: ["feed"] });
|
||||||
qc.removeQueries({ queryKey: ["facets"] });
|
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"
|
}}
|
||||||
>
|
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")}
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</button>
|
{t("feed.yt.back")}
|
||||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
</button>
|
||||||
<Youtube className="w-4 h-4 text-accent shrink-0" />
|
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||||
<span className="text-sm font-semibold truncate">
|
<Youtube className="w-4 h-4 text-accent shrink-0" />
|
||||||
{t("feed.yt.resultsFor", { query: ytSearch })}
|
<span className="text-sm font-semibold truncate">
|
||||||
</span>
|
{t("feed.yt.resultsFor", { query: ytSearch })}
|
||||||
<span className="text-xs text-muted">
|
</span>
|
||||||
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
|
<span className="text-xs text-muted">
|
||||||
</span>
|
{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>
|
</div>
|
||||||
|
|
||||||
{ytQuery.isLoading ? (
|
{ytQuery.isLoading ? (
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
RadioTower,
|
RadioTower,
|
||||||
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
|
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
|
||||||
|
|
@ -354,6 +355,15 @@ export default function Scheduler() {
|
||||||
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
|
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
|
||||||
});
|
});
|
||||||
|
const purgeMut = useMutation({
|
||||||
|
mutationFn: () => api.purgeDiscovery(),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
const removed =
|
||||||
|
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0);
|
||||||
|
notify({ level: "success", message: t("scheduler.purgedDiscovery", { count: removed }) });
|
||||||
|
qc.invalidateQueries({ queryKey: ["scheduler"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (q.isLoading && !data)
|
if (q.isLoading && !data)
|
||||||
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
|
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
|
||||||
|
|
@ -398,6 +408,16 @@ export default function Scheduler() {
|
||||||
{t("scheduler.runAll")}
|
{t("scheduler.runAll")}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip hint={t("scheduler.purgeDiscoveryHint")}>
|
||||||
|
<button
|
||||||
|
onClick={() => purgeMut.mutate()}
|
||||||
|
disabled={purgeMut.isPending}
|
||||||
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
{t("scheduler.purgeDiscovery")}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip hint={t("scheduler.pauseHint")}>
|
<Tooltip hint={t("scheduler.pauseHint")}>
|
||||||
<button
|
<button
|
||||||
onClick={() => pauseResume.mutate(data.paused)}
|
onClick={() => pauseResume.mutate(data.paused)}
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,8 @@
|
||||||
"tabVideos": "Videos",
|
"tabVideos": "Videos",
|
||||||
"tabAbout": "Info",
|
"tabAbout": "Info",
|
||||||
"loadMore": "Mehr von YouTube laden",
|
"loadMore": "Mehr von YouTube laden",
|
||||||
"noDescription": "Dieser Kanal hat keine Beschreibung."
|
"noDescription": "Dieser Kanal hat keine Beschreibung.",
|
||||||
|
"block": "Kanal blockieren",
|
||||||
|
"unblock": "Blockierung aufheben",
|
||||||
|
"blockedBadge": "Blockiert"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,5 +123,9 @@
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.",
|
"confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.",
|
||||||
"confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent.",
|
"confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent.",
|
||||||
"searchPlaceholder": "Kanäle suchen…"
|
"searchPlaceholder": "Kanäle suchen…",
|
||||||
|
"blocked": {
|
||||||
|
"title": "Blockierte Kanäle",
|
||||||
|
"hint": "Ihre Videos sind in deinem Feed, der Bibliothek und der Live-Suche ausgeblendet."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,10 @@
|
||||||
"explore_grace_days": {
|
"explore_grace_days": {
|
||||||
"label": "Erkunden — Karenzzeit (Tage)",
|
"label": "Erkunden — Karenzzeit (Tage)",
|
||||||
"hint": "Wie lange ein erkundeter, nicht abonnierter Kanal (und seine flüchtigen Videos) behalten wird, bevor die Aufräumaufgabe ihn entfernt. Ein Abo behält ihn dauerhaft."
|
"hint": "Wie lange ein erkundeter, nicht abonnierter Kanal (und seine flüchtigen Videos) behalten wird, bevor die Aufräumaufgabe ihn entfernt. Ein Abo behält ihn dauerhaft."
|
||||||
|
},
|
||||||
|
"search_grace_days": {
|
||||||
|
"label": "Suchergebnisse — Karenzzeit (Tage)",
|
||||||
|
"hint": "Wie lange ein nicht behaltenes Live-Suchergebnis-Video (niemand hat es angesehen/gespeichert/abonniert) behalten wird, bevor die Entdeckungs-Aufräumaufgabe es entfernt."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,12 @@
|
||||||
"noResults": "Keine YouTube-Videos gefunden.",
|
"noResults": "Keine YouTube-Videos gefunden.",
|
||||||
"error": "YouTube-Suche fehlgeschlagen.",
|
"error": "YouTube-Suche fehlgeschlagen.",
|
||||||
"loadMore": "Mehr laden (verbraucht Kontingent)",
|
"loadMore": "Mehr laden (verbraucht Kontingent)",
|
||||||
"loadMoreFree": "Mehr laden"
|
"loadMoreFree": "Mehr laden",
|
||||||
|
"show": "Zeigen",
|
||||||
|
"ephemeralNote": "Diese Ergebnisse sind temporär — sie verschwinden automatisch, außer du siehst dir eines an oder speicherst es.",
|
||||||
|
"clearNow": "Jetzt löschen",
|
||||||
|
"cleared_one": "{{count}} Ergebnis gelöscht",
|
||||||
|
"cleared_other": "{{count}} Ergebnisse gelöscht",
|
||||||
|
"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.",
|
"shortsPendingHint": "Bereits angereicherte Videos, die noch auf die Shorts-Prüfung warten.",
|
||||||
"liveRefresh": "Live zu aktualisieren",
|
"liveRefresh": "Live zu aktualisieren",
|
||||||
"liveRefreshHint": "Live-/bevorstehende Videos (und gerade beendete Streams ohne Dauer), bis sie sich stabilisieren."
|
"liveRefreshHint": "Live-/bevorstehende Videos (und gerade beendete Streams ohne Dauer), bis sie sich stabilisieren."
|
||||||
}
|
},
|
||||||
|
"purgeDiscovery": "Entdeckung leeren",
|
||||||
|
"purgeDiscoveryHint": "Alle nicht behaltenen Entdeckungsinhalte jetzt entfernen (von niemandem angesehene/gespeicherte Suchergebnisse und erkundete, nicht abonnierte Kanäle) — ohne Karenzzeit.",
|
||||||
|
"purgedDiscovery": "{{count}} Entdeckungselemente entfernt"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,8 @@
|
||||||
"tabVideos": "Videos",
|
"tabVideos": "Videos",
|
||||||
"tabAbout": "About",
|
"tabAbout": "About",
|
||||||
"loadMore": "Load more from YouTube",
|
"loadMore": "Load more from YouTube",
|
||||||
"noDescription": "This channel has no description."
|
"noDescription": "This channel has no description.",
|
||||||
|
"block": "Block channel",
|
||||||
|
"unblock": "Unblock channel",
|
||||||
|
"blockedBadge": "Blocked"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,5 +123,9 @@
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.",
|
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.",
|
||||||
"confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota.",
|
"confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota.",
|
||||||
"searchPlaceholder": "Search channels…"
|
"searchPlaceholder": "Search channels…",
|
||||||
|
"blocked": {
|
||||||
|
"title": "Blocked channels",
|
||||||
|
"hint": "Their videos are hidden from your feed, Library, and live search."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,10 @@
|
||||||
"explore_grace_days": {
|
"explore_grace_days": {
|
||||||
"label": "Explore — grace period (days)",
|
"label": "Explore — grace period (days)",
|
||||||
"hint": "How long an explored-but-unsubscribed channel (and its ephemeral videos) is kept before the cleanup job removes it. Subscribing keeps it permanently."
|
"hint": "How long an explored-but-unsubscribed channel (and its ephemeral videos) is kept before the cleanup job removes it. Subscribing keeps it permanently."
|
||||||
|
},
|
||||||
|
"search_grace_days": {
|
||||||
|
"label": "Search results — grace period (days)",
|
||||||
|
"hint": "How long an un-kept live-search result video (nobody watched / saved / subscribed to) is kept before the discovery-cleanup job removes it."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,12 @@
|
||||||
"noResults": "No YouTube videos found.",
|
"noResults": "No YouTube videos found.",
|
||||||
"error": "YouTube search failed.",
|
"error": "YouTube search failed.",
|
||||||
"loadMore": "Load more (uses quota)",
|
"loadMore": "Load more (uses quota)",
|
||||||
"loadMoreFree": "Load more"
|
"loadMoreFree": "Load more",
|
||||||
|
"show": "Show",
|
||||||
|
"ephemeralNote": "These results are temporary — they clear automatically unless you watch or save one.",
|
||||||
|
"clearNow": "Clear now",
|
||||||
|
"cleared_one": "Cleared {{count}} result",
|
||||||
|
"cleared_other": "Cleared {{count}} results",
|
||||||
|
"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.",
|
"shortsPendingHint": "Enriched videos still awaiting the Shorts probe.",
|
||||||
"liveRefresh": "Live to refresh",
|
"liveRefresh": "Live to refresh",
|
||||||
"liveRefreshHint": "Live/upcoming videos (and just-ended streams without a duration yet) re-checked until they settle."
|
"liveRefreshHint": "Live/upcoming videos (and just-ended streams without a duration yet) re-checked until they settle."
|
||||||
}
|
},
|
||||||
|
"purgeDiscovery": "Purge discovery",
|
||||||
|
"purgeDiscoveryHint": "Remove all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.",
|
||||||
|
"purgedDiscovery": "Removed {{count}} discovery items"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,8 @@
|
||||||
"tabVideos": "Videók",
|
"tabVideos": "Videók",
|
||||||
"tabAbout": "Névjegy",
|
"tabAbout": "Névjegy",
|
||||||
"loadMore": "Több betöltése a YouTube-ról",
|
"loadMore": "Több betöltése a YouTube-ról",
|
||||||
"noDescription": "Ennek a csatornának nincs leírása."
|
"noDescription": "Ennek a csatornának nincs leírása.",
|
||||||
|
"block": "Csatorna tiltása",
|
||||||
|
"unblock": "Tiltás feloldása",
|
||||||
|
"blockedBadge": "Tiltva"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,5 +123,9 @@
|
||||||
},
|
},
|
||||||
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el.",
|
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el.",
|
||||||
"confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt.",
|
"confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt.",
|
||||||
"searchPlaceholder": "Csatornák keresése…"
|
"searchPlaceholder": "Csatornák keresése…",
|
||||||
|
"blocked": {
|
||||||
|
"title": "Tiltott csatornák",
|
||||||
|
"hint": "A videóik el vannak rejtve a feededből, a Library-ből és a YouTube-keresésből."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,10 @@
|
||||||
"explore_grace_days": {
|
"explore_grace_days": {
|
||||||
"label": "Felfedezés — türelmi idő (nap)",
|
"label": "Felfedezés — türelmi idő (nap)",
|
||||||
"hint": "Meddig marad meg egy felfedezett, de nem feliratkozott csatorna (és efemer videói), mielőtt a takarító feladat törli. A feliratkozás véglegesen megtartja."
|
"hint": "Meddig marad meg egy felfedezett, de nem feliratkozott csatorna (és efemer videói), mielőtt a takarító feladat törli. A feliratkozás véglegesen megtartja."
|
||||||
|
},
|
||||||
|
"search_grace_days": {
|
||||||
|
"label": "Keresési találatok — türelmi idő (nap)",
|
||||||
|
"hint": "Meddig marad meg egy meg nem tartott YouTube-keresési találat (amit senki nem nézett meg / mentett el / iratkozott fel rá), mielőtt a felfedezés-takarító feladat törli."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"save": "Mentés",
|
"save": "Mentés",
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,12 @@
|
||||||
"noResults": "Nincs YouTube-találat.",
|
"noResults": "Nincs YouTube-találat.",
|
||||||
"error": "A YouTube-keresés nem sikerült.",
|
"error": "A YouTube-keresés nem sikerült.",
|
||||||
"loadMore": "Továbbiak betöltése (kvótát fogyaszt)",
|
"loadMore": "Továbbiak betöltése (kvótát fogyaszt)",
|
||||||
"loadMoreFree": "Továbbiak betöltése"
|
"loadMoreFree": "Továbbiak betöltése",
|
||||||
|
"show": "Mutat",
|
||||||
|
"ephemeralNote": "Ezek a találatok ideiglenesek — maguktól eltűnnek, hacsak meg nem nézel vagy el nem mentesz egyet.",
|
||||||
|
"clearNow": "Törlés most",
|
||||||
|
"cleared_one": "{{count}} találat törölve",
|
||||||
|
"cleared_other": "{{count}} találat törölve",
|
||||||
|
"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.",
|
"shortsPendingHint": "Már dúsított videók, amelyek még a Shorts-próbára várnak.",
|
||||||
"liveRefresh": "Frissítendő élő",
|
"liveRefresh": "Frissítendő élő",
|
||||||
"liveRefreshHint": "Élő/közelgő videók (és a frissen véget ért, hossz nélküli adások), amíg le nem ülepednek."
|
"liveRefreshHint": "Élő/közelgő videók (és a frissen véget ért, hossz nélküli adások), amíg le nem ülepednek."
|
||||||
}
|
},
|
||||||
|
"purgeDiscovery": "Felfedezés ürítése",
|
||||||
|
"purgeDiscoveryHint": "Most eltávolítja az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.",
|
||||||
|
"purgedDiscovery": "{{count}} felfedezés-elem eltávolítva"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ export interface ChannelDetail {
|
||||||
country: string | null;
|
country: string | null;
|
||||||
subscribed: boolean;
|
subscribed: boolean;
|
||||||
explored: boolean;
|
explored: boolean;
|
||||||
|
blocked: boolean;
|
||||||
stored_videos: number;
|
stored_videos: number;
|
||||||
from_explore: boolean;
|
from_explore: boolean;
|
||||||
details_synced: boolean;
|
details_synced: boolean;
|
||||||
|
|
@ -601,11 +602,16 @@ export const api = {
|
||||||
// `quiet` so the YT-search panel can render quota/limit errors (incl. 429) inline instead of
|
// `quiet` so the YT-search panel can render quota/limit errors (incl. 429) inline instead of
|
||||||
// popping the global modal. `cursor` is the next-page token (an InnerTube continuation token
|
// popping the global modal. `cursor` is the next-page token (an InnerTube continuation token
|
||||||
// in scrape mode, or a Data API pageToken in api mode); the response's `source` says which.
|
// in scrape mode, or a Data API pageToken in api mode); the response's `source` says which.
|
||||||
searchYoutube: (q: string, cursor: string | null): Promise<FeedResponse> => {
|
searchYoutube: (q: string, cursor: string | null, limit?: number): Promise<FeedResponse> => {
|
||||||
const p = new URLSearchParams({ q });
|
const p = new URLSearchParams({ q });
|
||||||
if (cursor) p.set("cursor", cursor);
|
if (cursor) p.set("cursor", cursor);
|
||||||
|
if (limit) p.set("limit", String(limit));
|
||||||
return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true });
|
return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true });
|
||||||
},
|
},
|
||||||
|
// Discard a set of live-search results "as if never added" (drops your search-finds + deletes
|
||||||
|
// the now-orphaned, un-kept videos). Returns how many videos were removed.
|
||||||
|
clearSearch: (videoIds: string[]): Promise<{ removed: number }> =>
|
||||||
|
req("/api/search/clear", { method: "POST", body: JSON.stringify({ video_ids: videoIds }) }),
|
||||||
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
|
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
|
||||||
req(`/api/facets?${filterParams(f).toString()}`),
|
req(`/api/facets?${filterParams(f).toString()}`),
|
||||||
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
|
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
|
||||||
|
|
@ -674,6 +680,14 @@ export const api = {
|
||||||
{ quiet: true }
|
{ quiet: true }
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
// --- per-user channel blocklist (excluded from live search + feed/explore) ---
|
||||||
|
blockedChannels: (): Promise<{ id: string; title: string | null; handle: string | null; thumbnail_url: string | null }[]> =>
|
||||||
|
req("/api/channels/blocked/list"),
|
||||||
|
blockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "POST" }),
|
||||||
|
unblockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "DELETE" }),
|
||||||
|
// Admin: reclaim all un-kept discovery content (search results + explored channels) now.
|
||||||
|
purgeDiscovery: (): Promise<Record<string, number>> =>
|
||||||
|
req("/api/admin/purge-discovery", { method: "POST" }),
|
||||||
|
|
||||||
// --- playlists ---
|
// --- playlists ---
|
||||||
playlists: (containsVideoId?: string): Promise<Playlist[]> =>
|
playlists: (containsVideoId?: string): Promise<Playlist[]> =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue