Merge: promote dev to prod

This commit is contained in:
npeter83 2026-07-01 02:38:15 +02:00
commit dda94be72b
54 changed files with 2534 additions and 342 deletions

View file

@ -1 +1 @@
0.18.0 0.19.0

View file

@ -0,0 +1,47 @@
"""per-user search finds
Revision ID: 0030_search_finds
Revises: 0029_unaccent_search
Create Date: 2026-06-30
Adds the `search_finds` table: a per-user record of which videos a user surfaced via their own
live YouTube search. This lets "videos I searched for" appear in that user's Mine feed (and the
Mine Source filter), independent of the global `videos.via_search` flag (which marks a video as
search-discovered by anyone, for the shared Library's Source filter).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0030_search_finds"
down_revision: Union[str, None] = "0029_unaccent_search"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"search_finds",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("video_id", sa.String(), 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(["video_id"], ["videos.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "video_id", name="uq_user_search_video"),
)
op.create_index("ix_search_finds_user_id", "search_finds", ["user_id"])
op.create_index("ix_search_finds_video_id", "search_finds", ["video_id"])
def downgrade() -> None:
op.drop_index("ix_search_finds_video_id", table_name="search_finds")
op.drop_index("ix_search_finds_user_id", table_name="search_finds")
op.drop_table("search_finds")

View file

@ -0,0 +1,49 @@
"""full-text relevance search on video titles
Revision ID: 0031_title_fts
Revises: 0030_search_finds
Create Date: 2026-06-30
Adds a PostgreSQL full-text search index over video titles so the feed search box can rank by
relevance (a YouTube-like "fuzzy" search: word-order-independent, multi-word AND, prefix on the
word being typed) instead of a whole-phrase substring match.
A custom text-search configuration `unaccent_simple` = the `simple` config (no stemming, no
stopwords right for a multilingual HU/EN/DE catalog) plus the `unaccent` dictionary, so the
search stays accent-insensitive ("tiesto" matches "Tiësto"). A GIN expression index on
`to_tsvector('public.unaccent_simple', coalesce(title,''))` makes the @@ match fast; the feed
query uses the exact same expression so the planner picks the index. `unaccent` is already
enabled (migration 0029). The config-based 2-arg to_tsvector is IMMUTABLE, so it's index-safe.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0031_title_fts"
down_revision: Union[str, None] = "0030_search_finds"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"CREATE TEXT SEARCH CONFIGURATION public.unaccent_simple (COPY = pg_catalog.simple)"
)
op.execute(
"""
ALTER TEXT SEARCH CONFIGURATION public.unaccent_simple
ALTER MAPPING FOR asciiword, asciihword, hword_asciipart, word, hword, hword_part
WITH unaccent, simple
"""
)
op.execute(
"""
CREATE INDEX ix_videos_title_fts ON videos
USING gin (to_tsvector('public.unaccent_simple', coalesce(title, '')))
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_videos_title_fts")
op.execute("DROP TEXT SEARCH CONFIGURATION IF EXISTS public.unaccent_simple")

View file

@ -0,0 +1,65 @@
"""richer full-text search document (title + keywords + search terms + description)
Revision ID: 0032_search_document
Revises: 0031_title_fts
Create Date: 2026-06-30
Broadens the feed's full-text search from the title alone to a weighted search document, so the
local search behaves more like YouTube's — finding videos by the uploader's own keywords or by
the search query that surfaced them, not only by words in the title:
- `keywords` the creator's snippet.tags (already fetched with the snippet we request, so
zero extra quota), stored space-joined.
- `search_terms` distinct live-search queries that surfaced the video (across all users),
appended by the search route. Folds YouTube's relevance judgement into local
search: a video YT returned for a query becomes findable by it locally.
These plus a truncated description feed a STORED generated `search_vector` column, weighted
title (A) > keywords+search_terms (B) > description (C) so ts_rank ranks title hits highest. A
plain GIN index on the column guarantees the planner uses it (no expression/param matching). The
title-only expression index from 0031 is dropped (superseded). Existing rows get title +
description indexed immediately; keywords/search_terms fill in as videos are (re-)enriched and
searched. Uses the `unaccent_simple` config from 0031 (accent-insensitive).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0032_search_document"
down_revision: Union[str, None] = "0031_title_fts"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_SEARCH_VECTOR_EXPR = (
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
"setweight(to_tsvector('public.unaccent_simple', coalesce(keywords, '') || ' ' || "
"coalesce(search_terms, '')), 'B') || "
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(description, ''), 1000)), 'C')"
)
def upgrade() -> None:
op.add_column("videos", sa.Column("keywords", sa.Text(), nullable=True))
op.add_column("videos", sa.Column("search_terms", sa.Text(), nullable=True))
# Replace the title-only index with a generated weighted search_vector + its GIN index.
op.execute("DROP INDEX IF EXISTS ix_videos_title_fts")
op.execute(
f"ALTER TABLE videos ADD COLUMN search_vector tsvector "
f"GENERATED ALWAYS AS ({_SEARCH_VECTOR_EXPR}) STORED"
)
op.execute(
"CREATE INDEX ix_videos_search_vector ON videos USING gin (search_vector)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_videos_search_vector")
op.drop_column("videos", "search_vector")
op.drop_column("videos", "search_terms")
op.drop_column("videos", "keywords")
# Recreate the title-only FTS index from 0031.
op.execute(
"CREATE INDEX ix_videos_title_fts ON videos "
"USING gin (to_tsvector('public.unaccent_simple', coalesce(title, '')))"
)

View file

@ -0,0 +1,83 @@
"""channel explore: about-page fields, explore provenance, explored_channels
Revision ID: 0033_channel_explore
Revises: 0032_search_document
Create Date: 2026-06-30
Supports the channel-explore feature (a dedicated channel page + ephemeral browsing of
un-subscribed channels):
- channels.total_view_count / published_at / banner_url / external_links richer "About"
metadata fetched from channels.list (part=statistics, snippet, brandingSettings).
- channels.from_explore this channel exists only because someone explored it (not an
organic subscription / not a search find); drives the ephemeral cleanup job.
- videos.via_explore this video entered the catalog via a channel exploration. Hidden
from everyone's Library by default; visible only to users who explored (or subscribe to)
the channel (a per-user gate in the feed query).
- explored_channels per-user record of "I explored this channel": gates channel-page
access to its ephemeral videos and drives the grace-period purge of un-kept channels.
All new flags default false / NULL, so every existing row stays organic.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0033_channel_explore"
down_revision: Union[str, None] = "0032_search_document"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("channels", sa.Column("total_view_count", sa.BigInteger(), nullable=True))
op.add_column(
"channels", sa.Column("published_at", sa.DateTime(timezone=True), nullable=True)
)
op.add_column("channels", sa.Column("banner_url", sa.String(length=1024), nullable=True))
op.add_column("channels", sa.Column("external_links", sa.JSON(), nullable=True))
op.add_column(
"channels",
sa.Column("from_explore", sa.Boolean(), nullable=False, server_default="false"),
)
op.create_index("ix_channels_from_explore", "channels", ["from_explore"])
op.add_column(
"videos",
sa.Column("via_explore", sa.Boolean(), nullable=False, server_default="false"),
)
op.create_index("ix_videos_via_explore", "videos", ["via_explore"])
op.create_table(
"explored_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_explored_channel"),
)
op.create_index("ix_explored_channels_user_id", "explored_channels", ["user_id"])
op.create_index("ix_explored_channels_channel_id", "explored_channels", ["channel_id"])
def downgrade() -> None:
op.drop_index("ix_explored_channels_channel_id", table_name="explored_channels")
op.drop_index("ix_explored_channels_user_id", table_name="explored_channels")
op.drop_table("explored_channels")
op.drop_index("ix_videos_via_explore", table_name="videos")
op.drop_column("videos", "via_explore")
op.drop_index("ix_channels_from_explore", table_name="channels")
op.drop_column("channels", "from_explore")
op.drop_column("channels", "external_links")
op.drop_column("channels", "banner_url")
op.drop_column("channels", "published_at")
op.drop_column("channels", "total_view_count")

View file

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

View file

@ -118,6 +118,15 @@ class Settings(BaseSettings):
# items). Deleted/private/paywalled get a recovery window; abandoned upcoming and # items). Deleted/private/paywalled get a recovery window; abandoned upcoming and
# confirmed-dead stuck-live have no age grace (deleted on the sweep that confirms them). # confirmed-dead stuck-live have no age grace (deleted on the sweep that confirms them).
maintenance_grace_days: int = 7 maintenance_grace_days: int = 7
# --- Channel-explore cleanup job ---
# Runs daily by default (admin-tunable via the Scheduler dashboard). An explored-but-never-
# kept channel (no subscription, no per-user state on its videos) is hard-deleted this many
# 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 # 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"

View file

@ -3,6 +3,7 @@ from datetime import date, datetime
from sqlalchemy import ( from sqlalchemy import (
BigInteger, BigInteger,
Boolean, Boolean,
Computed,
Date, Date,
DateTime, DateTime,
Float, Float,
@ -14,6 +15,7 @@ from sqlalchemy import (
UniqueConstraint, UniqueConstraint,
func, func,
) )
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base from app.db import Base
@ -163,6 +165,12 @@ class Channel(Base, TimestampMixin):
uploads_playlist_id: Mapped[str | None] = mapped_column(String(64)) uploads_playlist_id: Mapped[str | None] = mapped_column(String(64))
subscriber_count: Mapped[int | None] = mapped_column(BigInteger) subscriber_count: Mapped[int | None] = mapped_column(BigInteger)
video_count: Mapped[int | None] = mapped_column(BigInteger) video_count: Mapped[int | None] = mapped_column(BigInteger)
# Channel "About" metadata (fetched with the same channels.list call, no extra quota):
# lifetime view count, the channel's join date, banner image, and external links.
total_view_count: Mapped[int | None] = mapped_column(BigInteger)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
banner_url: Mapped[str | None] = mapped_column(String(1024))
external_links: Mapped[list | None] = mapped_column(JSON)
topic_categories: Mapped[list | None] = mapped_column(JSON) topic_categories: Mapped[list | None] = mapped_column(JSON)
default_language: Mapped[str | None] = mapped_column(String(16)) default_language: Mapped[str | None] = mapped_column(String(16))
country: Mapped[str | None] = mapped_column(String(8)) country: Mapped[str | None] = mapped_column(String(8))
@ -181,6 +189,14 @@ class Channel(Base, TimestampMixin):
from_search: Mapped[bool] = mapped_column( from_search: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True Boolean, default=False, server_default="false", index=True
) )
# True when this channel exists only because a user EXPLORED it (opened its page without
# subscribing) — an ephemeral, per-explorer addition. The cleanup job hard-deletes such a
# channel (and its via_explore videos) once nobody keeps it (no subscription, no live
# ExploredChannel row, no per-user state) after a grace period. Cleared when someone
# subscribes (the "keep" signal promotes it to organic).
from_explore: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True
)
videos: Mapped[list["Video"]] = relationship(back_populates="channel") videos: Mapped[list["Video"]] = relationship(back_populates="channel")
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel") subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel")
@ -237,6 +253,14 @@ class Video(Base, TimestampMixin):
topic_categories: Mapped[list | None] = mapped_column(JSON) topic_categories: Mapped[list | None] = mapped_column(JSON)
default_language: Mapped[str | None] = mapped_column(String(16)) default_language: Mapped[str | None] = mapped_column(String(16))
detected_language: Mapped[str | None] = mapped_column(String(16)) detected_language: Mapped[str | None] = mapped_column(String(16))
# Creator-supplied keyword tags (snippet.tags joined by spaces) — fetched for free with the
# snippet we already request. Indexed into search_vector so the feed search matches the
# uploader's own keywords, not just words in the title.
keywords: Mapped[str | None] = mapped_column(Text)
# Accumulated distinct live-search queries that surfaced this video (across all users) —
# folds YouTube's own relevance judgement into the local search: a video YouTube returned
# for "ti amo magyarul" becomes findable by that query locally even if its title lacks it.
search_terms: Mapped[str | None] = mapped_column(Text)
is_short: Mapped[bool] = mapped_column( is_short: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True Boolean, default=False, server_default="false", index=True
) )
@ -250,6 +274,14 @@ class Video(Base, TimestampMixin):
via_search: Mapped[bool] = mapped_column( via_search: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True Boolean, default=False, server_default="false", index=True
) )
# True when this video entered the catalog because a user EXPLORED its (un-subscribed)
# channel. Like via_search it's hidden from the shared Library by default, but it's
# additionally PER-USER private: a via_explore video is only visible to users who
# explored or subscribe to its channel (a gate in the feed query), so one user's
# curiosity never floods everyone else's catalog. Left False by the organic ingest paths.
via_explore: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True
)
# none | live | upcoming | premiere | was_live # none | live | upcoming | premiere | was_live
live_status: Mapped[str] = mapped_column( live_status: Mapped[str] = mapped_column(
String(16), default="none", server_default="none", index=True String(16), default="none", server_default="none", index=True
@ -272,6 +304,21 @@ class Video(Base, TimestampMixin):
) )
unavailable_reason: Mapped[str | None] = mapped_column(String(24)) unavailable_reason: Mapped[str | None] = mapped_column(String(24))
# Weighted full-text search document (DB-generated, never written by the app): title (A) >
# creator keywords + search queries (B) > truncated description (C). ts_rank honours the
# weights so title matches outrank description matches. Backed by a GIN index (migration
# 0032). The accent-insensitive `unaccent_simple` config comes from migration 0031.
search_vector: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
"setweight(to_tsvector('public.unaccent_simple', coalesce(keywords, '') || ' ' || "
"coalesce(search_terms, '')), 'B') || "
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(description, ''), 1000)), 'C')",
persisted=True,
),
)
channel: Mapped["Channel"] = relationship(back_populates="videos") channel: Mapped["Channel"] = relationship(back_populates="videos")
@ -344,6 +391,69 @@ class VideoState(Base, UpdatedAtMixin):
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class SearchFind(Base, TimestampMixin):
"""Per-user record of a video the user surfaced via their own live YouTube search.
Lets "videos I searched for" appear in that user's own (Mine) feed and the Mine Source
filter distinct from the GLOBAL `Video.via_search` provenance, which marks a video as
search-discovered by *anyone* and drives the shared Library's Source filter."""
__tablename__ = "search_finds"
__table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_search_video"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
video_id: Mapped[str] = mapped_column(
ForeignKey("videos.id", ondelete="CASCADE"), index=True
)
class ExploredChannel(Base, TimestampMixin):
"""Per-user record of a channel the user opened the page of WITHOUT subscribing.
Exploring an un-subscribed channel ingests its recent uploads (flagged via_explore) so the
user can browse them, but keeps them out of everyone else's catalog. This row (a) gates the
explorer's access to those ephemeral videos in the feed query and (b) drives the grace-period
purge: a from_explore channel with no live ExploredChannel row (and no subscription / no
per-user state on its videos) is hard-deleted by the cleanup job. Subscribing is the "keep"
signal it promotes the channel to organic and these rows become irrelevant. `created_at`
(from TimestampMixin) is the grace clock; a re-visit refreshes it."""
__tablename__ = "explored_channels"
__table_args__ = (
UniqueConstraint("user_id", "channel_id", name="uq_user_explored_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 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."""

View file

@ -38,6 +38,7 @@ class QuotaAction:
VIDEOS_LOOKUP = "videos_lookup" VIDEOS_LOOKUP = "videos_lookup"
VIDEOS_SEARCH = "videos_search" VIDEOS_SEARCH = "videos_search"
CHANNELS_DISCOVER = "channels_discover" CHANNELS_DISCOVER = "channels_discover"
CHANNELS_EXPLORE = "channels_explore"
CHANNELS_SUBSCRIBE = "channels_subscribe" CHANNELS_SUBSCRIBE = "channels_subscribe"
CHANNELS_UNSUBSCRIBE = "channels_unsubscribe" CHANNELS_UNSUBSCRIBE = "channels_unsubscribe"
MAINTENANCE_REVALIDATE = "maintenance_revalidate" MAINTENANCE_REVALIDATE = "maintenance_revalidate"

View file

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

View file

@ -5,15 +5,17 @@ import logging
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 from sqlalchemy import and_, func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota from app import quota, sysconfig
from app.auth import current_user, has_write_scope 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,
Playlist, Playlist,
PlaylistItem, PlaylistItem,
Subscription, Subscription,
@ -22,6 +24,7 @@ from app.models import (
Video, Video,
) )
from app.routes.admin import admin_user from app.routes.admin import admin_user
from app.sync.explore import explore_ingest_page
from app.sync.runner import run_recent_backfill from app.sync.runner import run_recent_backfill
from app.sync.subscriptions import apply_channel_details from app.sync.subscriptions import apply_channel_details
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError
@ -205,6 +208,145 @@ def discover_channels(
] ]
def _channel_detail_dict(
channel: Channel, *, subscribed: bool, explored: bool, blocked: bool, stored: int
) -> dict:
return {
"id": channel.id,
"title": channel.title,
"handle": channel.handle,
"description": channel.description,
"thumbnail_url": channel.thumbnail_url,
"banner_url": channel.banner_url,
"subscriber_count": channel.subscriber_count,
"video_count": channel.video_count,
"total_view_count": channel.total_view_count,
"published_at": channel.published_at.isoformat() if channel.published_at else None,
"external_links": channel.external_links or [],
"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,
}
@router.get("/{channel_id}")
def channel_detail(
channel_id: str,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""One channel's "About" detail for the channel page header/About tab, plus this user's
relationship to it (subscribed / explored) and how many of its videos are in the catalog.
Lazily fills the About metadata (view count, join date, banner, links) the first time it's
needed a stub, or a channel last enriched before those fields existed (published_at is a
reliable sentinel: every real channel has a creation date). The enrich uses the API key
(1 unit, cached after), so it's skipped for the quota-less demo account."""
channel = db.get(Channel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Unknown channel")
if channel.published_at is None and not user.is_demo:
try:
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), YouTubeClient(db, user) as yt:
apply_channel_details(db, yt.get_channels([channel_id]))
db.commit()
channel = db.get(Channel, channel_id)
except YouTubeError as exc:
log.warning("channel detail enrich failed (%s): %s", channel_id, exc)
db.rollback()
subscribed = db.execute(
select(Subscription.id).where(
Subscription.user_id == user.id, Subscription.channel_id == channel_id
)
).first() is not None
explored = db.execute(
select(ExploredChannel.id).where(
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, blocked=blocked, stored=int(stored)
)
@router.post("/{channel_id}/explore")
def explore_channel(
channel_id: str,
page_token: str | None = None,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Open an un-subscribed channel for browsing. Records the exploration (a per-user,
grace-clocked row that gates this user's access to the channel's ephemeral videos and
drives the later cleanup), flags the channel ephemeral unless someone organically follows
it, then ingests one page of its uploads (newest-first) marked via_explore + enriched.
Returns the next page token so the page can "load more". Guarded against the shared
backfill quota reserve; blocked for the demo account (it spends shared quota)."""
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,
detail="The shared daily quota reserve is reached — try exploring later.",
)
# Record/refresh the exploration (DB only) before ingesting, so the explorer can see the
# videos as soon as they land. Flag the channel ephemeral unless it's organically followed.
existing = db.execute(
select(ExploredChannel).where(
ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id
)
).scalar_one_or_none()
if existing is None:
db.add(ExploredChannel(user_id=user.id, channel_id=channel_id))
else:
existing.created_at = datetime.now(timezone.utc) # refresh the grace clock
followed = db.execute(
select(Subscription.id).where(Subscription.channel_id == channel_id).limit(1)
).first()
if followed is None and not channel.from_explore:
channel.from_explore = True
db.commit()
try:
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_EXPLORE), YouTubeClient(db, user) as yt:
# Enrich for the About panel + the uploads playlist id (needed to ingest videos).
if (
channel.details_synced_at is None
or channel.uploads_playlist_id is None
or channel.published_at is None
):
apply_channel_details(db, yt.get_channels([channel_id]))
db.commit()
channel = db.get(Channel, channel_id)
result = explore_ingest_page(db, yt, channel, page_token)
except YouTubeError as exc:
raise HTTPException(
status_code=422, detail=f"YouTube couldn't load this channel: {exc}"
)
return {
"channel_id": channel_id,
"ingested": result["ingested"],
"next_page_token": result["next_page_token"],
}
def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription: def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription:
sub = db.execute( sub = db.execute(
select(Subscription).where( select(Subscription).where(
@ -332,6 +474,14 @@ def subscribe(
subscribed_at=datetime.now(timezone.utc), subscribed_at=datetime.now(timezone.utc),
) )
) )
# Subscribing is the "keep" signal: a previously-explored channel becomes a normal organic
# one. Drop its ephemeral flags so the cleanup job leaves it alone and its videos stop being
# per-user private (they join the shared Library like any subscribed channel's uploads).
if channel.from_explore:
channel.from_explore = False
db.execute(
update(Video).where(Video.channel_id == channel_id, Video.via_explore.is_(True)).values(via_explore=False)
)
db.commit() db.commit()
return {"subscribed": channel_id} return {"subscribed": channel_id}
@ -367,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,

View file

@ -1,10 +1,11 @@
import base64 import base64
import binascii import binascii
import json import json
import re
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import Select, and_, false, func, or_, select from sqlalchemy import BigInteger, Select, and_, cast, false, func, or_, select
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 sqlalchemy.orm.exc import StaleDataError from sqlalchemy.orm.exc import StaleDataError
@ -13,10 +14,13 @@ 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,
Playlist, Playlist,
PlaylistItem, PlaylistItem,
SearchFind,
Subscription, Subscription,
Tag, Tag,
User, User,
@ -153,14 +157,20 @@ def _filtered_query(
), ),
) )
else: else:
# Only channels this user is subscribed to (and hasn't hidden). # "Mine": the user's own videos = their non-hidden subscriptions OR videos they
query = query.join( # surfaced via their own live YouTube search. Both are LEFT-joined so the Source
# filter below can include either or both, and the subscription row stays available
# for the priority sort.
query = query.outerjoin(
Subscription, Subscription,
and_( and_(
Subscription.channel_id == Video.channel_id, Subscription.channel_id == Video.channel_id,
Subscription.user_id == user.id, Subscription.user_id == user.id,
Subscription.hidden.is_(False), Subscription.hidden.is_(False),
), ),
).outerjoin(
SearchFind,
and_(SearchFind.video_id == Video.id, SearchFind.user_id == user.id),
) )
query = query.outerjoin( query = query.outerjoin(
@ -172,16 +182,50 @@ def _filtered_query(
# either way they shouldn't show in any feed view meanwhile. # either way they shouldn't show in any feed view meanwhile.
query = query.where(Video.unavailable_since.is_(None)) query = query.where(Video.unavailable_since.is_(None))
# Provenance filter for the Library: search-discovered videos are "noise" hidden by # Explored videos are PER-EXPLORER private: a via_explore video (ingested when someone
# default ("organic"), can be mixed in ("all"), or shown exclusively ("search"). Only # opened an un-subscribed channel's page) is visible only to users who explored or
# meaningful in "all" scope: "my" already restricts to subscribed channels, where a # subscribe to its channel, so one user's curiosity never leaks into everyone else's
# once-searched video is legitimately the user's. # catalog. Applied in BOTH scopes; the channel page reaches its ephemeral videos by
# asking for scope=all + channel_id (the channel is in this accessible set).
accessible_explore = (
select(Subscription.channel_id).where(Subscription.user_id == user.id)
).union(
select(ExploredChannel.channel_id).where(ExploredChannel.user_id == user.id)
)
query = query.where(
or_(
Video.via_explore.is_(False),
Video.channel_id.in_(accessible_explore),
)
)
# 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,
# "all" = both. Default "organic" keeps the Mine feed = subscriptions only.
if scope == "all": if scope == "all":
if library_source == "organic": if library_source == "organic":
query = query.where(Video.via_search.is_(False)) query = query.where(Video.via_search.is_(False))
elif library_source == "search": elif library_source == "search":
query = query.where(Video.via_search.is_(True)) query = query.where(Video.via_search.is_(True))
# "all" → both organic and search-discovered videos # "all" → both organic and search-discovered videos
else:
subscribed = Subscription.channel_id.isnot(None)
searched = SearchFind.video_id.isnot(None)
if library_source == "search":
query = query.where(searched)
elif library_source == "all":
query = query.where(or_(subscribed, searched))
else: # "organic" (default): subscriptions only
query = query.where(subscribed)
if channel_id: if channel_id:
query = query.where(Video.channel_id == channel_id) query = query.where(Video.channel_id == channel_id)
@ -204,9 +248,29 @@ def _filtered_query(
published_before, datetime.min.time(), tzinfo=timezone.utc published_before, datetime.min.time(), tzinfo=timezone.utc
) + timedelta(days=1) ) + timedelta(days=1)
query = query.where(Video.published_at < end) query = query.where(Video.published_at < end)
# Full-text relevance search over the weighted search document (title > creator keywords +
# the queries that surfaced the video > description; see Video.search_vector). YouTube-like
# "fuzzy" matching: word-order-independent, multi-word AND, prefix on the word being typed,
# accent-insensitive (unaccent_simple). The channel name still matches as a substring so
# channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort.
rank_expr = None
if q: if q:
# Accent-insensitive: unaccent() both sides so "tiesto" matches "Tiësto" (ILIKE alone ts_str = _to_tsquery_str(q)
# is case- but not diacritic-insensitive). unaccent is enabled by migration 0029. if ts_str:
tsq = func.to_tsquery("public.unaccent_simple", ts_str)
query = query.where(
or_(
Video.search_vector.op("@@")(tsq),
func.unaccent(Channel.title).ilike(func.unaccent(f"%{q}%")),
)
)
# Scale ts_rank (a float4) to an integer score so the keyset cursor compares it
# EXACTLY — a raw float4 vs the float8 cursor value mismatches on round-trip and
# breaks paging (the same page repeats). 1e6 granularity is ample; ties break on
# published_at then id.
rank_expr = cast(func.ts_rank(Video.search_vector, tsq) * 1000000, BigInteger)
else:
# No usable tokens (e.g. only punctuation): fall back to a plain substring match.
like = func.unaccent(f"%{q}%") like = func.unaccent(f"%{q}%")
query = query.where( query = query.where(
or_( or_(
@ -271,7 +335,19 @@ def _filtered_query(
else: # all else: # all
query = query.where(status_expr != "hidden") query = query.where(status_expr != "hidden")
return query, status_expr return query, status_expr, rank_expr
def _to_tsquery_str(q: str) -> str | None:
"""Turn free-text into a to_tsquery() string: word runs ANDed together, the LAST word
prefix-matched (`:*`) for as-you-type search e.g. "ti amo magyar" "ti & amo & magyar:*".
Only `\\w` runs containing an alphanumeric are kept, so no tsquery operators can leak in
(the string is built from sanitised tokens, never the raw query)."""
tokens = [t for t in re.findall(r"\w+", q, flags=re.UNICODE) if any(c.isalnum() for c in t)]
if not tokens:
return None
tokens[-1] = tokens[-1] + ":*"
return " & ".join(tokens)
# Shared query parameters for /feed and /feed/count. # Shared query parameters for /feed and /feed/count.
@ -320,7 +396,7 @@ def _feed_params(
# replaces OFFSET/LIMIT: deep scrolling no longer scans-and-discards skipped rows, # replaces OFFSET/LIMIT: deep scrolling no longer scans-and-discards skipped rows,
# and the page boundary is stable even as the scheduler ingests new videos mid-scroll # and the page boundary is stable even as the scheduler ingests new videos mid-scroll
# (OFFSET would shift and duplicate/skip rows). # (OFFSET would shift and duplicate/skip rows).
def _sort_keys(sort: str, seed: int) -> list[dict]: def _sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]:
pub = {"expr": Video.published_at, "asc": False, "nullable": True, "kind": "dt"} pub = {"expr": Video.published_at, "asc": False, "nullable": True, "kind": "dt"}
table: dict[str, list[dict]] = { table: dict[str, list[dict]] = {
"newest": [pub], "newest": [pub],
@ -341,6 +417,13 @@ def _sort_keys(sort: str, seed: int) -> list[dict]:
# the cursor stays valid across pages of one shuffled run. # the cursor stays valid across pages of one shuffled run.
"shuffle": [{"expr": func.md5(func.concat(Video.id, str(seed))), "asc": True, "nullable": False, "kind": "str"}], "shuffle": [{"expr": func.md5(func.concat(Video.id, str(seed))), "asc": True, "nullable": False, "kind": "str"}],
} }
# Relevance (search-result ranking) only exists when there's a query to rank against;
# without `rank_expr` it falls through to newest. Ties break by newest, then id.
if rank_expr is not None:
table["relevance"] = [
{"expr": rank_expr, "asc": False, "nullable": False, "kind": "num"},
pub,
]
return table.get(sort) or table["newest"] return table.get(sort) or table["newest"]
@ -409,8 +492,8 @@ def get_feed(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
query, _status = _filtered_query(db, user, **params) query, _status, rank_expr = _filtered_query(db, user, **params)
keys = _sort_keys(sort, seed) keys = _sort_keys(sort, seed, rank_expr)
# Expose each key column on the row so we can build the next cursor from the last item. # Expose each key column on the row so we can build the next cursor from the last item.
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)]) query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
@ -439,7 +522,7 @@ def get_feed_count(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
query, _status = _filtered_query(db, user, **params) query, _status, _rank = _filtered_query(db, user, **params)
total = db.scalar(select(func.count()).select_from(query.subquery())) total = db.scalar(select(func.count()).select_from(query.subquery()))
return {"count": total or 0} return {"count": total or 0}
@ -461,13 +544,13 @@ def get_facets(
(tag ids).""" (tag ids)."""
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
counts: dict[int, int] = {} counts: dict[int, int] = {}
for category in ("language", "topic"): for category in ("language", "topic", "other"):
# Disjunctive (OR) facets drop the category's own selections so its chips keep # Disjunctive (OR) facets drop the category's own selections so its chips keep
# independent counts (you can OR more of them in). Conjunctive (AND, topics only) # independent counts (you can OR more of them in). Conjunctive (AND, topics only)
# keeps them applied, so each remaining chip narrows to channels that ALSO have all # keeps them applied, so each remaining chip narrows to channels that ALSO have all
# already-selected topics — and tags that can't co-occur drop to zero (hidden). # already-selected topics — and tags that can't co-occur drop to zero (hidden).
conjunctive = category == "topic" and params.get("tag_mode") == "and" conjunctive = category == "topic" and params.get("tag_mode") == "and"
base, _status = _filtered_query( base, _status, _rank = _filtered_query(
db, db,
user, user,
**{**params, "exclude_tag_category": None if conjunctive else category}, **{**params, "exclude_tag_category": None if conjunctive else category},

View file

@ -16,13 +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_, select 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 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, 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
@ -90,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] return [by_id[i] for i in ids if i in by_id]
@router.get("/youtube") def _ingest_candidates(
def search_youtube( db: Session, yt: YouTubeClient, user: User, candidates: list[dict]
q: str | None = None, ) -> list[str]:
cursor: str | None = None, """Materialise one page of search candidates into the catalog (channel stubs + enrich,
user: User = Depends(require_human), video stubs flagged via_search, enrich, Shorts classification) and return the KEEPABLE
db: Session = Depends(get_db), video ids non-Short, non-live in the input relevance order."""
) -> 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"]
]
ids = [it["id"] for it in candidates] ids = [it["id"] for it in candidates]
if not ids: if not ids:
return { 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), # 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. # then enrich the new ones (channels.list, 1 unit). Stubs survive an enrich failure.
channel_ids = list({it["channel_id"] for it in candidates}) channel_ids = list({it["channel_id"] for it in candidates})
existing = set( existing = set(db.scalars(select(Channel.id).where(Channel.id.in_(channel_ids))).all())
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] new_channel_ids = [c for c in channel_ids if c not in existing]
if new_channel_ids: if new_channel_ids:
titles = {it["channel_id"]: it["channel_title"] for it in candidates} titles = {it["channel_id"]: it["channel_title"] for it in candidates}
@ -208,14 +165,182 @@ def search_youtube(
# 4) Confirm/deny Shorts (no quota) so none enter via search. # 4) Confirm/deny Shorts (no quota) so none enter via search.
_classify_shorts(db, videos) _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}
keep = { return [vid for vid in ids if vid in 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] 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]
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"])
)
db.execute(
update(Video)
.where(Video.id.in_(ordered))
.where(func.coalesce(Video.search_terms, "").notilike(f"%{term}%"))
.values(
search_terms=func.trim(
func.coalesce(Video.search_terms, "").op("||")(" ").op("||")(term)
)
)
)
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}

View file

@ -16,6 +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_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 (
@ -57,6 +58,7 @@ JOB_INTERVALS: dict[str, int] = {
"playlist_sync": settings.playlist_sync_minutes, "playlist_sync": settings.playlist_sync_minutes,
"maintenance": settings.maintenance_interval_minutes, "maintenance": settings.maintenance_interval_minutes,
"demo_reset": settings.demo_reset_minutes, "demo_reset": settings.demo_reset_minutes,
"explore_cleanup": settings.explore_cleanup_minutes,
} }
# Sane bounds for an admin-set interval (minutes). # Sane bounds for an admin-set interval (minutes).
@ -268,6 +270,12 @@ def _demo_reset_job() -> None:
_job("demo_reset", work) _job("demo_reset", work)
def _explore_cleanup_job() -> None:
# 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, # job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now"). # shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
JOB_FUNCS: dict[str, Callable[[], None]] = { JOB_FUNCS: dict[str, Callable[[], None]] = {
@ -280,6 +288,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
"playlist_sync": _playlist_sync_job, "playlist_sync": _playlist_sync_job,
"maintenance": _maintenance_job, "maintenance": _maintenance_job,
"demo_reset": _demo_reset_job, "demo_reset": _demo_reset_job,
"explore_cleanup": _explore_cleanup_job,
} }

180
backend/app/sync/explore.py Normal file
View file

@ -0,0 +1,180 @@
"""Channel exploration: ingest an un-subscribed channel's uploads on demand (flagged
via_explore, kept per-explorer) and purge channels nobody ends up keeping.
A user opens a channel's page without subscribing → we pull a page of its recent uploads,
mark the new rows via_explore, and enrich them so they're immediately browsable. The videos
stay private to users who explored (or subscribe to) the channel the feed query gates them
so one user's curiosity never floods everyone's catalog. Subscribing is the "keep" signal;
otherwise the cleanup job hard-deletes the channel and its untouched ephemeral videos after a
grace period (mirrors the maintenance job's grace-then-delete safety)."""
import logging
from datetime import timedelta
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app import sysconfig
from app.models import (
Channel,
ExploredChannel,
PlaylistItem,
SearchFind,
Subscription,
Video,
VideoState,
)
from app.sync.videos import _insert_stubs, _stub_from_playlist_item, apply_video_details
from app.utils import now_utc as _now
from app.youtube.client import YouTubeClient
log = logging.getLogger("siftlode.explore")
def _enrich_new(db: Session, yt: YouTubeClient, video_ids: list[str]) -> int:
"""Enrich just the not-yet-enriched videos among `video_ids` (videos.list, 1 unit/50)."""
if not video_ids:
return 0
videos = (
db.execute(
select(Video).where(
Video.id.in_(video_ids), Video.enriched_at.is_(None)
)
)
.scalars()
.all()
)
if not videos:
return 0
items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])}
now = _now()
for video in videos:
item = items.get(video.id)
if item is None:
video.enriched_at = now # unavailable; stop retrying
continue
apply_video_details(video, item)
video.enriched_at = now
db.commit()
return len(videos)
def explore_ingest_page(
db: Session, yt: YouTubeClient, channel: Channel, page_token: str | None = None
) -> dict:
"""Ingest one page (up to 50, newest-first) of the channel's uploads, marking the new
videos via_explore, then enrich them. Returns the count ingested and the next page token
(None when the back-catalogue is exhausted) so the caller can offer "load more". Stateless
pagination: the caller round-trips the token, so the channel's own backfill cursor (used by
the organic deep backfill) is never touched."""
if not channel.uploads_playlist_id:
return {"ingested": 0, "next_page_token": None}
data = yt.get_playlist_items(channel.uploads_playlist_id, page_token)
stubs: list[dict] = []
for item in data.get("items", []):
stub = _stub_from_playlist_item(item, channel.id)
if stub is not None:
stub["via_explore"] = True
stubs.append(stub)
inserted = _insert_stubs(db, stubs)
self_enriched = _enrich_new(db, yt, [s["id"] for s in stubs])
log.info(
"explore ingest channel=%s ingested=%s enriched=%s", channel.id, inserted, self_enriched
)
return {"ingested": inserted, "next_page_token": data.get("nextPageToken")}
def purge_unkept_explored(db: Session) -> dict:
"""Cleanup job: hard-delete explored channels nobody kept and their untouched ephemeral
videos, after a grace period (mirrors the maintenance grace-then-delete safety).
Keep signals that spare a channel/video:
- any user SUBSCRIBES to the channel (subscribe promotes it to organic channel-level), or
- a live ExploredChannel row (someone explored it within the grace window channel-level), or
- a video has per-user state: watched/in-progress (VideoState), in a playlist (PlaylistItem),
or surfaced by the user's own search (SearchFind) — these survive even if the channel goes.
A channel row is dropped only once it holds no videos at all. 0 quota."""
grace_days = sysconfig.get_int(db, "explore_grace_days")
cutoff = _now() - timedelta(days=grace_days)
expired = db.execute(
delete(ExploredChannel).where(ExploredChannel.created_at < cutoff)
).rowcount
db.commit()
sub_exists = (
select(Subscription.id).where(Subscription.channel_id == Channel.id).exists()
)
exp_exists = (
select(ExploredChannel.id).where(ExploredChannel.channel_id == Channel.id).exists()
)
orphan_ids = (
db.execute(
select(Channel.id).where(
Channel.from_explore.is_(True), ~sub_exists, ~exp_exists
)
)
.scalars()
.all()
)
if not orphan_ids:
return {"expired": expired, "videos_deleted": 0, "channels_deleted": 0}
state_exists = select(VideoState.id).where(VideoState.video_id == Video.id).exists()
pl_exists = select(PlaylistItem.id).where(PlaylistItem.video_id == Video.id).exists()
find_exists = select(SearchFind.id).where(SearchFind.video_id == Video.id).exists()
videos_deleted = db.execute(
delete(Video).where(
Video.channel_id.in_(orphan_ids),
Video.via_explore.is_(True),
~state_exists,
~pl_exists,
~find_exists,
)
).rowcount
db.commit()
has_videos = select(Video.id).where(Video.channel_id == Channel.id).exists()
channels_deleted = db.execute(
delete(Channel).where(Channel.id.in_(orphan_ids), ~has_videos)
).rowcount
db.commit()
return {
"expired": expired,
"videos_deleted": videos_deleted,
"channels_deleted": channels_deleted,
}
def purge_unkept_search(db: Session, grace_days: int | None = None) -> dict:
"""Remove live-search result videos nobody kept, after a grace period — "as if never added".
A `via_search` video is KEPT (and spared) when anyone has watched / in-progress / hidden it
(a VideoState), saved/playlisted it (a PlaylistItem), or subscribes to its channel (it's then
organically owned). A bare SearchFind does NOT count it's the ephemeral "shown in search"
marker itself so a result you only glanced at is reclaimed. Deleting the video cascades its
SearchFinds. `grace_days=0` purges immediately (the admin "purge now" path); None uses config.
0 quota."""
grace = sysconfig.get_int(db, "search_grace_days") if grace_days is None else grace_days
cutoff = _now() - timedelta(days=grace)
state_exists = select(VideoState.id).where(VideoState.video_id == Video.id).exists()
pl_exists = select(PlaylistItem.id).where(PlaylistItem.video_id == Video.id).exists()
sub_exists = (
select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists()
)
deleted = db.execute(
delete(Video).where(
Video.via_search.is_(True),
Video.created_at < cutoff,
~state_exists,
~pl_exists,
~sub_exists,
)
).rowcount
db.commit()
return {"search_videos_deleted": deleted}
def purge_ephemeral(db: Session) -> dict:
"""The scheduler's discovery-cleanup pass: reclaim un-kept explored channels AND un-kept
live-search result videos in one run."""
return {**purge_unkept_explored(db), **purge_unkept_search(db)}

View file

@ -18,6 +18,29 @@ def _to_int(value) -> int | None:
return None return None
def _parse_dt(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def _external_links(branding: dict) -> list | None:
"""Channel "About" links. The API exposes a few in brandingSettings.channel.profileColor/
/... varies; the durable spot is brandingSettings.channel.links is gone post-2023, so we
read what's present (settings/links) defensively and keep [{title, url}] pairs."""
channel = branding.get("channel", {})
links = channel.get("links") or []
out = [
{"title": l.get("title"), "url": l.get("url")}
for l in links
if isinstance(l, dict) and l.get("url")
]
return out or None
def apply_channel_details(db: Session, items: list[dict]) -> None: def apply_channel_details(db: Session, items: list[dict]) -> None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
for item in items: for item in items:
@ -28,6 +51,7 @@ def apply_channel_details(db: Session, items: list[dict]) -> None:
content = item.get("contentDetails", {}) content = item.get("contentDetails", {})
stats = item.get("statistics", {}) stats = item.get("statistics", {})
topics = item.get("topicDetails", {}) topics = item.get("topicDetails", {})
branding = item.get("brandingSettings", {})
channel.title = snippet.get("title") or channel.title channel.title = snippet.get("title") or channel.title
channel.handle = snippet.get("customUrl") channel.handle = snippet.get("customUrl")
@ -40,6 +64,10 @@ def apply_channel_details(db: Session, items: list[dict]) -> None:
channel.uploads_playlist_id = content.get("relatedPlaylists", {}).get("uploads") channel.uploads_playlist_id = content.get("relatedPlaylists", {}).get("uploads")
channel.subscriber_count = _to_int(stats.get("subscriberCount")) channel.subscriber_count = _to_int(stats.get("subscriberCount"))
channel.video_count = _to_int(stats.get("videoCount")) channel.video_count = _to_int(stats.get("videoCount"))
channel.total_view_count = _to_int(stats.get("viewCount"))
channel.published_at = _parse_dt(snippet.get("publishedAt"))
channel.banner_url = branding.get("image", {}).get("bannerExternalUrl")
channel.external_links = _external_links(branding)
channel.topic_categories = topics.get("topicCategories") channel.topic_categories = topics.get("topicCategories")
channel.details_synced_at = now channel.details_synced_at = now

View file

@ -234,6 +234,9 @@ def apply_video_details(video: Video, item: dict) -> None:
video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None
video.like_count = int(stats["likeCount"]) if stats.get("likeCount") else None video.like_count = int(stats["likeCount"]) if stats.get("likeCount") else None
video.category_id = int(snippet["categoryId"]) if snippet.get("categoryId") else None video.category_id = int(snippet["categoryId"]) if snippet.get("categoryId") else None
# Creator keyword tags (free with the snippet we already fetch) → search document.
tags = snippet.get("tags")
video.keywords = " ".join(tags) if tags else None
video.topic_categories = topics.get("topicCategories") video.topic_categories = topics.get("topicCategories")
video.default_language = snippet.get("defaultLanguage") or snippet.get( video.default_language = snippet.get("defaultLanguage") or snippet.get(
"defaultAudioLanguage" "defaultAudioLanguage"

View file

@ -51,6 +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),
# --- 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) --- # --- 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).

View file

@ -187,7 +187,7 @@ class YouTubeClient:
data = self._get( data = self._get(
"channels", "channels",
{ {
"part": "snippet,contentDetails,statistics,topicDetails", "part": "snippet,contentDetails,statistics,topicDetails,brandingSettings",
"id": ",".join(batch), "id": ",".join(batch),
"maxResults": 50, "maxResults": 50,
}, },

View file

@ -28,6 +28,8 @@ import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar"; import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed"; import Feed from "./components/Feed";
import ChannelPage from "./components/ChannelPage";
import BackToTop from "./components/BackToTop";
import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels"; import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels";
import Playlists from "./components/Playlists"; import Playlists from "./components/Playlists";
import Stats from "./components/Stats"; import Stats from "./components/Stats";
@ -128,10 +130,18 @@ export default function App() {
// popstate handler below derives ytSearch from it, so Back steps player → search → feed in // popstate handler below derives ytSearch from it, so Back steps player → search → feed in
// that order (a player opened over the results pops first, then the search, then the feed) — // that order (a player opened over the results pops first, then the search, then the feed) —
// instead of the search vanishing because it had no history entry of its own. // instead of the search vanishing because it had no history entry of its own.
const [ytSearch, setYtSearch] = useState<string | null>(null); const [ytSearch, setYtSearch] = useState<string | null>(() => {
// Restore a live search across a reload ONLY when it was served by the zero-quota scrape
// source (re-fetching is free) — an api-source search would re-spend ~100 units, so it's
// dropped to the normal feed as before. The scrape flag rides in history.state (survives F5).
const st = window.history.state;
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
});
const enterYtSearch = useCallback((q: string) => { const enterYtSearch = useCallback((q: string) => {
setYtSearch(q); setYtSearch(q);
const st = window.history.state || {}; // Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps
// it once the results resolve, so a reload only restores a confirmed scrape-sourced search.
const { _ytScrape: _drop, ...st } = window.history.state || {};
if (st._yt) window.history.replaceState({ ...st, _yt: q }, ""); // refine current search if (st._yt) window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
}, []); }, []);
@ -140,6 +150,27 @@ export default function App() {
if (window.history.state?._yt) window.history.back(); if (window.history.state?._yt) window.history.back();
else setYtSearch(null); else setYtSearch(null);
}, []); }, []);
// The open channel page (null = no channel page). Like the YouTube-search sub-view it owns a
// browser-history entry (history.state._chan = channel id, _chanName = a display name to show
// before the detail loads), so Back closes the channel page (after any player opened over it)
// and reload returns to the normal app. Channels/videos are reached by id, so it's not in the URL.
const [channelView, setChannelView] = useState<{ id: string; name?: string } | null>(() => {
// Restore the open channel page across a reload (it rides in history.state, which survives F5),
// unlike the YouTube search which is intentionally dropped (it would re-spend quota).
const c = window.history.state?._chan as string | undefined;
return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null;
});
const openChannel = useCallback((id: string, name?: string) => {
setChannelView({ id, name });
const st = window.history.state || {};
if (st._chan)
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "");
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "");
}, []);
const closeChannel = useCallback(() => {
if (window.history.state?._chan) window.history.back();
else setChannelView(null);
}, []);
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render. // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all"); const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all");
@ -186,14 +217,17 @@ export default function App() {
} }
function setPage(next: Page) { function setPage(next: Page) {
if (next === page) return; // While a channel page is open it overlays the content column, so a nav click must close it
// even when the underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
if (next === page && !channelView) return;
const go = () => { const go = () => {
setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next); setPageState(next);
localStorage.setItem(PAGE_KEY, next); localStorage.setItem(PAGE_KEY, next);
// Push an in-app history entry so the browser/mouse Back button steps through pages // Push an in-app history entry so the browser/mouse Back button steps through pages
// instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean — // instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean —
// the page rides in history.state, not the query string (filters never go in the URL). // the page rides in history.state, not the query string (filters never go in the URL).
// A fresh { sfPage } (no carried-over _sub/_ov markers) so each page starts at its root. // A fresh { sfPage } (no carried-over _sub/_ov/_chan markers) so each page starts at its root.
window.history.pushState({ sfPage: next }, ""); window.history.pushState({ sfPage: next }, "");
}; };
// Guard leaving the Settings page with unsaved preference changes. // Guard leaving the Settings page with unsaved preference changes.
@ -245,10 +279,17 @@ export default function App() {
// from history.state on popstate. (stripUrlParams preserves history.state, so this stamp // from history.state on popstate. (stripUrlParams preserves history.state, so this stamp
// survives a later query-string strip.) // survives a later query-string strip.)
useEffect(() => { useEffect(() => {
// Drop any stale _yt from a prior session (a reload starts on the normal feed; ytSearch // A reload returns to the normal feed by dropping a stale _yt — EXCEPT when the search was
// begins null), so the first Back doesn't resurrect a search we're no longer showing. // scrape-sourced (zero quota): then we keep _yt + _ytScrape so it restores and re-fetches for
const { _yt: _staleYt, ...rest } = window.history.state || {}; // free (ytSearch above already initialised from it). An api-source search is still dropped (it
// would re-spend ~100 units). _chan/_chanName are always kept so a channel page survives F5.
const st = window.history.state || {};
if (st._yt && st._ytScrape) {
window.history.replaceState({ ...st, sfPage: page }, "");
} else {
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
window.history.replaceState({ ...rest, sfPage: page }, ""); window.history.replaceState({ ...rest, sfPage: page }, "");
}
function onPop(e: PopStateEvent) { function onPop(e: PopStateEvent) {
const p = (e.state?.sfPage as Page) ?? "feed"; const p = (e.state?.sfPage as Page) ?? "feed";
// Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings // Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings
@ -273,6 +314,9 @@ export default function App() {
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or // The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
// clears it to match the entry we landed on (and a player popped first leaves it intact). // clears it to match the entry we landed on (and a player popped first leaves it intact).
setYtSearch((e.state?._yt as string) ?? null); setYtSearch((e.state?._yt as string) ?? null);
// The channel page rides in history.state._chan the same way.
const chan = e.state?._chan as string | undefined;
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
} }
window.addEventListener("popstate", onPop); window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop);
@ -518,6 +562,18 @@ export default function App() {
language={i18n.language as LangCode} language={i18n.language as LangCode}
/> />
<div className="relative flex-1 min-w-0 flex flex-col"> <div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? (
<ChannelPage
key={channelView.id}
channelId={channelView.id}
initialName={channelView.name}
me={meQuery.data!}
view={view}
onBack={closeChannel}
onOpenChannel={openChannel}
/>
) : (
<>
<Header <Header
me={meQuery.data!} me={meQuery.data!}
filters={filters} filters={filters}
@ -556,10 +612,7 @@ export default function App() {
setView={setChannelsView} setView={setChannelsView}
filtersResetToken={channelsFilterReset} filtersResetToken={channelsFilterReset}
onOpenWizard={() => setWizardOpen(true)} onOpenWizard={() => setWizardOpen(true)}
onViewChannel={(id, name) => { onViewChannel={(id, name) => openChannel(id, name)}
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
setPage("feed");
}}
onFilterByTag={(tagId, name) => { onFilterByTag={(tagId, name) => {
setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined }); setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined });
setPage("feed"); setPage("feed");
@ -599,10 +652,13 @@ export default function App() {
ytSearch={ytSearch} ytSearch={ytSearch}
onYtSearch={enterYtSearch} onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch} onExitYtSearch={exitYtSearch}
onOpenChannel={openChannel}
/> />
)} )}
</main> </main>
</div> </div>
</>
)}
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail. {/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
Anchored inside the content column so they clear the sidebar automatically Anchored inside the content column so they clear the sidebar automatically
(collapsed or expanded) without tracking its width. */} (collapsed or expanded) without tracking its width. */}
@ -625,6 +681,8 @@ export default function App() {
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} /> <ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
)} )}
<ErrorDialog /> <ErrorDialog />
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
</div> </div>
); );
} }

View file

@ -0,0 +1,46 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next";
// A floating "back to top" button that fades in once the current page's scroll container is
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside its
// own <main class="overflow-y-auto"> (the normal pages share App's; the channel page has its own),
// so it targets the live <main> and re-binds whenever `dep` changes (i.e. on navigation). Portaled
// to <body> so its fixed position is viewport-relative regardless of transformed ancestors.
export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; threshold?: number }) {
const { t } = useTranslation();
const [show, setShow] = useState(false);
const scrollerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const el = document.querySelector("main");
scrollerRef.current = el;
if (!el) {
setShow(false);
return;
}
const onScroll = () => setShow(el.scrollTop > threshold);
onScroll(); // a freshly-bound page starts at the top → hidden
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, [dep, threshold]);
const toTop = () => scrollerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
// No aria-label/title attributes: ad-block "annoyance" cosmetic filters (e.g. Brave Shields)
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
// The accessible name comes from a visually-hidden child instead, which those selectors can't match.
return createPortal(
<button
onClick={toTop}
className={`fixed bottom-5 right-5 z-30 inline-flex items-center justify-center w-11 h-11 rounded-full bg-card border border-border text-fg shadow-lg hover:border-accent hover:text-accent transition-all duration-200 ${
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
}`}
>
<ArrowUp className="w-5 h-5" aria-hidden="true" />
<span className="sr-only">{t("common.backToTop")}</span>
</button>,
document.body
);
}

View file

@ -0,0 +1,341 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw } from "lucide-react";
import Avatar from "./Avatar";
import Feed from "./Feed";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { api, type FeedFilters, type Me } from "../lib/api";
import { channelYouTubeUrl, formatViews } from "../lib/format";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
// auto-ingests recent uploads in the background ("explore") so they're browsable immediately,
// and offers "load more" to page deeper — without permanently polluting everyone's library
// (the backend keeps explored videos private to this user until they subscribe).
export default function ChannelPage({
channelId,
initialName,
me,
view,
onBack,
onOpenChannel,
}: {
channelId: string;
initialName?: string;
me: Me;
view: "grid" | "list";
onBack: () => void;
onOpenChannel: (id: string, name?: string) => void;
}) {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const [tab, setTab] = useState<"videos" | "about">("videos");
const detail = useQuery({
queryKey: ["channel", channelId],
queryFn: () => api.channelDetail(channelId),
});
const ch = detail.data;
// Background auto-ingest ("explore") of an un-subscribed channel's uploads.
const [nextToken, setNextToken] = useState<string | null | undefined>(undefined);
const [exploring, setExploring] = useState(false);
const autoTried = useRef<string | null>(null);
const runExplore = useCallback(
(token?: string | null) => {
setExploring(true);
return api
.exploreChannel(channelId, token ?? undefined)
.then((r) => {
setNextToken(r.next_page_token);
qc.invalidateQueries({ queryKey: ["feed"] });
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
// first visit (the GET that drove this render predated the explore that just ran).
qc.invalidateQueries({ queryKey: ["channel", channelId] });
})
.finally(() => setExploring(false));
},
[channelId, qc]
);
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo
// account (explore spends shared quota) and when videos are already present (a revisit, or a
// subscribed channel whose uploads the scheduler backfills).
useEffect(() => {
if (!ch || me.is_demo) return;
if (autoTried.current === channelId) 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: () => {
qc.invalidateQueries({ queryKey: ["channel", channelId] });
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["channels"] });
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
},
});
const unsubscribe = useMutation({
mutationFn: () => api.unsubscribeChannel(channelId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channel", channelId] });
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["channels"] });
},
});
const onUnsubscribe = async () => {
const ok = await confirm({
title: t("channel.unsubTitle"),
message: t("channel.unsubBody", { name: ch?.title ?? channelId }),
confirmLabel: t("channel.unsubscribe"),
danger: true,
});
if (ok) unsubscribe.mutate();
};
// Channel-scoped feed: the whole catalog filtered to this channel (scope=all + source=all so
// the explored, per-user-private videos show — the backend gates them to this explorer).
const [filters, setFilters] = useState<FeedFilters>(() => ({
tags: [],
tagMode: "or",
q: "",
sort: "newest",
scope: "all",
librarySource: "all",
includeNormal: true,
includeShorts: false,
includeLive: true,
show: "all",
channelId,
channelName: initialName,
}));
const name = ch?.title ?? initialName ?? channelId;
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
const joined = ch?.published_at
? new Date(ch.published_at).toLocaleDateString(i18n.language, { year: "numeric", month: "short" })
: null;
// Handle + stats on one compact meta line under the name (saves the separate stats row).
const metaParts = [
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
ch?.subscriber_count != null ? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) }) : null,
ch?.video_count != null ? t("channel.videoCount", { count: ch.video_count }) : null,
ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
joined ? t("channel.joined", { date: joined }) : null,
].filter(Boolean) as string[];
const tabClass = (active: boolean) =>
active
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
return (
<main className="flex-1 min-w-0 overflow-y-auto">
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
<div className="relative px-4 sm:px-6 pt-3">
{ch?.banner_url ? (
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
// low default size, so request a crisp wide version (=w1707) and object-cover the
// desktop "safe area" — the centre 2560×423 (~6:1) band.
<div
className="w-full overflow-hidden rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
>
<img
src={`${ch.banner_url}=w1707`}
alt=""
className="w-full h-full object-cover object-center"
/>
</div>
) : (
<div className="h-14 w-full rounded-2xl bg-surface" />
)}
<button
onClick={onBack}
className="absolute top-5 left-7 sm:left-9 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm"
>
<ArrowLeft className="w-4 h-4" />
{t("channel.back")}
</button>
</div>
<div className="px-4 sm:px-6">
{/* Identity + actions. relative+z-10 so the avatar that overlaps up into the banner
paints ABOVE it (the banner's positioned container would otherwise cover it). */}
<div className="relative z-10 flex items-end gap-4 -mt-8">
<Avatar
src={ch?.thumbnail_url ?? null}
fallback={name}
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
/>
<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?.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">
{metaParts.map((part, i) => (
<span key={i}>
{i > 0 && <span className="mr-1.5">·</span>}
{part}
</span>
))}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 pb-1">
<a
href={ytUrl}
target="_blank"
rel="noreferrer"
title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg border border-border"
>
<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}
disabled={unsubscribe.isPending}
title={t("channel.unsubscribe")}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg disabled:opacity-50"
>
<Check className="w-4 h-4" />
{t("channel.subscribedState")}
</button>
) : (
<button
onClick={() => subscribe.mutate()}
disabled={subscribe.isPending}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50"
>
<Plus className="w-4 h-4" />
{t("channel.subscribe")}
</button>
))}
</div>
</div>
{exploring && (
<div className="flex items-center gap-2 text-xs text-muted mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("channel.loadingVideos")}
</div>
)}
{/* Tabs */}
<div className="flex items-center gap-4 mt-3 border-b border-border">
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
{t("channel.tabVideos")}
</button>
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
{t("channel.tabAbout")}
</button>
</div>
</div>
{/* Tab content */}
{tab === "videos" ? (
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
canRead={me.can_read}
isDemo={me.is_demo}
onOpenWizard={() => {}}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
onOpenChannel={onOpenChannel}
channelScoped
/>
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<button
onClick={() => runExplore(nextToken)}
disabled={exploring}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border hover:bg-surface disabled:opacity-50"
>
{exploring ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
{t("channel.loadMore")}
</button>
</div>
)}
</>
) : (
<div className="px-4 sm:px-6 py-4 max-w-3xl">
{ch?.description ? (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{ch.description}</p>
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
{ch?.external_links && ch.external_links.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{ch.external_links.map((l, i) => (
<a
key={i}
href={l.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-accent hover:underline"
>
<ExternalLink className="w-3.5 h-3.5" />
{l.title || l.url}
</a>
))}
</div>
)}
</div>
)}
</main>
);
}

View file

@ -1,9 +1,10 @@
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
Ban,
Check, Check,
Eye, Eye,
EyeOff, EyeOff,
@ -11,7 +12,9 @@ import {
Pencil, Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
Search,
UserMinus, UserMinus,
X,
} from "lucide-react"; } from "lucide-react";
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
@ -85,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"] });
@ -181,9 +192,26 @@ export default function Channels({
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"), onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"),
}); });
// Channel-name search + tag-chip filtering, applied client-side over the status-filtered list
// (prominent controls instead of the DataTable's hidden per-column popovers).
const [search, setSearch] = useState("");
const [tagFilter, setTagFilter] = useState<number[]>([]);
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
useEffect(() => {
if (focusChannelName) setSearch(focusChannelName);
}, [focusChannelName]);
// The header's reset intent clears the search + tag chips (skip the initial mount).
const resetRef = useRef(filtersResetToken);
useEffect(() => {
if (filtersResetToken === resetRef.current) return;
resetRef.current = filtersResetToken;
setSearch("");
setTagFilter([]);
}, [filtersResetToken]);
// The Sync-status filter stays a top-level chip set (not a column filter) because the // The Sync-status filter stays a top-level chip set (not a column filter) because the
// header's "go to full history" deep-link drives it; the DataTable then handles name // header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination
// search, tag filtering, sort and pagination over whatever the status chip leaves. // run over whatever the status chip leaves.
const channels = (channelsQuery.data ?? []).filter((c) => const channels = (channelsQuery.data ?? []).filter((c) =>
statusFilter === "needs_full" statusFilter === "needs_full"
? !c.backfill_done ? !c.backfill_done
@ -193,6 +221,12 @@ export default function Channels({
? c.hidden ? c.hidden
: true : true
); );
const q = search.trim().toLowerCase();
const visibleChannels = channels.filter((c) => {
if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false;
if (tagFilter.length && !tagFilter.some((id) => c.tag_ids.includes(id))) return false;
return true;
});
const s = statusQuery.data; const s = statusQuery.data;
const onView = (c: ManagedChannel) => const onView = (c: ManagedChannel) =>
@ -231,7 +265,6 @@ export default function Channels({
header: t("channels.cols.channel"), header: t("channels.cols.channel"),
sortable: true, sortable: true,
sortValue: (c) => (c.title ?? c.id).toLowerCase(), sortValue: (c) => (c.title ?? c.id).toLowerCase(),
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
cardPrimary: true, cardPrimary: true,
render: (c) => ( render: (c) => (
<ChannelLink <ChannelLink
@ -305,12 +338,6 @@ export default function Channels({
key: "tags", key: "tags",
header: t("channels.cols.tags"), header: t("channels.cols.tags"),
cardLabel: false, cardLabel: false,
filter: {
kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
// OR semantics: show channels carrying any of the selected tags.
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
},
render: (c) => ( render: (c) => (
<TagsCell <TagsCell
c={c} c={c}
@ -468,21 +495,60 @@ export default function Channels({
/> />
</p> </p>
{/* Your tags — read-only overview; add/rename/delete live in the manager dialog. */} {/* Channel-name search */}
<div className="relative mb-3 max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted pointer-events-none" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("channels.searchPlaceholder")}
className="w-full bg-card border border-border rounded-lg pl-8 pr-8 py-2 text-sm outline-none focus:border-accent"
/>
{search && (
<button
onClick={() => setSearch("")}
aria-label={t("common.cancel")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted hover:text-fg"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Your tags — click a chip to filter the table by it (add/rename/delete live in the manager). */}
<div className="flex flex-wrap items-center gap-1.5 mb-4"> <div className="flex flex-wrap items-center gap-1.5 mb-4">
<Tooltip hint={t("channels.tags.yourTagsHint")}> <Tooltip hint={t("channels.tags.yourTagsHint")}>
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4"> <span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
{t("channels.tags.yourTags")} {t("channels.tags.yourTags")}
</span> </span>
</Tooltip> </Tooltip>
{userTags.map((tg) => ( {userTags.map((tg) => {
<span const active = tagFilter.includes(tg.id);
return (
<button
key={tg.id} key={tg.id}
className="text-xs px-2 py-1 rounded-full bg-card border border-border" onClick={() =>
setTagFilter((f) => (active ? f.filter((x) => x !== tg.id) : [...f, tg.id]))
}
aria-pressed={active}
className={`text-xs px-2 py-1 rounded-full border transition ${
active
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:text-fg hover:border-accent"
}`}
> >
{tg.name} {tg.name}
</span> </button>
))} );
})}
{tagFilter.length > 0 && (
<button
onClick={() => setTagFilter([])}
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
>
{t("datatable.clear")}
</button>
)}
<button <button
onClick={() => setTagManagerOpen(true)} onClick={() => setTagManagerOpen(true)}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition" className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
@ -500,18 +566,47 @@ export default function Channels({
<div className="text-muted py-8">{t("channels.loading")}</div> <div className="text-muted py-8">{t("channels.loading")}</div>
) : ( ) : (
<DataTable <DataTable
rows={channels} rows={visibleChannels}
columns={columns} columns={columns}
rowKey={(c) => c.id} rowKey={(c) => c.id}
persistKey="siftlode.channelsTable" persistKey="siftlode.channelsTable"
controlsPosition="top" controlsPosition="top"
controlsLeading={statusChips} controlsLeading={statusChips}
externalFilter={focusChannelName ? { key: "channel", value: focusChannelName } : null}
resetFiltersToken={filtersResetToken}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")} rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
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>
</> </>
); );
@ -609,8 +704,20 @@ function TagsCell({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Open the picker upward when there isn't room below (rows near the viewport bottom would
// otherwise clip it against the scroll container), as long as there's room above.
const [openUp, setOpenUp] = useState(false);
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useDismiss(open, () => setOpen(false), ref); useDismiss(open, () => setOpen(false), ref);
const toggle = () => {
if (!open) {
const r = btnRef.current?.getBoundingClientRect();
const est = Math.min(userTags.length * 30 + 12, 264); // matches the max-height cap below
setOpenUp(!!r && r.bottom + est > window.innerHeight - 8 && r.top - est > 8);
}
setOpen((o) => !o);
};
if (userTags.length === 0) return null; if (userTags.length === 0) return null;
// Show only the tags actually attached to this channel; the “+” opens a per-channel // Show only the tags actually attached to this channel; the “+” opens a per-channel
// picker to attach/detach (kept the convenient "kirakás" without listing every tag // picker to attach/detach (kept the convenient "kirakás" without listing every tag
@ -629,7 +736,8 @@ function TagsCell({
</button> </button>
))} ))}
<button <button
onClick={() => setOpen((o) => !o)} ref={btnRef}
onClick={toggle}
title={t("channels.row.editTags")} title={t("channels.row.editTags")}
aria-label={t("channels.row.editTags")} aria-label={t("channels.row.editTags")}
className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition" className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
@ -637,7 +745,11 @@ function TagsCell({
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3" />
</button> </button>
{open && ( {open && (
<div className="glass-menu absolute left-0 top-full mt-1 z-30 w-44 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"> <div
className={`glass-menu absolute left-0 z-30 w-44 max-h-[264px] overflow-y-auto rounded-xl p-1.5 animate-[popIn_0.16s_ease] ${
openUp ? "bottom-full mb-1" : "top-full mt-1"
}`}
>
{userTags.map((tg) => { {userTags.map((tg) => {
const on = c.tag_ids.includes(tg.id); const on = c.tag_ids.includes(tg.id);
return ( return (

View file

@ -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";
@ -23,9 +23,11 @@ const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings // Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
// (which encode both). One entry per concept; a single arrow flips the direction. // (which encode both). One entry per concept; a single arrow flips the direction.
type SortKey = "date" | "popular" | "duration" | "title" | "subscribers" | "priority" | "shuffle"; // "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.
type SortKey = "date" | "popular" | "duration" | "title" | "subscribers" | "priority" | "shuffle" | "relevance";
const SORT_KEYS: SortKey[] = ["date", "popular", "duration", "title", "subscribers", "priority", "shuffle"]; const SORT_KEYS: SortKey[] = ["date", "popular", "duration", "title", "subscribers", "priority", "shuffle"];
const SORT_MAP: Record<Exclude<SortKey, "shuffle">, { asc: string; desc: string }> = { const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"];
const SORT_MAP: Record<Exclude<SortKey, "shuffle" | "relevance">, { asc: string; desc: string }> = {
date: { desc: "newest", asc: "oldest" }, date: { desc: "newest", asc: "oldest" },
popular: { desc: "views", asc: "views_asc" }, popular: { desc: "views", asc: "views_asc" },
duration: { desc: "duration_desc", asc: "duration_asc" }, duration: { desc: "duration_desc", asc: "duration_asc" },
@ -33,11 +35,11 @@ const SORT_MAP: Record<Exclude<SortKey, "shuffle">, { asc: string; desc: string
subscribers: { desc: "subscribers", asc: "subscribers_asc" }, subscribers: { desc: "subscribers", asc: "subscribers_asc" },
priority: { desc: "priority", asc: "priority_asc" }, priority: { desc: "priority", asc: "priority_asc" },
}; };
const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle">, "asc" | "desc"> = { const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle" | "relevance">, "asc" | "desc"> = {
date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "desc", date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "desc",
}; };
function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } { function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } {
if (s === "shuffle") return { key: "shuffle", dir: "desc" }; if (s === "shuffle" || s === "relevance") return { key: s, dir: "desc" };
for (const k of Object.keys(SORT_MAP) as (keyof typeof SORT_MAP)[]) { for (const k of Object.keys(SORT_MAP) as (keyof typeof SORT_MAP)[]) {
if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" }; if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" };
if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" }; if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" };
@ -45,7 +47,8 @@ function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } {
return { key: "date", dir: "desc" }; return { key: "date", dir: "desc" };
} }
function buildSort(key: SortKey, dir: "asc" | "desc"): string { function buildSort(key: SortKey, dir: "asc" | "desc"): string {
return key === "shuffle" ? "shuffle" : SORT_MAP[key][dir]; if (key === "shuffle" || key === "relevance") return key;
return SORT_MAP[key][dir];
} }
function matchesView(status: string, show: string): boolean { function matchesView(status: string, show: string): boolean {
@ -74,6 +77,8 @@ export default function Feed({
ytSearch, ytSearch,
onYtSearch, onYtSearch,
onExitYtSearch, onExitYtSearch,
onOpenChannel,
channelScoped,
}: { }: {
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
@ -88,8 +93,17 @@ export default function Feed({
ytSearch: string | null; ytSearch: string | null;
onYtSearch: (q: string) => void; onYtSearch: (q: string) => void;
onExitYtSearch: () => void; onExitYtSearch: () => void;
// Open a channel's dedicated page (from a card's channel name / avatar). Threaded down to
// the cards via VirtualFeed.
onOpenChannel?: (channelId: string, channelName?: string) => void;
// True when this feed is scoped to a single channel (the channel page). Drops sort options
// that are constant within one channel (subscribers, priority) — they'd be meaningless.
channelScoped?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const sortKeys = channelScoped
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
: SORT_KEYS;
const [overrides, setOverrides] = useState<Record<string, string>>({}); const [overrides, setOverrides] = useState<Record<string, string>>({});
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({}); const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
// The open player: which video and where to start (null = resume from saved position). // The open player: which video and where to start (null = resume from saved position).
@ -104,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 —
@ -125,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,
@ -134,6 +151,30 @@ export default function Feed({
retry: false, retry: false,
}); });
// Remember (in history.state) that this live search was served by the zero-quota scrape source,
// so a reload restores the results instead of dropping to the feed — re-fetching scrape pages
// costs no quota. An api-source search is left unmarked (a reload would re-spend ~100 units).
useEffect(() => {
if (!ytActive) return;
const st = window.history.state;
if (!st || st._yt !== ytSearch) return; // only mark our own sub-view entry
if (ytQuery.data?.pages?.[0]?.source === "scrape" && !st._ytScrape) {
window.history.replaceState({ ...st, _ytScrape: true }, "");
}
}, [ytActive, ytSearch, ytQuery.data]);
// Switching to the relevance sort when a search starts happens atomically in the header's
// input onChange (race-free with the query update). Here we only handle the reverse: when
// the term is cleared, fall back to the default sort — relevance has no dropdown option and
// doesn't rank without a query.
const qEmpty = !filters.q.trim();
useEffect(() => {
if (qEmpty && parseSort(filters.sort).key === "relevance") {
setFilters({ ...filters, sort: buildSort("date", "desc") });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qEmpty]);
// Drop optimistic status overrides when filters change OR fresh server data // Drop optimistic status overrides when filters change OR fresh server data
// arrives — after a refetch the server is authoritative, so a stale override // arrives — after a refetch the server is authoritative, so a stale override
// (e.g. a video reverted to "new" from the notification center) won't keep it // (e.g. a video reverted to "new" from the notification center) won't keep it
@ -242,11 +283,11 @@ export default function Feed({
[qc] [qc]
); );
const onChannelFilter = useCallback( const handleOpenChannel = useCallback(
(channelId: string, channelName: string) => { (channelId: string, channelName: string) => {
setFilters({ ...filters, channelId, channelName }); onOpenChannel?.(channelId, channelName);
}, },
[filters, setFilters] [onOpenChannel]
); );
const onToggleSave = useCallback( const onToggleSave = useCallback(
@ -290,12 +331,16 @@ 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">
<div className="flex items-center gap-2 flex-wrap">
<button <button
onClick={() => { onClick={() => {
// The search just ingested new catalog videos, so the normal feed (which was // The search just ingested new catalog videos, so the normal feed (which was
// disabled and still holds its pre-search cache) is stale. Drop those caches so // disabled and still holds its pre-search cache) is stale. Drop those caches so
// it re-fetches fresh on return instead of showing the old (often empty) result. // it re-fetches fresh on return instead of showing the old (often empty) result.
// Surface the just-searched videos in the feed you return to: switch the Source
// filter to "search" (your own search finds) and rank them by relevance.
setFilters({ ...filters, librarySource: "search", sort: "relevance" });
onExitYtSearch(); onExitYtSearch();
qc.removeQueries({ queryKey: ["feed"] }); qc.removeQueries({ queryKey: ["feed"] });
qc.removeQueries({ queryKey: ["feed-count"] }); qc.removeQueries({ queryKey: ["feed-count"] });
@ -314,6 +359,47 @@ export default function Feed({
<span className="text-xs text-muted"> <span className="text-xs text-muted">
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")} {zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
</span> </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 ? (
@ -329,7 +415,7 @@ export default function Feed({
view={view} view={view}
onState={onState} onState={onState}
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter} onOpenChannel={handleOpenChannel}
onResetState={onResetState} onResetState={onResetState}
onOpen={openVideo} onOpen={openVideo}
hasNextPage={false} hasNextPage={false}
@ -360,6 +446,7 @@ export default function Feed({
startAt={activeVideo.startAt} startAt={activeVideo.startAt}
onClose={() => setActiveVideo(null)} onClose={() => setActiveVideo(null)}
onState={onState} onState={onState}
onOpenChannel={onOpenChannel}
/> />
)} )}
</div> </div>
@ -412,7 +499,7 @@ export default function Feed({
const toolbar = ( const toolbar = (
<div className="pb-3"> <div className="pb-3">
<div className="flex items-center gap-1.5 flex-wrap mb-2.5"> <div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
{SHOW_IDS.map((id) => ( {SHOW_IDS.map((id) => (
<button <button
key={id} key={id}
@ -445,7 +532,7 @@ export default function Feed({
</button> </button>
); );
})} })}
{filters.scope === "all" && ( {(
<> <>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" /> <span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<label className="inline-flex items-center gap-1.5 text-xs text-muted"> <label className="inline-flex items-center gap-1.5 text-xs text-muted">
@ -468,9 +555,8 @@ export default function Feed({
</label> </label>
</> </>
)} )}
</div> <span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<div className="flex items-center gap-2 border-t border-border pt-2.5"> <span className="text-xs text-muted whitespace-nowrap">
<span className="text-sm text-muted">
{countQuery.data {countQuery.data
? t("feed.videoCount", { ? t("feed.videoCount", {
count: countQuery.data.count, count: countQuery.data.count,
@ -478,13 +564,15 @@ export default function Feed({
}) })
: " "} : " "}
</span> </span>
<div className="flex-1" /> <div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted">{t("feed.sortLabel")}</span> <span className="text-xs text-muted">{t("feed.sortLabel")}</span>
<select <select
value={sortKey} value={sortKey}
onChange={(e) => { onChange={(e) => {
const key = e.target.value as SortKey; const key = e.target.value as SortKey;
const dir = key === "shuffle" ? "desc" : SORT_DEFAULT_DIR[key]; const dir = DIRECTIONLESS.includes(key)
? "desc"
: SORT_DEFAULT_DIR[key as Exclude<SortKey, "shuffle" | "relevance">];
setFilters({ setFilters({
...filters, ...filters,
sort: buildSort(key, dir), sort: buildSort(key, dir),
@ -493,13 +581,14 @@ export default function Feed({
}} }}
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent" className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
> >
{SORT_KEYS.map((k) => ( {/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
{(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => (
<option key={k} value={k}> <option key={k} value={k}>
{t("feed.sortKey." + k)} {t("feed.sortKey." + k)}
</option> </option>
))} ))}
</select> </select>
{sortKey !== "shuffle" && ( {!DIRECTIONLESS.includes(sortKey) && (
<button <button
onClick={() => onClick={() =>
setFilters({ ...filters, sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc") }) setFilters({ ...filters, sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc") })
@ -523,6 +612,7 @@ export default function Feed({
)} )}
</div> </div>
</div> </div>
</div>
); );
return ( return (
@ -547,7 +637,7 @@ export default function Feed({
view={view} view={view}
onState={onState} onState={onState}
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter} onOpenChannel={handleOpenChannel}
onResetState={onResetState} onResetState={onResetState}
onOpen={openVideo} onOpen={openVideo}
hasNextPage={!!hasNextPage} hasNextPage={!!hasNextPage}
@ -561,6 +651,7 @@ export default function Feed({
startAt={activeVideo.startAt} startAt={activeVideo.startAt}
onClose={() => setActiveVideo(null)} onClose={() => setActiveVideo(null)}
onState={onState} onState={onState}
onOpenChannel={onOpenChannel}
/> />
)} )}
{isFetchingNextPage && ( {isFetchingNextPage && (

View file

@ -67,7 +67,14 @@ export default function Header({
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" /> <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input <input
value={filters.q} value={filters.q}
onChange={(e) => setFilters({ ...filters, q: e.target.value })} onChange={(e) => {
const q = e.target.value;
// When a search first appears, rank the feed by relevance — set atomically with
// the query (race-free vs. per-keystroke updates). Only overrides the default
// "newest" sort; a custom sort the user chose is left alone. Cleared in Feed.
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
}}
onKeyDown={(e) => { onKeyDown={(e) => {
// Enter runs a live YouTube search for the typed term (the box itself filters // Enter runs a live YouTube search for the typed term (the box itself filters
// the local catalog as you type; Enter escalates to the YouTube API). // the local catalog as you type; Enter escalates to the YouTube API).

View file

@ -45,6 +45,7 @@ export default function PlayerModal({
startIndex, startIndex,
onClose, onClose,
onState, onState,
onOpenChannel,
}: { }: {
video: Video; video: Video;
// Where to start the opened video: a number of seconds (0 = Restart), or null/undefined // Where to start the opened video: a number of seconds (0 = Restart), or null/undefined
@ -57,6 +58,9 @@ export default function PlayerModal({
startIndex?: number; startIndex?: number;
onClose: () => void; onClose: () => void;
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
// Open the active video's channel page in-app (closes the player first). The small external
// icon next to the name keeps the open-on-YouTube behaviour.
onOpenChannel?: (channelId: string, channelName?: string) => void;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
@ -552,14 +556,38 @@ export default function PlayerModal({
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span> <span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
) )
) : ( ) : (
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
<button
onClick={() => {
if (!onOpenChannel) return onClose();
const cid = active.channel_id;
const cname = active.channel_title ?? undefined;
// Closing the player pops its own history entry (useBackToClose → history.back()).
// Open the channel only AFTER that pop's popstate — pushing the channel entry
// before it would let the back remove it again (the bug: clicking the name only
// closed the player). One-shot listener fires after App's popstate handler.
const open = () => {
window.removeEventListener("popstate", open);
onOpenChannel(cid, cname);
};
window.addEventListener("popstate", open);
onClose();
}}
className="font-medium hover:text-accent truncate text-left"
>
{active.channel_title}
</button>
<a <a
href={active.channel_url} href={active.channel_url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="font-medium hover:text-accent shrink-0" title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="text-muted hover:text-accent shrink-0"
> >
{active.channel_title} <ExternalLink className="w-3.5 h-3.5" />
</a> </a>
</div>
)} )}
{!navigated ? ( {!navigated ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0"> <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">

View file

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

View file

@ -182,7 +182,8 @@ export default function Sidebar({
const languages = tags.filter((t) => t.category === "language"); const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic"); const topics = tags.filter((t) => t.category === "topic");
// The user's own (non-system) tags — those assigned to channels in the Channel manager. // The user's own (non-system) tags — those assigned to channels in the Channel manager.
// Facets only cover language/topic, so these show their static channel_count. // Facets cover them too now (the "other" category), so they're contextual like the rest:
// zero-count chips hide once facets load, counts reflect the current filter.
const userTags = tags.filter((t) => !t.system); const userTags = tags.filter((t) => !t.system);
const [customDates, setCustomDates] = useState(false); const [customDates, setCustomDates] = useState(false);
const [tagManagerOpen, setTagManagerOpen] = useState(false); const [tagManagerOpen, setTagManagerOpen] = useState(false);
@ -407,18 +408,22 @@ export default function Sidebar({
</> </>
); );
} }
case "tags": case "tags": {
const tagChips = visibleChips(userTags);
return ( return (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{userTags.map((tg) => ( {tagChips.map((tg) => (
<TagChip <TagChip
key={tg.id} key={tg.id}
tag={tg} tag={tg}
count={tg.channel_count} count={chipCount(tg)}
active={filters.tags.includes(tg.id)} active={filters.tags.includes(tg.id)}
onClick={() => toggleTag(tg.id)} onClick={() => toggleTag(tg.id)}
/> />
))} ))}
{facetsReady && tagChips.length === 0 && (
<span className="text-[11px] text-muted">{t("sidebar.noMatchingTags")}</span>
)}
<button <button
onClick={() => setTagManagerOpen(true)} onClick={() => setTagManagerOpen(true)}
title={t("sidebar.manageTags")} title={t("sidebar.manageTags")}
@ -431,6 +436,7 @@ export default function Sidebar({
); );
} }
} }
}
const orderedAvailable = layout.order.filter((id) => available[id]); const orderedAvailable = layout.order.filter((id) => available[id]);
const renderedIds = editing const renderedIds = editing

View file

@ -1,4 +1,4 @@
import { memo } from "react"; import { memo, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Bookmark, Bookmark,
@ -6,7 +6,6 @@ import {
CheckCheck, CheckCheck,
Eye, Eye,
EyeOff, EyeOff,
ListFilter,
Play, Play,
RotateCcw, RotateCcw,
} from "lucide-react"; } from "lucide-react";
@ -21,13 +20,11 @@ function Actions({
onState, onState,
onResetState, onResetState,
onToggleSave, onToggleSave,
onChannelFilter,
}: { }: {
video: Video; video: Video;
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onResetState?: (id: string) => void; onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void; onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const act = (status: string) => (e: React.MouseEvent) => { const act = (status: string) => (e: React.MouseEvent) => {
@ -97,19 +94,6 @@ function Actions({
<RotateCcw className="w-4 h-4" /> <RotateCcw className="w-4 h-4" />
</button> </button>
)} )}
{onChannelFilter && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChannelFilter(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
title={t("card.onlyThisChannel")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
>
<ListFilter className="w-4 h-4" />
</button>
)}
</div> </div>
); );
} }
@ -244,7 +228,7 @@ function VideoCard({
onState, onState,
onResetState, onResetState,
onToggleSave, onToggleSave,
onChannelFilter, onOpenChannel,
onOpen, onOpen,
}: { }: {
video: Video; video: Video;
@ -252,7 +236,7 @@ function VideoCard({
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onResetState?: (id: string) => void; onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void; onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void; onOpenChannel?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video, startAt?: number | null) => void; onOpen?: (v: Video, startAt?: number | null) => void;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@ -280,12 +264,26 @@ function VideoCard({
)} )}
</> </>
); );
// Show the full title in a native tooltip only when it's clamped (overflows its 2 lines).
const titleRef = useRef<HTMLAnchorElement>(null);
const [titleClamped, setTitleClamped] = useState(false);
useEffect(() => {
const el = titleRef.current;
if (!el) return;
const check = () => setTitleClamped(el.scrollHeight > el.clientHeight + 1);
check();
const ro = new ResizeObserver(check);
ro.observe(el);
return () => ro.disconnect();
}, [video.title]);
const title = ( const title = (
<a <a
ref={titleRef}
href={video.watch_url} href={video.watch_url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
onClick={(e) => openInApp(e, video, onOpen)} onClick={(e) => openInApp(e, video, onOpen)}
title={titleClamped ? video.title ?? undefined : undefined}
className="font-medium leading-snug line-clamp-2 hover:text-accent" className="font-medium leading-snug line-clamp-2 hover:text-accent"
> >
{video.title} {video.title}
@ -303,18 +301,18 @@ function VideoCard({
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} /> <Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{title} {title}
<a <button
href={video.channel_url} onClick={(e) => {
target="_blank" e.stopPropagation();
rel="noreferrer" onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
onClick={(e) => e.stopPropagation()} }}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg" className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
> >
{video.channel_title} {video.channel_title}
</a> </button>
<div className="text-xs text-muted mt-0.5">{meta}</div> <div className="text-xs text-muted mt-0.5">{meta}</div>
</div> </div>
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} /> <Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
</div> </div>
); );
} }
@ -335,17 +333,17 @@ function VideoCard({
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{title} {title}
<a <button
href={video.channel_url} onClick={(e) => {
target="_blank" e.stopPropagation();
rel="noreferrer" onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
onClick={(e) => e.stopPropagation()} }}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg" className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
> >
{video.channel_title} {video.channel_title}
</a> </button>
<div className="text-xs text-muted mt-0.5">{meta}</div> <div className="text-xs text-muted mt-0.5">{meta}</div>
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} /> <Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
</div> </div>
</div> </div>
</div> </div>

View file

@ -40,7 +40,7 @@ export interface VirtualFeedProps {
view: "grid" | "list"; view: "grid" | "list";
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void; onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter: (channelId: string, channelName: string) => void; onOpenChannel: (channelId: string, channelName: string) => void;
onResetState: (id: string) => void; onResetState: (id: string) => void;
onOpen: (v: Video, startAt?: number | null) => void; onOpen: (v: Video, startAt?: number | null) => void;
hasNextPage: boolean; hasNextPage: boolean;
@ -53,7 +53,7 @@ export default function VirtualFeed({
view, view,
onState, onState,
onToggleSave, onToggleSave,
onChannelFilter, onOpenChannel,
onResetState, onResetState,
onOpen, onOpen,
hasNextPage, hasNextPage,
@ -149,7 +149,7 @@ export default function VirtualFeed({
view="grid" view="grid"
onState={onState} onState={onState}
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter} onOpenChannel={onOpenChannel}
onResetState={onResetState} onResetState={onResetState}
onOpen={onOpen} onOpen={onOpen}
/> />
@ -162,7 +162,7 @@ export default function VirtualFeed({
view="list" view="list"
onState={onState} onState={onState}
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter} onOpenChannel={onOpenChannel}
onResetState={onResetState} onResetState={onResetState}
onOpen={onOpen} onOpen={onOpen}
/> />

View file

@ -0,0 +1,23 @@
{
"back": "Zurück",
"exploringBadge": "Erkunden",
"subscribe": "Abonnieren",
"subscribedState": "Abonniert",
"unsubscribe": "Abbestellen",
"unsubTitle": "Abbestellen?",
"unsubBody": "{{name}} auf YouTube nicht mehr folgen?",
"subscribed": "{{name}} abonniert",
"subscribers": "{{formatted}} Abonnenten",
"videoCount_one": "{{count}} Video",
"videoCount_other": "{{count}} Videos",
"totalViews": "{{formatted}} Aufrufe",
"joined": "Beigetreten {{date}}",
"loadingVideos": "Videos dieses Kanals werden geladen…",
"tabVideos": "Videos",
"tabAbout": "Info",
"loadMore": "Mehr von YouTube laden",
"noDescription": "Dieser Kanal hat keine Beschreibung.",
"block": "Kanal blockieren",
"unblock": "Blockierung aufheben",
"blockedBadge": "Blockiert"
}

View file

@ -122,5 +122,10 @@
"resetFailed": "Kanal konnte nicht zurückgesetzt werden" "resetFailed": "Kanal konnte nicht zurückgesetzt werden"
}, },
"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…",
"blocked": {
"title": "Blockierte Kanäle",
"hint": "Ihre Videos sind in deinem Feed, der Bibliothek und der Live-Suche ausgeblendet."
}
} }

View file

@ -15,5 +15,6 @@
"accessRequestsMessage": "{{count}} ausstehend — prüfe sie auf der Benutzer-Seite.", "accessRequestsMessage": "{{count}} ausstehend — prüfe sie auf der Benutzer-Seite.",
"accessRequestsReview": "Überprüfen", "accessRequestsReview": "Überprüfen",
"demoTitle": "Demo-Konto", "demoTitle": "Demo-Konto",
"demoSharedNotice": "Du bist im gemeinsamen Demo-Konto. Alles, was du hier tust — Playlists, ausgeblendete Videos, Einstellungen — ist gemeinsam und kann von anderen Demo-Besuchern gleichzeitig geändert werden." "demoSharedNotice": "Du bist im gemeinsamen Demo-Konto. Alles, was du hier tust — Playlists, ausgeblendete Videos, Einstellungen — ist gemeinsam und kann von anderen Demo-Besuchern gleichzeitig geändert werden.",
"backToTop": "Nach oben"
} }

View file

@ -9,29 +9,98 @@
"quota": "Kontingent", "quota": "Kontingent",
"backfill": "Nachladen (Backfill)", "backfill": "Nachladen (Backfill)",
"shorts": "Shorts-Prüfung", "shorts": "Shorts-Prüfung",
"batch": "Stapelgrößen" "batch": "Stapelgrößen",
"explore": "Kanal erkunden"
}, },
"fields": { "fields": {
"smtp_host": { "label": "SMTP-Host", "hint": "z. B. smtp.gmail.com" }, "smtp_host": {
"smtp_port": { "label": "SMTP-Port", "hint": "Üblicherweise 587 (STARTTLS)." }, "label": "SMTP-Host",
"smtp_user": { "label": "SMTP-Benutzername", "hint": "Das sendende Konto / der Login." }, "hint": "z. B. smtp.gmail.com"
"smtp_from": { "label": "Absenderadresse", "hint": "z. B. Siftlode <addr@gmail.com>. Standard ist der Benutzername." }, },
"smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." }, "smtp_port": {
"quota_daily_budget": { "label": "Tägliches Kontingentbudget", "hint": "YouTube-Data-API-Einheiten pro Tag (kostenloses Limit 10.000). Standard 9000 lässt Puffer." }, "label": "SMTP-Port",
"backfill_quota_reserve": { "label": "Nachlade-Kontingentreserve", "hint": "Reservierte Einheiten, damit geplantes Nachladen interaktive Syncs / Anreicherung nie aushungert." }, "hint": "Üblicherweise 587 (STARTTLS)."
"search_daily_limit_per_user": { "label": "Live-Suche — Tageslimit pro Nutzer", "hint": "Maximale Live-YouTube-Suchen pro Nutzer und Tag. Bei der API-Quelle kostet jede Suche 100 Einheiten (das gemeinsame Budget reicht also für insgesamt nur ~80-90/Tag); bei der Scrape-Quelle ist es ein reines Missbrauchslimit. 0 = Live-Suche deaktiviert." }, },
"search_source": { "label": "Quelle der Live-Suche", "hint": "Woher die Ergebnisse der Live-YouTube-Suche stammen. \"scrape\" nutzt YouTubes internen Endpunkt und verbraucht kein API-Kontingent (empfohlen). \"api\" nutzt das offizielle search.list (100 Einheiten/Seite). Auf \"api\" umstellen, falls das Scraping ausfällt oder blockiert wird." }, "smtp_user": {
"backfill_recent_max_videos": { "label": "Aktuelles Nachladen — max. Videos", "hint": "Neueste Videos pro Kanal beim ersten Durchlauf." }, "label": "SMTP-Benutzername",
"backfill_recent_max_days": { "label": "Aktuelles Nachladen — max. Alter (Tage)", "hint": "Wie weit der erste Durchlauf pro Kanal zurückreicht." }, "hint": "Das sendende Konto / der Login."
"shorts_probe_max_seconds": { "label": "Shorts-Prüfung — max. Dauer (s)", "hint": "Nur Videos bis zu dieser Länge werden als mögliche Shorts geprüft." }, },
"shorts_probe_batch": { "label": "Shorts-Prüfung — Stapelgröße", "hint": "Wie viele Videos pro Lauf klassifiziert werden." }, "smtp_from": {
"enrich_batch_size": { "label": "Anreicherungs-Stapelgröße", "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." }, "label": "Absenderadresse",
"autotag_title_sample": { "label": "Auto-Tag-Titelstichprobe", "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." }, "hint": "z. B. Siftlode <addr@gmail.com>. Standard ist der Benutzername."
"youtube_api_key": { "label": "YouTube-API-Schlüssel", "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." }, },
"youtube_api_proxy": { "label": "YouTube-API-Egress-Proxy", "hint": "Optional. HTTP(S)-Proxy-URL für ausgehende YouTube-API-Aufrufe — über einen Host mit fester IP leiten, damit ein IP-beschränkter Schlüssel von einem Server mit dynamischer IP funktioniert. Leer = direkt." }, "smtp_password": {
"google_client_id": { "label": "Google-Client-ID", "hint": "OAuth-2.0-Client-ID für die Google-Anmeldung. Verschlüsselt gespeichert; nur schreibbar. Beide Google-Felder leer lassen, um die Google-Anmeldung zu deaktivieren (E-Mail+Passwort funktioniert weiter)." }, "label": "SMTP-Passwort",
"google_client_secret": { "label": "Google-Client-Secret", "hint": "OAuth-2.0-Client-Secret. Verschlüsselt gespeichert; nur schreibbar." }, "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt."
"allow_registration": { "label": "Registrierung erlauben", "hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig." } },
"quota_daily_budget": {
"label": "Tägliches Kontingentbudget",
"hint": "YouTube-Data-API-Einheiten pro Tag (kostenloses Limit 10.000). Standard 9000 lässt Puffer."
},
"backfill_quota_reserve": {
"label": "Nachlade-Kontingentreserve",
"hint": "Reservierte Einheiten, damit geplantes Nachladen interaktive Syncs / Anreicherung nie aushungert."
},
"search_daily_limit_per_user": {
"label": "Live-Suche — Tageslimit pro Nutzer",
"hint": "Maximale Live-YouTube-Suchen pro Nutzer und Tag. Bei der API-Quelle kostet jede Suche 100 Einheiten (das gemeinsame Budget reicht also für insgesamt nur ~80-90/Tag); bei der Scrape-Quelle ist es ein reines Missbrauchslimit. 0 = Live-Suche deaktiviert."
},
"search_source": {
"label": "Quelle der Live-Suche",
"hint": "Woher die Ergebnisse der Live-YouTube-Suche stammen. \"scrape\" nutzt YouTubes internen Endpunkt und verbraucht kein API-Kontingent (empfohlen). \"api\" nutzt das offizielle search.list (100 Einheiten/Seite). Auf \"api\" umstellen, falls das Scraping ausfällt oder blockiert wird."
},
"backfill_recent_max_videos": {
"label": "Aktuelles Nachladen — max. Videos",
"hint": "Neueste Videos pro Kanal beim ersten Durchlauf."
},
"backfill_recent_max_days": {
"label": "Aktuelles Nachladen — max. Alter (Tage)",
"hint": "Wie weit der erste Durchlauf pro Kanal zurückreicht."
},
"shorts_probe_max_seconds": {
"label": "Shorts-Prüfung — max. Dauer (s)",
"hint": "Nur Videos bis zu dieser Länge werden als mögliche Shorts geprüft."
},
"shorts_probe_batch": {
"label": "Shorts-Prüfung — Stapelgröße",
"hint": "Wie viele Videos pro Lauf klassifiziert werden."
},
"enrich_batch_size": {
"label": "Anreicherungs-Stapelgröße",
"hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)."
},
"autotag_title_sample": {
"label": "Auto-Tag-Titelstichprobe",
"hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung."
},
"youtube_api_key": {
"label": "YouTube-API-Schlüssel",
"hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar."
},
"youtube_api_proxy": {
"label": "YouTube-API-Egress-Proxy",
"hint": "Optional. HTTP(S)-Proxy-URL für ausgehende YouTube-API-Aufrufe — über einen Host mit fester IP leiten, damit ein IP-beschränkter Schlüssel von einem Server mit dynamischer IP funktioniert. Leer = direkt."
},
"google_client_id": {
"label": "Google-Client-ID",
"hint": "OAuth-2.0-Client-ID für die Google-Anmeldung. Verschlüsselt gespeichert; nur schreibbar. Beide Google-Felder leer lassen, um die Google-Anmeldung zu deaktivieren (E-Mail+Passwort funktioniert weiter)."
},
"google_client_secret": {
"label": "Google-Client-Secret",
"hint": "OAuth-2.0-Client-Secret. Verschlüsselt gespeichert; nur schreibbar."
},
"allow_registration": {
"label": "Registrierung erlauben",
"hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig."
},
"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", "save": "Speichern",
"saving": "Speichern…", "saving": "Speichern…",

View file

@ -21,7 +21,8 @@
"title": "Name", "title": "Name",
"subscribers": "Kanal-Abonnenten", "subscribers": "Kanal-Abonnenten",
"priority": "Kanal-Priorität", "priority": "Kanal-Priorität",
"shuffle": "Überraschung" "shuffle": "Überraschung",
"relevance": "Relevanz"
}, },
"loadingMore": "Mehr wird geladen…", "loadingMore": "Mehr wird geladen…",
"hiddenNamed": "Ausgeblendet: „{{title}}”", "hiddenNamed": "Ausgeblendet: „{{title}}”",
@ -32,7 +33,7 @@
"unwatch": "Nicht mehr als angesehen", "unwatch": "Nicht mehr als angesehen",
"source": { "source": {
"label": "Quelle", "label": "Quelle",
"tip": "Bibliothek danach filtern, wie Videos hierher kamen: nur organischer Katalog, mit Suchergebnissen, oder nur über Live-YouTube-Suche gefundene Videos.", "tip": "Danach filtern, wie Videos hierher kamen: nur deine Abos/Katalog, samt deiner über Live-YouTube-Suche gefundenen Videos, oder nur diese Suchergebnisse.",
"organic": "Ohne Suchergebnisse", "organic": "Ohne Suchergebnisse",
"all": "Mit Suchergebnissen", "all": "Mit Suchergebnissen",
"only": "Nur Suchergebnisse" "only": "Nur Suchergebnisse"
@ -47,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."
} }
} }

View file

@ -13,5 +13,6 @@
"channels_subscribe": "Abonnieren", "channels_subscribe": "Abonnieren",
"channels_unsubscribe": "Abo beenden", "channels_unsubscribe": "Abo beenden",
"maintenance_revalidate": "Wartungs-Neuvalidierung", "maintenance_revalidate": "Wartungs-Neuvalidierung",
"other": "Sonstiges" "other": "Sonstiges",
"channels_explore": "Kanal erkunden"
} }

View file

@ -66,7 +66,8 @@
"shorts": "Prüft youtube.com/shorts, um Shorts zu markieren. Fällt er aus, werden Shorts im Feed nicht von normalen Videos getrennt.", "shorts": "Prüft youtube.com/shorts, um Shorts zu markieren. Fällt er aus, werden Shorts im Feed nicht von normalen Videos getrennt.",
"subscriptions": "Importiert die YouTube-Abos jedes Nutzers neu. Fällt er aus, werden auf YouTube hinzugefügte oder entfernte Abos hier nicht übernommen.", "subscriptions": "Importiert die YouTube-Abos jedes Nutzers neu. Fällt er aus, werden auf YouTube hinzugefügte oder entfernte Abos hier nicht übernommen.",
"playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht.", "playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht.",
"maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog." "maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog.",
"explore_cleanup": "Entfernt Kanäle, die du erkundet, aber nie abonniert hast (und ihre Videos), nach einer Karenzzeit, damit Neugier-Stöbern den Katalog nicht überfüllt. Wenn es stoppt, bleiben verlassene erkundete Kanäle bestehen."
}, },
"jobs": { "jobs": {
"rss_poll": "RSS-Abfrage (neue Uploads)", "rss_poll": "RSS-Abfrage (neue Uploads)",
@ -77,7 +78,8 @@
"subscriptions": "Abo-Resync", "subscriptions": "Abo-Resync",
"playlist_sync": "YouTube-Wiedergabelisten-Sync", "playlist_sync": "YouTube-Wiedergabelisten-Sync",
"maintenance": "Wartung + Validierung", "maintenance": "Wartung + Validierung",
"demo_reset": "Demo-Konto-Reset" "demo_reset": "Demo-Konto-Reset",
"explore_cleanup": "Bereinigung erkundeter Kanäle"
}, },
"queue": { "queue": {
"recentPending": "Zu synchronisierende Kanäle", "recentPending": "Zu synchronisierende Kanäle",
@ -90,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"
} }

View file

@ -0,0 +1,23 @@
{
"back": "Back",
"exploringBadge": "Exploring",
"subscribe": "Subscribe",
"subscribedState": "Subscribed",
"unsubscribe": "Unsubscribe",
"unsubTitle": "Unsubscribe?",
"unsubBody": "Stop following {{name}} on YouTube?",
"subscribed": "Subscribed to {{name}}",
"subscribers": "{{formatted}} subscribers",
"videoCount_one": "{{count}} video",
"videoCount_other": "{{count}} videos",
"totalViews": "{{formatted}} views",
"joined": "Joined {{date}}",
"loadingVideos": "Loading this channel's videos…",
"tabVideos": "Videos",
"tabAbout": "About",
"loadMore": "Load more from YouTube",
"noDescription": "This channel has no description.",
"block": "Block channel",
"unblock": "Unblock channel",
"blockedBadge": "Blocked"
}

View file

@ -122,5 +122,10 @@
"resetFailed": "Couldn't reset this channel" "resetFailed": "Couldn't reset this channel"
}, },
"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…",
"blocked": {
"title": "Blocked channels",
"hint": "Their videos are hidden from your feed, Library, and live search."
}
} }

View file

@ -15,5 +15,6 @@
"accessRequestsMessage": "{{count}} pending — review them on the Users page.", "accessRequestsMessage": "{{count}} pending — review them on the Users page.",
"accessRequestsReview": "Review", "accessRequestsReview": "Review",
"demoTitle": "Demo account", "demoTitle": "Demo account",
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time." "demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.",
"backToTop": "Back to top"
} }

View file

@ -9,29 +9,98 @@
"quota": "Quota", "quota": "Quota",
"backfill": "Backfill", "backfill": "Backfill",
"shorts": "Shorts probe", "shorts": "Shorts probe",
"batch": "Batch sizes" "batch": "Batch sizes",
"explore": "Channel explore"
}, },
"fields": { "fields": {
"smtp_host": { "label": "SMTP host", "hint": "e.g. smtp.gmail.com" }, "smtp_host": {
"smtp_port": { "label": "SMTP port", "hint": "Usually 587 (STARTTLS)." }, "label": "SMTP host",
"smtp_user": { "label": "SMTP username", "hint": "The sending account / login." }, "hint": "e.g. smtp.gmail.com"
"smtp_from": { "label": "From address", "hint": "e.g. Siftlode <addr@gmail.com>. Defaults to the username." }, },
"smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." }, "smtp_port": {
"quota_daily_budget": { "label": "Daily quota budget", "hint": "Total YouTube Data API units to spend per day (free tier is 10,000). Default 9000 leaves headroom." }, "label": "SMTP port",
"backfill_quota_reserve": { "label": "Backfill quota reserve", "hint": "Units kept in reserve so scheduled backfill never starves interactive syncs / enrichment." }, "hint": "Usually 587 (STARTTLS)."
"search_daily_limit_per_user": { "label": "Live search — daily limit per user", "hint": "Max live YouTube searches per user per day. With the API source each search costs 100 units (so the shared budget affords only ~80-90/day total); with the scrape source it's a pure anti-abuse limit. Set 0 to disable live search." }, },
"search_source": { "label": "Live search source", "hint": "Where live YouTube search results come from. \"scrape\" uses YouTube's internal endpoint and spends no API quota (recommended). \"api\" uses the official search.list (100 units/page). Switch to \"api\" if scraping ever breaks or is blocked." }, "smtp_user": {
"backfill_recent_max_videos": { "label": "Recent backfill — max videos", "hint": "Newest videos fetched per channel on the first pass." }, "label": "SMTP username",
"backfill_recent_max_days": { "label": "Recent backfill — max age (days)", "hint": "How far back the first pass reaches per channel." }, "hint": "The sending account / login."
"shorts_probe_max_seconds": { "label": "Shorts probe — max duration (s)", "hint": "Only videos at or below this length are probed as possible Shorts." }, },
"shorts_probe_batch": { "label": "Shorts probe — batch size", "hint": "How many videos to classify per run." }, "smtp_from": {
"enrich_batch_size": { "label": "Enrichment batch size", "hint": "videos.list ids per call (YouTube caps this at 50)." }, "label": "From address",
"autotag_title_sample": { "label": "Auto-tag title sample", "hint": "Recent video titles sampled per channel for language detection." }, "hint": "e.g. Siftlode <addr@gmail.com>. Defaults to the username."
"youtube_api_key": { "label": "YouTube API key", "hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only." }, },
"youtube_api_proxy": { "label": "YouTube API egress proxy", "hint": "Optional. HTTP(S) proxy URL for outbound YouTube API calls — route them through a fixed-IP host so an IP-restricted key keeps working from a dynamic-IP server. Leave empty for direct." }, "smtp_password": {
"google_client_id": { "label": "Google client ID", "hint": "OAuth 2.0 client ID for Sign in with Google. Stored encrypted; write-only. Leave both Google fields empty to disable Google sign-in (email+password still works)." }, "label": "SMTP password",
"google_client_secret": { "label": "Google client secret", "hint": "OAuth 2.0 client secret. Stored encrypted; write-only." }, "hint": "App password. Stored encrypted; write-only — it's never shown back."
"allow_registration": { "label": "Allow registration", "hint": "When on, anyone can submit an email+password registration. They still need to verify their email and be approved by an admin before they can sign in." } },
"quota_daily_budget": {
"label": "Daily quota budget",
"hint": "Total YouTube Data API units to spend per day (free tier is 10,000). Default 9000 leaves headroom."
},
"backfill_quota_reserve": {
"label": "Backfill quota reserve",
"hint": "Units kept in reserve so scheduled backfill never starves interactive syncs / enrichment."
},
"search_daily_limit_per_user": {
"label": "Live search — daily limit per user",
"hint": "Max live YouTube searches per user per day. With the API source each search costs 100 units (so the shared budget affords only ~80-90/day total); with the scrape source it's a pure anti-abuse limit. Set 0 to disable live search."
},
"search_source": {
"label": "Live search source",
"hint": "Where live YouTube search results come from. \"scrape\" uses YouTube's internal endpoint and spends no API quota (recommended). \"api\" uses the official search.list (100 units/page). Switch to \"api\" if scraping ever breaks or is blocked."
},
"backfill_recent_max_videos": {
"label": "Recent backfill — max videos",
"hint": "Newest videos fetched per channel on the first pass."
},
"backfill_recent_max_days": {
"label": "Recent backfill — max age (days)",
"hint": "How far back the first pass reaches per channel."
},
"shorts_probe_max_seconds": {
"label": "Shorts probe — max duration (s)",
"hint": "Only videos at or below this length are probed as possible Shorts."
},
"shorts_probe_batch": {
"label": "Shorts probe — batch size",
"hint": "How many videos to classify per run."
},
"enrich_batch_size": {
"label": "Enrichment batch size",
"hint": "videos.list ids per call (YouTube caps this at 50)."
},
"autotag_title_sample": {
"label": "Auto-tag title sample",
"hint": "Recent video titles sampled per channel for language detection."
},
"youtube_api_key": {
"label": "YouTube API key",
"hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only."
},
"youtube_api_proxy": {
"label": "YouTube API egress proxy",
"hint": "Optional. HTTP(S) proxy URL for outbound YouTube API calls — route them through a fixed-IP host so an IP-restricted key keeps working from a dynamic-IP server. Leave empty for direct."
},
"google_client_id": {
"label": "Google client ID",
"hint": "OAuth 2.0 client ID for Sign in with Google. Stored encrypted; write-only. Leave both Google fields empty to disable Google sign-in (email+password still works)."
},
"google_client_secret": {
"label": "Google client secret",
"hint": "OAuth 2.0 client secret. Stored encrypted; write-only."
},
"allow_registration": {
"label": "Allow registration",
"hint": "When on, anyone can submit an email+password registration. They still need to verify their email and be approved by an admin before they can sign in."
},
"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", "save": "Save",
"saving": "Saving…", "saving": "Saving…",

View file

@ -21,7 +21,8 @@
"title": "Name", "title": "Name",
"subscribers": "Channel subscribers", "subscribers": "Channel subscribers",
"priority": "Channel priority", "priority": "Channel priority",
"shuffle": "Surprise me" "shuffle": "Surprise me",
"relevance": "Relevance"
}, },
"loadingMore": "Loading more…", "loadingMore": "Loading more…",
"hiddenNamed": "Hidden “{{title}}”", "hiddenNamed": "Hidden “{{title}}”",
@ -32,7 +33,7 @@
"unwatch": "Unwatch", "unwatch": "Unwatch",
"source": { "source": {
"label": "Source", "label": "Source",
"tip": "Filter the Library by how videos got here: organic catalog only, plus search results, or only videos found via live YouTube search.", "tip": "Filter by how videos got here: your subscriptions/catalog only, plus videos you found via live YouTube search, or only those search results.",
"organic": "Hide search results", "organic": "Hide search results",
"all": "Include search results", "all": "Include search results",
"only": "Search results only" "only": "Search results only"
@ -47,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."
} }
} }

View file

@ -13,5 +13,6 @@
"channels_subscribe": "Subscribe", "channels_subscribe": "Subscribe",
"channels_unsubscribe": "Unsubscribe", "channels_unsubscribe": "Unsubscribe",
"maintenance_revalidate": "Maintenance re-validation", "maintenance_revalidate": "Maintenance re-validation",
"other": "Other" "other": "Other",
"channels_explore": "Channel explore"
} }

View file

@ -66,7 +66,8 @@
"shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the feed.", "shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the feed.",
"subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.", "subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.",
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.", "playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.",
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue." "maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.",
"explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger."
}, },
"jobs": { "jobs": {
"rss_poll": "RSS poll (new uploads)", "rss_poll": "RSS poll (new uploads)",
@ -77,7 +78,8 @@
"subscriptions": "Subscription resync", "subscriptions": "Subscription resync",
"playlist_sync": "YouTube playlist sync", "playlist_sync": "YouTube playlist sync",
"maintenance": "Maintenance + validation", "maintenance": "Maintenance + validation",
"demo_reset": "Demo account reset" "demo_reset": "Demo account reset",
"explore_cleanup": "Explored-channel cleanup"
}, },
"queue": { "queue": {
"recentPending": "Channels to sync", "recentPending": "Channels to sync",
@ -90,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"
} }

View file

@ -0,0 +1,23 @@
{
"back": "Vissza",
"exploringBadge": "Felfedezés",
"subscribe": "Feliratkozás",
"subscribedState": "Feliratkozva",
"unsubscribe": "Leiratkozás",
"unsubTitle": "Leiratkozol?",
"unsubBody": "Megszünteted a(z) {{name}} követését a YouTube-on?",
"subscribed": "Feliratkoztál: {{name}}",
"subscribers": "{{formatted}} feliratkozó",
"videoCount_one": "{{count}} videó",
"videoCount_other": "{{count}} videó",
"totalViews": "{{formatted}} megtekintés",
"joined": "Csatlakozott: {{date}}",
"loadingVideos": "A csatorna videóinak betöltése…",
"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.",
"block": "Csatorna tiltása",
"unblock": "Tiltás feloldása",
"blockedBadge": "Tiltva"
}

View file

@ -122,5 +122,10 @@
"resetFailed": "Nem sikerült resetelni a csatornát" "resetFailed": "Nem sikerült resetelni a csatornát"
}, },
"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…",
"blocked": {
"title": "Tiltott csatornák",
"hint": "A videóik el vannak rejtve a feededből, a Library-ből és a YouTube-keresésből."
}
} }

View file

@ -15,5 +15,6 @@
"accessRequestsMessage": "{{count}} függőben — nézd át a Felhasználók oldalon.", "accessRequestsMessage": "{{count}} függőben — nézd át a Felhasználók oldalon.",
"accessRequestsReview": "Áttekintés", "accessRequestsReview": "Áttekintés",
"demoTitle": "Demo fiók", "demoTitle": "Demo fiók",
"demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják." "demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.",
"backToTop": "Vissza a tetejére"
} }

View file

@ -9,29 +9,98 @@
"quota": "Kvóta", "quota": "Kvóta",
"backfill": "Letöltés (backfill)", "backfill": "Letöltés (backfill)",
"shorts": "Shorts-vizsgálat", "shorts": "Shorts-vizsgálat",
"batch": "Kötegméretek" "batch": "Kötegméretek",
"explore": "Csatorna-felfedezés"
}, },
"fields": { "fields": {
"smtp_host": { "label": "SMTP-kiszolgáló", "hint": "pl. smtp.gmail.com" }, "smtp_host": {
"smtp_port": { "label": "SMTP-port", "hint": "Általában 587 (STARTTLS)." }, "label": "SMTP-kiszolgáló",
"smtp_user": { "label": "SMTP-felhasználónév", "hint": "A küldő fiók / bejelentkezés." }, "hint": "pl. smtp.gmail.com"
"smtp_from": { "label": "Feladó cím", "hint": "pl. Siftlode <addr@gmail.com>. Alapból a felhasználónév." }, },
"smtp_password": { "label": "SMTP-jelszó", "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra." }, "smtp_port": {
"quota_daily_budget": { "label": "Napi kvótakeret", "hint": "Naponta elkölthető YouTube Data API-egységek (az ingyenes keret 10 000). Az alap 9000 tartalékot hagy." }, "label": "SMTP-port",
"backfill_quota_reserve": { "label": "Letöltési kvótatartalék", "hint": "Tartalékban tartott egységek, hogy az ütemezett letöltés ne éheztesse ki az interaktív szinkront / gazdagítást." }, "hint": "Általában 587 (STARTTLS)."
"search_daily_limit_per_user": { "label": "Élő keresés — napi limit / felhasználó", "hint": "Maximális élő YouTube-keresés felhasználónként naponta. Az API-forrásnál egy keresés 100 egységbe kerül (így a közös büdzséből összesen csak ~80-90 fér bele naponta); a scrape-forrásnál ez tiszta visszaélés-elleni limit. 0 = élő keresés kikapcsolva." }, },
"search_source": { "label": "Élő keresés forrása", "hint": "Honnan jönnek az élő YouTube-keresés találatai. A „scrape” a YouTube belső végpontját használja és nem fogyaszt API-kvótát (ajánlott). Az „api” a hivatalos search.list-et (100 egység/oldal). Válts „api”-ra, ha a scrape valaha elromlik vagy letiltják." }, "smtp_user": {
"backfill_recent_max_videos": { "label": "Friss letöltés — max videó", "hint": "Csatornánként az első körben letöltött legújabb videók száma." }, "label": "SMTP-felhasználónév",
"backfill_recent_max_days": { "label": "Friss letöltés — max kor (nap)", "hint": "Az első kör csatornánként meddig nyúl vissza." }, "hint": "A küldő fiók / bejelentkezés."
"shorts_probe_max_seconds": { "label": "Shorts-vizsgálat — max hossz (mp)", "hint": "Csak az ennél nem hosszabb videókat vizsgáljuk lehetséges Shortsként." }, },
"shorts_probe_batch": { "label": "Shorts-vizsgálat — kötegméret", "hint": "Futásonként ennyi videót osztályozunk." }, "smtp_from": {
"enrich_batch_size": { "label": "Gazdagítási kötegméret", "hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)." }, "label": "Feladó cím",
"autotag_title_sample": { "label": "Auto-címke címmintavétel", "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." }, "hint": "pl. Siftlode <addr@gmail.com>. Alapból a felhasználónév."
"youtube_api_key": { "label": "YouTube API-kulcs", "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." }, },
"youtube_api_proxy": { "label": "YouTube API egress proxy", "hint": "Opcionális. HTTP(S) proxy URL a kimenő YouTube API-hívásokhoz — fix IP-jű hoston átengedve egy IP-korlátozott kulcs dinamikus IP-jű szerverről is működik. Üresen = közvetlen." }, "smtp_password": {
"google_client_id": { "label": "Google kliens-azonosító", "hint": "OAuth 2.0 kliens-azonosító a Google-bejelentkezéshez. Titkosítva tárolva; csak írható. Hagyd üresen mindkét Google-mezőt a Google-bejelentkezés kikapcsolásához (az e-mail+jelszó továbbra is működik)." }, "label": "SMTP-jelszó",
"google_client_secret": { "label": "Google kliens-titok", "hint": "OAuth 2.0 kliens-titok. Titkosítva tárolva; csak írható." }, "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra."
"allow_registration": { "label": "Regisztráció engedélyezése", "hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges." } },
"quota_daily_budget": {
"label": "Napi kvótakeret",
"hint": "Naponta elkölthető YouTube Data API-egységek (az ingyenes keret 10 000). Az alap 9000 tartalékot hagy."
},
"backfill_quota_reserve": {
"label": "Letöltési kvótatartalék",
"hint": "Tartalékban tartott egységek, hogy az ütemezett letöltés ne éheztesse ki az interaktív szinkront / gazdagítást."
},
"search_daily_limit_per_user": {
"label": "Élő keresés — napi limit / felhasználó",
"hint": "Maximális élő YouTube-keresés felhasználónként naponta. Az API-forrásnál egy keresés 100 egységbe kerül (így a közös büdzséből összesen csak ~80-90 fér bele naponta); a scrape-forrásnál ez tiszta visszaélés-elleni limit. 0 = élő keresés kikapcsolva."
},
"search_source": {
"label": "Élő keresés forrása",
"hint": "Honnan jönnek az élő YouTube-keresés találatai. A „scrape” a YouTube belső végpontját használja és nem fogyaszt API-kvótát (ajánlott). Az „api” a hivatalos search.list-et (100 egység/oldal). Válts „api”-ra, ha a scrape valaha elromlik vagy letiltják."
},
"backfill_recent_max_videos": {
"label": "Friss letöltés — max videó",
"hint": "Csatornánként az első körben letöltött legújabb videók száma."
},
"backfill_recent_max_days": {
"label": "Friss letöltés — max kor (nap)",
"hint": "Az első kör csatornánként meddig nyúl vissza."
},
"shorts_probe_max_seconds": {
"label": "Shorts-vizsgálat — max hossz (mp)",
"hint": "Csak az ennél nem hosszabb videókat vizsgáljuk lehetséges Shortsként."
},
"shorts_probe_batch": {
"label": "Shorts-vizsgálat — kötegméret",
"hint": "Futásonként ennyi videót osztályozunk."
},
"enrich_batch_size": {
"label": "Gazdagítási kötegméret",
"hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)."
},
"autotag_title_sample": {
"label": "Auto-címke címmintavétel",
"hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez."
},
"youtube_api_key": {
"label": "YouTube API-kulcs",
"hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható."
},
"youtube_api_proxy": {
"label": "YouTube API egress proxy",
"hint": "Opcionális. HTTP(S) proxy URL a kimenő YouTube API-hívásokhoz — fix IP-jű hoston átengedve egy IP-korlátozott kulcs dinamikus IP-jű szerverről is működik. Üresen = közvetlen."
},
"google_client_id": {
"label": "Google kliens-azonosító",
"hint": "OAuth 2.0 kliens-azonosító a Google-bejelentkezéshez. Titkosítva tárolva; csak írható. Hagyd üresen mindkét Google-mezőt a Google-bejelentkezés kikapcsolásához (az e-mail+jelszó továbbra is működik)."
},
"google_client_secret": {
"label": "Google kliens-titok",
"hint": "OAuth 2.0 kliens-titok. Titkosítva tárolva; csak írható."
},
"allow_registration": {
"label": "Regisztráció engedélyezése",
"hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges."
},
"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", "save": "Mentés",
"saving": "Mentés…", "saving": "Mentés…",

View file

@ -21,7 +21,8 @@
"title": "Név", "title": "Név",
"subscribers": "Csatorna feliratkozók", "subscribers": "Csatorna feliratkozók",
"priority": "Csatorna prioritás", "priority": "Csatorna prioritás",
"shuffle": "Meglepetés" "shuffle": "Meglepetés",
"relevance": "Relevancia"
}, },
"loadingMore": "Továbbiak betöltése…", "loadingMore": "Továbbiak betöltése…",
"hiddenNamed": "Elrejtve: „{{title}}”", "hiddenNamed": "Elrejtve: „{{title}}”",
@ -32,7 +33,7 @@
"unwatch": "Megnézés visszavonása", "unwatch": "Megnézés visszavonása",
"source": { "source": {
"label": "Forrás", "label": "Forrás",
"tip": "Szűrd a könyvtárat aszerint, hogyan kerültek be a videók: csak organikus katalógus, kereséssel együtt, vagy csak az élő YouTube-keresésből talált videók.", "tip": "Szűrés aszerint, hogyan kerültek be a videók: csak a feliratkozásaid/katalógus, az élő YouTube-keresésből talált videóiddal együtt, vagy csak azok a keresési találatok.",
"organic": "Keresés nélkül", "organic": "Keresés nélkül",
"all": "Kereséssel együtt", "all": "Kereséssel együtt",
"only": "Csak keresésből" "only": "Csak keresésből"
@ -47,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."
} }
} }

View file

@ -13,5 +13,6 @@
"channels_subscribe": "Feliratkozás", "channels_subscribe": "Feliratkozás",
"channels_unsubscribe": "Leiratkozás", "channels_unsubscribe": "Leiratkozás",
"maintenance_revalidate": "Karbantartó újraellenőrzés", "maintenance_revalidate": "Karbantartó újraellenőrzés",
"other": "Egyéb" "other": "Egyéb",
"channels_explore": "Csatorna-felfedezés"
} }

View file

@ -66,7 +66,8 @@
"shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a hírfolyamban.", "shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a hírfolyamban.",
"subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.", "subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.",
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.", "playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.",
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak." "maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.",
"explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak."
}, },
"jobs": { "jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)", "rss_poll": "RSS-lekérdezés (új feltöltések)",
@ -77,7 +78,8 @@
"subscriptions": "Feliratkozások újraszinkronja", "subscriptions": "Feliratkozások újraszinkronja",
"playlist_sync": "YouTube lejátszási listák szinkronja", "playlist_sync": "YouTube lejátszási listák szinkronja",
"maintenance": "Karbantartás + ellenőrzés", "maintenance": "Karbantartás + ellenőrzés",
"demo_reset": "Demo fiók visszaállítása" "demo_reset": "Demo fiók visszaállítása",
"explore_cleanup": "Felfedezett csatornák takarítása"
}, },
"queue": { "queue": {
"recentPending": "Szinkronra váró csatorna", "recentPending": "Szinkronra váró csatorna",
@ -90,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"
} }

View file

@ -76,6 +76,38 @@ export interface VideoDetail {
duration_seconds: number | null; duration_seconds: number | null;
} }
export interface ChannelLink {
title: string | null;
url: string;
}
export interface ChannelDetail {
id: string;
title: string | null;
handle: string | null;
description: string | null;
thumbnail_url: string | null;
banner_url: string | null;
subscriber_count: number | null;
video_count: number | null;
total_view_count: number | null;
published_at: string | null;
external_links: ChannelLink[];
country: string | null;
subscribed: boolean;
explored: boolean;
blocked: boolean;
stored_videos: number;
from_explore: boolean;
details_synced: boolean;
}
export interface ExploreResult {
channel_id: string;
ingested: number;
next_page_token: string | null;
}
export interface FeedResponse { export interface FeedResponse {
items: Video[]; items: Video[];
has_more: boolean; has_more: boolean;
@ -570,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
@ -628,6 +665,29 @@ export const api = {
subscribeChannel: (id: string) => subscribeChannel: (id: string) =>
req(`/api/channels/${id}/subscribe`, { method: "POST" }), req(`/api/channels/${id}/subscribe`, { method: "POST" }),
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
// --- channel page (About detail + ephemeral exploration of un-subscribed channels) ---
channelDetail: (id: string): Promise<ChannelDetail> => req(`/api/channels/${id}`),
// Open an un-subscribed channel for browsing: ingest a page of its uploads (newest-first,
// marked via_explore) + record the exploration. `pageToken` continues into older uploads
// ("load more"). `quiet` so a quota-reserve 429 / load error renders inline on the page.
exploreChannel: (id: string, pageToken?: string | null): Promise<ExploreResult> => {
const p = new URLSearchParams();
if (pageToken) p.set("page_token", pageToken);
const qs = p.toString();
return req(
`/api/channels/${id}/explore${qs ? `?${qs}` : ""}`,
{ method: "POST" },
{ 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[]> =>

View file

@ -66,9 +66,17 @@ export function useBackToClose(onClose: () => void): void {
const idx = overlayStack.lastIndexOf(token); const idx = overlayStack.lastIndexOf(token);
if (idx !== -1) { if (idx !== -1) {
// Closed programmatically (not via Back): drop our own history entry, and tell the // Closed programmatically (not via Back): drop our own history entry, and tell the
// remaining overlays' handlers to ignore the resulting popstate. // remaining overlays' handlers to ignore the resulting popstate. A one-shot listener
// clears the flag on that very popstate even when NO overlay remains underneath to
// consume it — otherwise suppressPop leaks `true` and silently swallows the NEXT
// overlay's first Back (reopen-after-button-close → first Back does nothing).
overlayStack.splice(idx, 1); overlayStack.splice(idx, 1);
suppressPop = true; suppressPop = true;
const clearSuppress = () => {
suppressPop = false;
window.removeEventListener("popstate", clearSuppress);
};
window.addEventListener("popstate", clearSuppress);
window.history.back(); window.history.back();
} }
}; };

View file

@ -14,6 +14,23 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.19.0",
date: "2026-07-01",
summary: "Channel pages, much smarter search, and self-tidying YouTube search results.",
features: [
"Channel pages: open any channel's own page inside Siftlode — banner, stats (subscribers, videos, views, joined) and its uploads, with Videos and About tabs. Click a channel name anywhere to open it.",
"Explore channels you don't follow: opening an un-subscribed channel loads its recent uploads just for you — private until you subscribe. Channels you explore but don't keep tidy themselves away automatically after a while.",
"Smarter search: results are now relevance-ranked and match far more than the title — a video's tags, its description, and even the searches that first surfaced it all count, so you can find things by meaning instead of exact words. In your own feed (Mine), a Source filter separates your subscriptions from your search finds.",
"Temporary search results: live YouTube search results are clearly marked temporary — they clear themselves unless you watch or save one, with a “Clear now” button to do it at once. Pick how many to fetch (20/40/60/100), and videos you already have are sorted below fresh discoveries.",
"Block channels: hide a channel everywhere — from search, your feed and channel pages — and manage your blocked channels from the Channel manager.",
"The Channel manager gets a search box and clickable tag chips, and a floating “back to top” button appears on long pages.",
],
fixes: [
"The Back button no longer does nothing the first time: after you closed the video player (or a dialog) with its ✕/Escape and opened another, the next Back press now closes it right away — everywhere in the app.",
"Reloading the page now keeps your YouTube search results on screen (they re-fetch for free) instead of dropping you back to the feed.",
],
},
{ {
version: "0.18.0", version: "0.18.0",
date: "2026-06-29", date: "2026-06-29",