diff --git a/VERSION b/VERSION index 6633391..1cf0537 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.18.0 +0.19.0 diff --git a/backend/alembic/versions/0030_search_finds.py b/backend/alembic/versions/0030_search_finds.py new file mode 100644 index 0000000..975027a --- /dev/null +++ b/backend/alembic/versions/0030_search_finds.py @@ -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") diff --git a/backend/alembic/versions/0031_title_fts.py b/backend/alembic/versions/0031_title_fts.py new file mode 100644 index 0000000..ea16ac9 --- /dev/null +++ b/backend/alembic/versions/0031_title_fts.py @@ -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") diff --git a/backend/alembic/versions/0032_search_document.py b/backend/alembic/versions/0032_search_document.py new file mode 100644 index 0000000..3683df4 --- /dev/null +++ b/backend/alembic/versions/0032_search_document.py @@ -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, '')))" + ) diff --git a/backend/alembic/versions/0033_channel_explore.py b/backend/alembic/versions/0033_channel_explore.py new file mode 100644 index 0000000..d12187b --- /dev/null +++ b/backend/alembic/versions/0033_channel_explore.py @@ -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") diff --git a/backend/alembic/versions/0034_blocked_channels.py b/backend/alembic/versions/0034_blocked_channels.py new file mode 100644 index 0000000..ebcb85c --- /dev/null +++ b/backend/alembic/versions/0034_blocked_channels.py @@ -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") diff --git a/backend/app/config.py b/backend/app/config.py index 78c1bd8..9b13b62 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -118,6 +118,15 @@ class Settings(BaseSettings): # 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). 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 # ("was_live") are real watchable content and stay visible. feed_default_hidden_live: str = "live,upcoming" diff --git a/backend/app/models.py b/backend/app/models.py index f40653d..5788589 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -3,6 +3,7 @@ from datetime import date, datetime from sqlalchemy import ( BigInteger, Boolean, + Computed, Date, DateTime, Float, @@ -14,6 +15,7 @@ from sqlalchemy import ( UniqueConstraint, func, ) +from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base @@ -163,6 +165,12 @@ class Channel(Base, TimestampMixin): uploads_playlist_id: Mapped[str | None] = mapped_column(String(64)) subscriber_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) default_language: Mapped[str | None] = mapped_column(String(16)) country: Mapped[str | None] = mapped_column(String(8)) @@ -181,6 +189,14 @@ class Channel(Base, TimestampMixin): from_search: Mapped[bool] = mapped_column( 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") subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel") @@ -237,6 +253,14 @@ class Video(Base, TimestampMixin): topic_categories: Mapped[list | None] = mapped_column(JSON) default_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( Boolean, default=False, server_default="false", index=True ) @@ -250,6 +274,14 @@ class Video(Base, TimestampMixin): via_search: Mapped[bool] = mapped_column( 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 live_status: Mapped[str] = mapped_column( 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)) + # 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") @@ -344,6 +391,69 @@ class VideoState(Base, UpdatedAtMixin): 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): """Tracks YouTube Data API units spent per Pacific-time day (the quota resets at midnight Pacific). The whole app shares one daily budget.""" diff --git a/backend/app/quota.py b/backend/app/quota.py index 1e72e56..0e44839 100644 --- a/backend/app/quota.py +++ b/backend/app/quota.py @@ -38,6 +38,7 @@ class QuotaAction: VIDEOS_LOOKUP = "videos_lookup" VIDEOS_SEARCH = "videos_search" CHANNELS_DISCOVER = "channels_discover" + CHANNELS_EXPLORE = "channels_explore" CHANNELS_SUBSCRIBE = "channels_subscribe" CHANNELS_UNSUBSCRIBE = "channels_unsubscribe" MAINTENANCE_REVALIDATE = "maintenance_revalidate" diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 722ac3c..1d86ed0 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -376,3 +376,15 @@ def reset_demo( same automatically (admin-tunable interval); this is the admin's on-demand button.""" demo = get_or_create_demo_user(db) return {"reset": True, "playlists_seeded": reset_demo_state(db, demo)} + + +@router.post("/purge-discovery") +def purge_discovery( + _: User = Depends(admin_user), db: Session = Depends(get_db) +) -> dict: + """Reclaim all un-kept discovery content NOW (ignoring the grace period): live-search result + videos nobody watched/saved/subscribed to, plus explored-but-unsubscribed channels. The + scheduled discovery-cleanup job does the same on its interval; this is the on-demand button.""" + from app.sync.explore import purge_unkept_explored, purge_unkept_search + + return {**purge_unkept_search(db, grace_days=0), **purge_unkept_explored(db)} diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index f1d00eb..4c47b71 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -5,15 +5,17 @@ import logging from datetime import datetime, timezone 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 app import quota -from app.auth import current_user, has_write_scope +from app import quota, sysconfig +from app.auth import current_user, has_write_scope, require_human from app.db import get_db from app.models import ( + BlockedChannel, Channel, ChannelTag, + ExploredChannel, Playlist, PlaylistItem, Subscription, @@ -22,6 +24,7 @@ from app.models import ( Video, ) 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.subscriptions import apply_channel_details 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: sub = db.execute( select(Subscription).where( @@ -332,6 +474,14 @@ def subscribe( 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() return {"subscribed": channel_id} @@ -367,6 +517,68 @@ def unsubscribe( return {"unsubscribed": channel_id} +@router.get("/blocked/list") +def list_blocked( + user: User = Depends(current_user), db: Session = Depends(get_db) +) -> list[dict]: + """The user's blocked channels (videos from these are excluded from their search/feed/explore).""" + rows = db.execute( + select(Channel) + .join(BlockedChannel, BlockedChannel.channel_id == Channel.id) + .where(BlockedChannel.user_id == user.id) + .order_by(func.lower(Channel.title)) + ).scalars().all() + return [ + {"id": c.id, "title": c.title, "handle": c.handle, "thumbnail_url": c.thumbnail_url} + for c in rows + ] + + +@router.post("/{channel_id}/block") +def block_channel( + channel_id: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Block a channel for this user: its videos won't appear in their live search (and won't be + ingested for them) and are hidden from their feed / explore. Idempotent. The now-hidden + un-kept search/explore videos are reclaimed by the discovery-cleanup job in due course.""" + if db.get(Channel, channel_id) is None: + raise HTTPException(status_code=404, detail="Unknown channel") + exists = db.execute( + select(BlockedChannel.id).where( + BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id + ) + ).first() + if exists is None: + db.add(BlockedChannel(user_id=user.id, channel_id=channel_id)) + # Stop any active exploration of it so it can be cleaned up. + db.execute( + ExploredChannel.__table__.delete().where( + (ExploredChannel.user_id == user.id) + & (ExploredChannel.channel_id == channel_id) + ) + ) + db.commit() + return {"blocked": channel_id} + + +@router.delete("/{channel_id}/block") +def unblock_channel( + channel_id: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + db.execute( + BlockedChannel.__table__.delete().where( + (BlockedChannel.user_id == user.id) + & (BlockedChannel.channel_id == channel_id) + ) + ) + db.commit() + return {"unblocked": channel_id} + + @router.post("/{channel_id}/tags") def attach_tag( channel_id: str, diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 09648d5..7b76021 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -1,10 +1,11 @@ import base64 import binascii import json +import re from datetime import date, datetime, timedelta, timezone 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.orm import Session, aliased from sqlalchemy.orm.exc import StaleDataError @@ -13,10 +14,13 @@ from app import quota from app.auth import current_user from app.db import get_db from app.models import ( + BlockedChannel, Channel, ChannelTag, + ExploredChannel, Playlist, PlaylistItem, + SearchFind, Subscription, Tag, User, @@ -153,14 +157,20 @@ def _filtered_query( ), ) else: - # Only channels this user is subscribed to (and hasn't hidden). - query = query.join( + # "Mine": the user's own videos = their non-hidden subscriptions OR videos they + # 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, and_( Subscription.channel_id == Video.channel_id, Subscription.user_id == user.id, Subscription.hidden.is_(False), ), + ).outerjoin( + SearchFind, + and_(SearchFind.video_id == Video.id, SearchFind.user_id == user.id), ) query = query.outerjoin( @@ -172,16 +182,50 @@ def _filtered_query( # either way they shouldn't show in any feed view meanwhile. query = query.where(Video.unavailable_since.is_(None)) - # Provenance filter for the Library: search-discovered videos are "noise" hidden by - # default ("organic"), can be mixed in ("all"), or shown exclusively ("search"). Only - # meaningful in "all" scope: "my" already restricts to subscribed channels, where a - # once-searched video is legitimately the user's. + # Explored videos are PER-EXPLORER private: a via_explore video (ingested when someone + # opened an un-subscribed channel's page) is visible only to users who explored or + # subscribe to its channel, so one user's curiosity never leaks into everyone else'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 library_source == "organic": query = query.where(Video.via_search.is_(False)) elif library_source == "search": query = query.where(Video.via_search.is_(True)) # "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: query = query.where(Video.channel_id == channel_id) @@ -204,16 +248,36 @@ def _filtered_query( published_before, datetime.min.time(), tzinfo=timezone.utc ) + timedelta(days=1) 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: - # Accent-insensitive: unaccent() both sides so "tiesto" matches "Tiësto" (ILIKE alone - # is case- but not diacritic-insensitive). unaccent is enabled by migration 0029. - like = func.unaccent(f"%{q}%") - query = query.where( - or_( - func.unaccent(Video.title).ilike(like), - func.unaccent(Channel.title).ilike(like), + ts_str = _to_tsquery_str(q) + 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}%") + query = query.where( + or_( + func.unaccent(Video.title).ilike(like), + func.unaccent(Channel.title).ilike(like), + ) ) - ) if tags: # AND across tag categories (e.g. language AND topic narrows), OR within a @@ -271,7 +335,19 @@ def _filtered_query( else: # all 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. @@ -320,7 +396,7 @@ def _feed_params( # 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 # (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"} table: dict[str, list[dict]] = { "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. "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"] @@ -409,8 +492,8 @@ def get_feed( user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - query, _status = _filtered_query(db, user, **params) - keys = _sort_keys(sort, seed) + query, _status, rank_expr = _filtered_query(db, user, **params) + 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. 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), db: Session = Depends(get_db), ) -> 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())) return {"count": total or 0} @@ -461,13 +544,13 @@ def get_facets( (tag ids).""" visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) 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 # 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 # already-selected topics — and tags that can't co-occur drop to zero (hidden). conjunctive = category == "topic" and params.get("tag_mode") == "and" - base, _status = _filtered_query( + base, _status, _rank = _filtered_query( db, user, **{**params, "exclude_tag_category": None if conjunctive else category}, diff --git a/backend/app/routes/search.py b/backend/app/routes/search.py index efba64f..c84a509 100644 --- a/backend/app/routes/search.py +++ b/backend/app/routes/search.py @@ -16,13 +16,24 @@ from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone 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 app import quota, sysconfig from app.auth import require_human 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.sync.subscriptions import apply_channel_details from app.sync.videos import _insert_stubs, apply_video_details, parse_dt @@ -90,18 +101,119 @@ def _serialize_ids(db: Session, user: User, ids: list[str]) -> list[dict]: return [by_id[i] for i in ids if i in by_id] +def _ingest_candidates( + db: Session, yt: YouTubeClient, user: User, candidates: list[dict] +) -> list[str]: + """Materialise one page of search candidates into the catalog (channel stubs + enrich, + video stubs flagged via_search, enrich, Shorts classification) and return the KEEPABLE + video ids — non-Short, non-live — in the input relevance order.""" + ids = [it["id"] for it in candidates] + if not ids: + return [] + + # 1) Channels are the FK target — create stubs for unknown ones (flagged from_search), + # then enrich the new ones (channels.list, 1 unit). Stubs survive an enrich failure. + channel_ids = list({it["channel_id"] for it in candidates}) + existing = set(db.scalars(select(Channel.id).where(Channel.id.in_(channel_ids))).all()) + new_channel_ids = [c for c in channel_ids if c not in existing] + if new_channel_ids: + titles = {it["channel_id"]: it["channel_title"] for it in candidates} + for cid in new_channel_ids: + db.add(Channel(id=cid, title=titles.get(cid), from_search=True)) + db.commit() + try: + with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER): + details = yt.get_channels(new_channel_ids) + apply_channel_details(db, details) + db.commit() + except YouTubeError: + db.rollback() # details can be backfilled later; the stubs remain + + # 2) Video stubs, flagged via_search. Existing rows keep their flags (ON CONFLICT DO + # NOTHING), so an organically-synced video stays organic. + _insert_stubs( + db, + [ + { + "id": it["id"], + "channel_id": it["channel_id"], + "title": it["title"], + "published_at": parse_dt(it["published_at"]), + "thumbnail_url": it["thumbnail_url"], + "via_search": True, + } + for it in candidates + ], + ) + + # 3) Enrich (videos.list, 1 unit/50) so cards have duration/stats and we can judge live. + videos = db.scalars(select(Video).where(Video.id.in_(ids))).all() + try: + with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH): + detail_by_id = {it["id"]: it for it in yt.get_videos(ids)} + except YouTubeError: + detail_by_id = {} + now = datetime.now(timezone.utc) + for v in videos: + item = detail_by_id.get(v.id) + if item is None: + continue + apply_video_details(v, item) + v.enriched_at = now + db.commit() + + # 4) Confirm/deny Shorts (no quota) so none enter via search. + _classify_shorts(db, videos) + + keep = {v.id for v in videos if not v.is_short and v.live_status not in _LIVE_HIDDEN} + return [vid for vid in ids if vid in keep] + + +def _new_first(db: Session, user: User, ids: list[str]) -> list[str]: + """Reorder results so genuinely-new discoveries come first and things the user already has + (a subscribed channel, or a video they've watched / in-progress / saved / playlisted) sink + to the back — keeping the relevance order within each group.""" + if not ids: + return ids + sub_ch = select(Subscription.channel_id).where(Subscription.user_id == user.id) + has_state = ( + select(VideoState.id) + .where(VideoState.video_id == Video.id, VideoState.user_id == user.id) + .exists() + ) + in_playlist = ( + select(PlaylistItem.id) + .join(Playlist, Playlist.id == PlaylistItem.playlist_id) + .where(PlaylistItem.video_id == Video.id, Playlist.user_id == user.id) + .exists() + ) + have = set( + db.scalars( + select(Video.id).where( + Video.id.in_(ids), + or_(Video.channel_id.in_(sub_ch), has_state, in_playlist), + ) + ).all() + ) + return [i for i in ids if i not in have] + [i for i in ids if i in have] + + @router.get("/youtube") def search_youtube( q: str | None = None, cursor: str | None = None, + limit: int = 20, user: User = Depends(require_human), db: Session = Depends(get_db), ) -> dict: - """One page of live YouTube search results, materialised into the catalog. `cursor` is the - YouTube nextPageToken (each page is another 100-unit search).""" + """Live YouTube search results materialised into the catalog. `limit` is how many results to + gather (the count selector); with the free scrape source we page through continuations until + that many are collected, so there's no manual "load more". The API source costs 100 units per + page, so it returns a single page (≤50) regardless. `cursor` lets the caller fetch further.""" term = (q or "").strip() if not term: raise HTTPException(status_code=400, detail="A search term is required.") + limit = max(1, min(limit, 100)) # Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page). source = (sysconfig.get_str(db, "search_source") or "scrape").lower() @@ -124,98 +236,111 @@ def search_youtube( detail="The shared YouTube quota for today is used up. Try again tomorrow.", ) + # Channels this user has blocked are dropped before ingestion — never shown, never stored. + blocked = set( + db.scalars( + select(BlockedChannel.channel_id).where(BlockedChannel.user_id == user.id) + ).all() + ) + + collected: list[str] = [] + next_cursor = cursor + MAX_PAGES = 12 # scrape safety bound with YouTubeClient(db, user) as yt: - try: - if source == "api": - with quota.attribute(user.id, quota.QuotaAction.VIDEOS_SEARCH): - page = yt.search_videos(term, page_token=cursor) - else: - page = search_scrape.search(term, continuation=cursor) - # Zero-cost, but logged so the per-user daily cap above keeps counting it. - quota.log_action(db, user.id, quota.QuotaAction.VIDEOS_SEARCH) - except (YouTubeError, search_scrape.ScrapeError) as exc: - raise HTTPException(status_code=502, detail=f"YouTube search failed: {exc}") - - # Drop currently-live/upcoming results (search can't filter them and we never ingest - # them this way) and anything missing a channel id. - candidates = [ - it - for it in page["items"] - if it["live_broadcast"] == "none" and it["channel_id"] - ] - ids = [it["id"] for it in candidates] - if not ids: - return { - "items": [], - "next_cursor": page["next_page_token"], - "has_more": bool(page["next_page_token"]), - "source": source, - } - - # 1) Channels are the FK target — create stubs for unknown ones (flagged from_search), - # then enrich the new ones (channels.list, 1 unit). Stubs survive an enrich failure. - channel_ids = list({it["channel_id"] for it in candidates}) - existing = set( - db.scalars(select(Channel.id).where(Channel.id.in_(channel_ids))).all() - ) - new_channel_ids = [c for c in channel_ids if c not in existing] - if new_channel_ids: - titles = {it["channel_id"]: it["channel_title"] for it in candidates} - for cid in new_channel_ids: - db.add(Channel(id=cid, title=titles.get(cid), from_search=True)) - db.commit() + for _ in range(MAX_PAGES): try: - with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER): - details = yt.get_channels(new_channel_ids) - apply_channel_details(db, details) - db.commit() - except YouTubeError: - db.rollback() # details can be backfilled later; the stubs remain + if source == "api": + with quota.attribute(user.id, quota.QuotaAction.VIDEOS_SEARCH): + page = yt.search_videos(term, page_token=next_cursor) + else: + page = search_scrape.search(term, continuation=next_cursor) + # Zero-cost, but logged so the per-user daily cap above keeps counting it. + quota.log_action(db, user.id, quota.QuotaAction.VIDEOS_SEARCH) + except (YouTubeError, search_scrape.ScrapeError) as exc: + if collected: + break # keep what we already gathered + raise HTTPException(status_code=502, detail=f"YouTube search failed: {exc}") - # 2) Video stubs, flagged via_search. Existing rows keep their flags (ON CONFLICT DO - # NOTHING), so an organically-synced video stays organic. - _insert_stubs( - db, - [ - { - "id": it["id"], - "channel_id": it["channel_id"], - "title": it["title"], - "published_at": parse_dt(it["published_at"]), - "thumbnail_url": it["thumbnail_url"], - "via_search": True, - } - for it in candidates - ], + # Drop currently-live/upcoming, channel-less, and blocked-channel results. + candidates = [ + it + for it in page["items"] + if it["live_broadcast"] == "none" + and it["channel_id"] + and it["channel_id"] not in blocked + ] + collected.extend(_ingest_candidates(db, yt, user, candidates)) + next_cursor = page["next_page_token"] + # The API source pages cost 100 units each — never auto-page; one page per request. + if source == "api" or len(collected) >= limit or not next_cursor: + break + + 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) + ) + ) ) - - # 3) Enrich (videos.list, 1 unit/50) so cards have duration/stats and we can judge live. - videos = db.scalars(select(Video).where(Video.id.in_(ids))).all() - try: - with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH): - detail_by_id = {it["id"]: it for it in yt.get_videos(ids)} - except YouTubeError: - detail_by_id = {} - now = datetime.now(timezone.utc) - for v in videos: - item = detail_by_id.get(v.id) - if item is None: - continue - apply_video_details(v, item) - v.enriched_at = now db.commit() - # 4) Confirm/deny Shorts (no quota) so none enter via search. - _classify_shorts(db, videos) - - # 5) Exclude Shorts and still-live results; keep the search's relevance order. - keep = { - v.id for v in videos if not v.is_short and v.live_status not in _LIVE_HIDDEN - } - ordered = [vid for vid in ids if vid in keep] + ordered = _new_first(db, user, ordered) return { "items": _serialize_ids(db, user, ordered), - "next_cursor": page["next_page_token"], - "has_more": bool(page["next_page_token"]), + "next_cursor": next_cursor, + "has_more": bool(next_cursor), "source": source, } + + +@router.post("/clear") +def clear_search( + payload: dict, + user: User = Depends(require_human), + db: Session = Depends(get_db), +) -> dict: + """Discard the given live-search results "as if never added": drop this user's search-finds + for them, then hard-delete the now-orphaned ones — a via_search video nobody KEPT (no watch + state, not playlisted, channel not subscribed) and that no other user still has as a search + find. Kept videos and organically-owned ones are left untouched.""" + ids = [str(v) for v in (payload.get("video_ids") or [])] + if not ids: + return {"removed": 0} + db.execute( + delete(SearchFind).where( + SearchFind.user_id == user.id, SearchFind.video_id.in_(ids) + ) + ) + db.commit() + has_state = select(VideoState.id).where(VideoState.video_id == Video.id).exists() + in_playlist = select(PlaylistItem.id).where(PlaylistItem.video_id == Video.id).exists() + subscribed = ( + select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists() + ) + other_find = select(SearchFind.id).where(SearchFind.video_id == Video.id).exists() + removed = db.execute( + delete(Video).where( + Video.id.in_(ids), + Video.via_search.is_(True), + ~has_state, + ~in_playlist, + ~subscribed, + ~other_find, + ) + ).rowcount + db.commit() + return {"removed": removed} diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index fd8de99..cdec9f3 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -16,6 +16,7 @@ from app.models import SchedulerSetting from app.notifications import create_notification from app.state import is_sync_paused from app.sync.autotag import run_autotag_all +from app.sync.explore import purge_ephemeral from app.sync.maintenance import run_maintenance from app.sync.playlists import sync_all_playlists from app.sync.runner import ( @@ -57,6 +58,7 @@ JOB_INTERVALS: dict[str, int] = { "playlist_sync": settings.playlist_sync_minutes, "maintenance": settings.maintenance_interval_minutes, "demo_reset": settings.demo_reset_minutes, + "explore_cleanup": settings.explore_cleanup_minutes, } # Sane bounds for an admin-set interval (minutes). @@ -268,6 +270,12 @@ def _demo_reset_job() -> None: _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, # shared by start_scheduler (recurring registration) and trigger_job (manual "run now"). JOB_FUNCS: dict[str, Callable[[], None]] = { @@ -280,6 +288,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = { "playlist_sync": _playlist_sync_job, "maintenance": _maintenance_job, "demo_reset": _demo_reset_job, + "explore_cleanup": _explore_cleanup_job, } diff --git a/backend/app/sync/explore.py b/backend/app/sync/explore.py new file mode 100644 index 0000000..9faf800 --- /dev/null +++ b/backend/app/sync/explore.py @@ -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)} diff --git a/backend/app/sync/subscriptions.py b/backend/app/sync/subscriptions.py index adafce3..c0f643a 100644 --- a/backend/app/sync/subscriptions.py +++ b/backend/app/sync/subscriptions.py @@ -18,6 +18,29 @@ def _to_int(value) -> int | 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: now = datetime.now(timezone.utc) for item in items: @@ -28,6 +51,7 @@ def apply_channel_details(db: Session, items: list[dict]) -> None: content = item.get("contentDetails", {}) stats = item.get("statistics", {}) topics = item.get("topicDetails", {}) + branding = item.get("brandingSettings", {}) channel.title = snippet.get("title") or channel.title 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.subscriber_count = _to_int(stats.get("subscriberCount")) 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.details_synced_at = now diff --git a/backend/app/sync/videos.py b/backend/app/sync/videos.py index de0df77..a7a77d9 100644 --- a/backend/app/sync/videos.py +++ b/backend/app/sync/videos.py @@ -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.like_count = int(stats["likeCount"]) if stats.get("likeCount") 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.default_language = snippet.get("defaultLanguage") or snippet.get( "defaultAudioLanguage" diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index c6d8004..ff396c7 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -51,6 +51,11 @@ SPECS: tuple[ConfigSpec, ...] = ( # --- Batch sizes --- ConfigSpec("enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50), ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000), + # --- Discovery cleanup (search + explore ephemeral content) --- + # Days an explored-but-unsubscribed channel is kept before the cleanup job purges it. + ConfigSpec("explore_grace_days", "int", "explore", "explore_grace_days", min=0, max=3_650), + # Days an un-kept live-search result video is kept before the cleanup job removes it. + ConfigSpec("search_grace_days", "int", "explore", "search_grace_days", min=0, max=3_650), # --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) --- ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True), # Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key). diff --git a/backend/app/youtube/client.py b/backend/app/youtube/client.py index b4e5ef6..0fc0ebf 100644 --- a/backend/app/youtube/client.py +++ b/backend/app/youtube/client.py @@ -187,7 +187,7 @@ class YouTubeClient: data = self._get( "channels", { - "part": "snippet,contentDetails,statistics,topicDetails", + "part": "snippet,contentDetails,statistics,topicDetails,brandingSettings", "id": ",".join(batch), "maxResults": 50, }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b777c3a..18aa039 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,8 @@ import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; import Sidebar from "./components/Sidebar"; 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 Playlists from "./components/Playlists"; 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 // 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. - const [ytSearch, setYtSearch] = useState(null); + const [ytSearch, setYtSearch] = useState(() => { + // 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) => { 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 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(); 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); // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render. const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all"); @@ -186,14 +217,17 @@ export default function App() { } 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 = () => { + setChannelView(null); // leave any open channel page when navigating via the rail setPageState(next); localStorage.setItem(PAGE_KEY, next); // 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 — // 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 }, ""); }; // 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 // survives a later query-string strip.) useEffect(() => { - // Drop any stale _yt from a prior session (a reload starts on the normal feed; ytSearch - // begins null), so the first Back doesn't resurrect a search we're no longer showing. - const { _yt: _staleYt, ...rest } = window.history.state || {}; - window.history.replaceState({ ...rest, sfPage: page }, ""); + // A reload returns to the normal feed by dropping a stale _yt — EXCEPT when the search was + // scrape-sourced (zero quota): then we keep _yt + _ytScrape so it restores and re-fetches for + // 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 }, ""); + } function onPop(e: PopStateEvent) { const p = (e.state?.sfPage as Page) ?? "feed"; // 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 // clears it to match the entry we landed on (and a player popped first leaves it intact). 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); return () => window.removeEventListener("popstate", onPop); @@ -518,6 +562,18 @@ export default function App() { language={i18n.language as LangCode} />
+ {channelView ? ( + + ) : ( + <>
setWizardOpen(true)} - onViewChannel={(id, name) => { - setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); - setPage("feed"); - }} + onViewChannel={(id, name) => openChannel(id, name)} onFilterByTag={(tagId, name) => { setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined }); setPage("feed"); @@ -599,10 +652,13 @@ export default function App() { ytSearch={ytSearch} onYtSearch={enterYtSearch} onExitYtSearch={exitYtSearch} + onOpenChannel={openChannel} /> )}
+ + )} {/* 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 (collapsed or expanded) without tracking its width. */} @@ -625,6 +681,8 @@ export default function App() { setNotesOpen(false)} highlight={notesHighlight} /> )} + {/* Re-binds to the active page's scroll container on navigation (page or channel change). */} + ); } diff --git a/frontend/src/components/BackToTop.tsx b/frontend/src/components/BackToTop.tsx new file mode 100644 index 0000000..26bc792 --- /dev/null +++ b/frontend/src/components/BackToTop.tsx @@ -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
(the normal pages share App's; the channel page has its own), +// so it targets the live
and re-binds whenever `dep` changes (i.e. on navigation). Portaled +// to 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(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( + , + document.body + ); +} diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx new file mode 100644 index 0000000..2c81407 --- /dev/null +++ b/frontend/src/components/ChannelPage.tsx @@ -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(undefined); + const [exploring, setExploring] = useState(false); + const autoTried = useRef(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(() => ({ + 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 ( +
+ {/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */} +
+ {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. +
+ +
+ ) : ( +
+ )} + +
+ +
+ {/* 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). */} +
+ +
+
+

{name}

+ {ch?.blocked ? ( + + {t("channel.blockedBadge")} + + ) : ( + ch?.explored && + !ch?.subscribed && ( + + {t("channel.exploringBadge")} + + ) + )} +
+
+ {metaParts.map((part, i) => ( + + {i > 0 && ·} + {part} + + ))} +
+
+
+ + + + {!me.is_demo && ( + + )} + {!me.is_demo && + !ch?.blocked && + (ch?.subscribed ? ( + + ) : ( + + ))} +
+
+ + {exploring && ( +
+ + {t("channel.loadingVideos")} +
+ )} + + {/* Tabs */} +
+ + +
+
+ + {/* Tab content */} + {tab === "videos" ? ( + <> + {}} + ytSearch={null} + onYtSearch={() => {}} + onExitYtSearch={() => {}} + onOpenChannel={onOpenChannel} + channelScoped + /> + {!ch?.subscribed && nextToken && ( +
+ +
+ )} + + ) : ( +
+ {ch?.description ? ( +

{ch.description}

+ ) : ( +

{t("channel.noDescription")}

+ )} + {ch?.external_links && ch.external_links.length > 0 && ( +
+ {ch.external_links.map((l, i) => ( + + + {l.title || l.url} + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 21cce00..26e4fa7 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -1,9 +1,10 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDown, ArrowUp, + Ban, Check, Eye, EyeOff, @@ -11,7 +12,9 @@ import { Pencil, Plus, RefreshCw, + Search, UserMinus, + X, } from "lucide-react"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; import { useDismiss } from "../lib/useDismiss"; @@ -85,7 +88,15 @@ export default function Channels({ const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); + const blockedQuery = useQuery({ queryKey: ["blocked-channels"], queryFn: api.blockedChannels }); const [tagManagerOpen, setTagManagerOpen] = useState(false); + const unblock = useMutation({ + mutationFn: (id: string) => api.unblockChannel(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["blocked-channels"] }); + qc.invalidateQueries({ queryKey: ["feed"] }); + }, + }); const invalidate = () => { qc.invalidateQueries({ queryKey: ["channels"] }); @@ -181,9 +192,26 @@ export default function Channels({ 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([]); + // 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 - // header's "go to full history" deep-link drives it; the DataTable then handles name - // search, tag filtering, sort and pagination over whatever the status chip leaves. + // header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination + // run over whatever the status chip leaves. const channels = (channelsQuery.data ?? []).filter((c) => statusFilter === "needs_full" ? !c.backfill_done @@ -193,6 +221,12 @@ export default function Channels({ ? c.hidden : 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 onView = (c: ManagedChannel) => @@ -231,7 +265,6 @@ export default function Channels({ header: t("channels.cols.channel"), sortable: true, sortValue: (c) => (c.title ?? c.id).toLowerCase(), - filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` }, cardPrimary: true, render: (c) => ( ({ 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) => (

- {/* Your tags — read-only overview; add/rename/delete live in the manager dialog. */} + {/* Channel-name search */} +
+ + 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 && ( + + )} +
+ + {/* Your tags — click a chip to filter the table by it (add/rename/delete live in the manager). */}
{t("channels.tags.yourTags")} - {userTags.map((tg) => ( - { + const active = tagFilter.includes(tg.id); + return ( + + ); + })} + {tagFilter.length > 0 && ( + + )} + + ))} +
+

{t("channels.blocked.hint")}

+ + )} ); @@ -609,8 +704,20 @@ function TagsCell({ }) { const { t } = useTranslation(); 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(null); + const btnRef = useRef(null); 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; // 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 @@ -629,7 +736,8 @@ function TagsCell({ ))} {open && ( -
+
{userTags.map((tg) => { const on = c.tag_ids.includes(tg.id); return ( diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 0223735..3288e26 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowDown, ArrowUp, ArrowLeft, RefreshCw, Youtube } from "lucide-react"; +import { ArrowDown, ArrowUp, ArrowLeft, Info, RefreshCw, Trash2, Youtube } from "lucide-react"; import { api, HttpError, type FeedFilters, type Video } from "../lib/api"; import i18n from "../i18n"; import { notify, resolveVideo } from "../lib/notifications"; @@ -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 // (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_MAP: Record, { asc: string; desc: string }> = { +const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"]; +const SORT_MAP: Record, { asc: string; desc: string }> = { date: { desc: "newest", asc: "oldest" }, popular: { desc: "views", asc: "views_asc" }, duration: { desc: "duration_desc", asc: "duration_asc" }, @@ -33,11 +35,11 @@ const SORT_MAP: Record, { asc: string; desc: string subscribers: { desc: "subscribers", asc: "subscribers_asc" }, priority: { desc: "priority", asc: "priority_asc" }, }; -const SORT_DEFAULT_DIR: Record, "asc" | "desc"> = { +const SORT_DEFAULT_DIR: Record, "asc" | "desc"> = { date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "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)[]) { if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" }; 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" }; } 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 { @@ -74,6 +77,8 @@ export default function Feed({ ytSearch, onYtSearch, onExitYtSearch, + onOpenChannel, + channelScoped, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; @@ -88,8 +93,17 @@ export default function Feed({ ytSearch: string | null; onYtSearch: (q: string) => 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 sortKeys = channelScoped + ? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority") + : SORT_KEYS; const [overrides, setOverrides] = useState>({}); const [savedOverrides, setSavedOverrides] = useState>({}); // 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; + // How many live-search results to gather (the count selector replaces a manual "load more"; + // the free scrape source pages through continuations until this many are collected). + const [ytCount, setYtCount] = useState(20); // Debounce only the search term feeding the queries: the input still updates instantly (it // owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch — @@ -125,8 +142,8 @@ export default function Feed({ // retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a // revisited search from re-spending. const ytQuery = useInfiniteQuery({ - queryKey: ["yt-search", ytSearch], - queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null), + queryKey: ["yt-search", ytSearch, ytCount], + queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount), initialPageParam: null as string | null, getNextPageParam: (last) => last.next_cursor ?? undefined, enabled: ytActive, @@ -134,6 +151,30 @@ export default function Feed({ 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 // 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 @@ -242,11 +283,11 @@ export default function Feed({ [qc] ); - const onChannelFilter = useCallback( + const handleOpenChannel = useCallback( (channelId: string, channelName: string) => { - setFilters({ ...filters, channelId, channelName }); + onOpenChannel?.(channelId, channelName); }, - [filters, setFilters] + [onOpenChannel] ); const onToggleSave = useCallback( @@ -290,30 +331,75 @@ export default function Feed({ return (
-
- -
@@ -412,7 +499,7 @@ export default function Feed({ const toolbar = (
-
+
{SHOW_IDS.map((id) => (
-
- +
); @@ -547,7 +637,7 @@ export default function Feed({ view={view} onState={onState} onToggleSave={onToggleSave} - onChannelFilter={onChannelFilter} + onOpenChannel={handleOpenChannel} onResetState={onResetState} onOpen={openVideo} hasNextPage={!!hasNextPage} @@ -561,6 +651,7 @@ export default function Feed({ startAt={activeVideo.startAt} onClose={() => setActiveVideo(null)} onState={onState} + onOpenChannel={onOpenChannel} /> )} {isFetchingNextPage && ( diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index c1d8a9c..5291e92 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -67,7 +67,14 @@ export default function Header({ 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) => { // 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). diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index fc324db..973ada7 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -45,6 +45,7 @@ export default function PlayerModal({ startIndex, onClose, onState, + onOpenChannel, }: { video: Video; // 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; onClose: () => 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 qc = useQueryClient(); @@ -552,14 +556,38 @@ export default function PlayerModal({ {liveData?.author ?? ""} ) ) : ( - - {active.channel_title} - +
+ + + + +
)} {!navigated ? (
diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 394434a..ba13bfe 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -13,6 +13,7 @@ import { Pencil, Play, RadioTower, + Trash2, X, } from "lucide-react"; import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api"; @@ -354,6 +355,15 @@ export default function Scheduler() { mutationFn: (v: number) => api.updateMaintenanceBatch(v), onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }), }); + const purgeMut = useMutation({ + mutationFn: () => api.purgeDiscovery(), + onSuccess: (res) => { + const removed = + (res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0); + notify({ level: "success", message: t("scheduler.purgedDiscovery", { count: removed }) }); + qc.invalidateQueries({ queryKey: ["scheduler"] }); + }, + }); if (q.isLoading && !data) return
{t("scheduler.loading")}
; @@ -398,6 +408,16 @@ export default function Scheduler() { {t("scheduler.runAll")} + + +
); + } } } diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 5087913..4e55b35 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Bookmark, @@ -6,7 +6,6 @@ import { CheckCheck, Eye, EyeOff, - ListFilter, Play, RotateCcw, } from "lucide-react"; @@ -21,13 +20,11 @@ function Actions({ onState, onResetState, onToggleSave, - onChannelFilter, }: { video: Video; onState: (id: string, status: string) => void; onResetState?: (id: string) => void; onToggleSave: (id: string, saved: boolean) => void; - onChannelFilter?: (channelId: string, channelName: string) => void; }) { const { t } = useTranslation(); const act = (status: string) => (e: React.MouseEvent) => { @@ -97,19 +94,6 @@ function Actions({ )} - {onChannelFilter && ( - - )}
); } @@ -244,7 +228,7 @@ function VideoCard({ onState, onResetState, onToggleSave, - onChannelFilter, + onOpenChannel, onOpen, }: { video: Video; @@ -252,7 +236,7 @@ function VideoCard({ onState: (id: string, status: string) => void; onResetState?: (id: string) => 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; }) { 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(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 = ( openInApp(e, video, onOpen)} + title={titleClamped ? video.title ?? undefined : undefined} className="font-medium leading-snug line-clamp-2 hover:text-accent" > {video.title} @@ -303,18 +301,18 @@ function VideoCard({ - +
); } @@ -335,17 +333,17 @@ function VideoCard({ />
diff --git a/frontend/src/components/VirtualFeed.tsx b/frontend/src/components/VirtualFeed.tsx index 2048cd5..d7f368b 100644 --- a/frontend/src/components/VirtualFeed.tsx +++ b/frontend/src/components/VirtualFeed.tsx @@ -40,7 +40,7 @@ export interface VirtualFeedProps { view: "grid" | "list"; onState: (id: string, status: string) => void; onToggleSave: (id: string, saved: boolean) => void; - onChannelFilter: (channelId: string, channelName: string) => void; + onOpenChannel: (channelId: string, channelName: string) => void; onResetState: (id: string) => void; onOpen: (v: Video, startAt?: number | null) => void; hasNextPage: boolean; @@ -53,7 +53,7 @@ export default function VirtualFeed({ view, onState, onToggleSave, - onChannelFilter, + onOpenChannel, onResetState, onOpen, hasNextPage, @@ -149,7 +149,7 @@ export default function VirtualFeed({ view="grid" onState={onState} onToggleSave={onToggleSave} - onChannelFilter={onChannelFilter} + onOpenChannel={onOpenChannel} onResetState={onResetState} onOpen={onOpen} /> @@ -162,7 +162,7 @@ export default function VirtualFeed({ view="list" onState={onState} onToggleSave={onToggleSave} - onChannelFilter={onChannelFilter} + onOpenChannel={onOpenChannel} onResetState={onResetState} onOpen={onOpen} /> diff --git a/frontend/src/i18n/locales/de/channel.json b/frontend/src/i18n/locales/de/channel.json new file mode 100644 index 0000000..ffeac3c --- /dev/null +++ b/frontend/src/i18n/locales/de/channel.json @@ -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" +} diff --git a/frontend/src/i18n/locales/de/channels.json b/frontend/src/i18n/locales/de/channels.json index 93cafc2..d653eef 100644 --- a/frontend/src/i18n/locales/de/channels.json +++ b/frontend/src/i18n/locales/de/channels.json @@ -122,5 +122,10 @@ "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.", - "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." + } } diff --git a/frontend/src/i18n/locales/de/common.json b/frontend/src/i18n/locales/de/common.json index 512fe96..f5ca1be 100644 --- a/frontend/src/i18n/locales/de/common.json +++ b/frontend/src/i18n/locales/de/common.json @@ -15,5 +15,6 @@ "accessRequestsMessage": "{{count}} ausstehend — prüfe sie auf der Benutzer-Seite.", "accessRequestsReview": "Überprüfen", "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" } diff --git a/frontend/src/i18n/locales/de/config.json b/frontend/src/i18n/locales/de/config.json index 76e68f5..e830ef2 100644 --- a/frontend/src/i18n/locales/de/config.json +++ b/frontend/src/i18n/locales/de/config.json @@ -9,29 +9,98 @@ "quota": "Kontingent", "backfill": "Nachladen (Backfill)", "shorts": "Shorts-Prüfung", - "batch": "Stapelgrößen" + "batch": "Stapelgrößen", + "explore": "Kanal erkunden" }, "fields": { - "smtp_host": { "label": "SMTP-Host", "hint": "z. B. smtp.gmail.com" }, - "smtp_port": { "label": "SMTP-Port", "hint": "Üblicherweise 587 (STARTTLS)." }, - "smtp_user": { "label": "SMTP-Benutzername", "hint": "Das sendende Konto / der Login." }, - "smtp_from": { "label": "Absenderadresse", "hint": "z. B. Siftlode . Standard ist der Benutzername." }, - "smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." }, - "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." } + "smtp_host": { + "label": "SMTP-Host", + "hint": "z. B. smtp.gmail.com" + }, + "smtp_port": { + "label": "SMTP-Port", + "hint": "Üblicherweise 587 (STARTTLS)." + }, + "smtp_user": { + "label": "SMTP-Benutzername", + "hint": "Das sendende Konto / der Login." + }, + "smtp_from": { + "label": "Absenderadresse", + "hint": "z. B. Siftlode . Standard ist der Benutzername." + }, + "smtp_password": { + "label": "SMTP-Passwort", + "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." + }, + "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", "saving": "Speichern…", diff --git a/frontend/src/i18n/locales/de/feed.json b/frontend/src/i18n/locales/de/feed.json index b648197..0696650 100644 --- a/frontend/src/i18n/locales/de/feed.json +++ b/frontend/src/i18n/locales/de/feed.json @@ -21,7 +21,8 @@ "title": "Name", "subscribers": "Kanal-Abonnenten", "priority": "Kanal-Priorität", - "shuffle": "Überraschung" + "shuffle": "Überraschung", + "relevance": "Relevanz" }, "loadingMore": "Mehr wird geladen…", "hiddenNamed": "Ausgeblendet: „{{title}}”", @@ -32,7 +33,7 @@ "unwatch": "Nicht mehr als angesehen", "source": { "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", "all": "Mit Suchergebnissen", "only": "Nur Suchergebnisse" @@ -47,6 +48,12 @@ "noResults": "Keine YouTube-Videos gefunden.", "error": "YouTube-Suche fehlgeschlagen.", "loadMore": "Mehr laden (verbraucht Kontingent)", - "loadMoreFree": "Mehr laden" + "loadMoreFree": "Mehr laden", + "show": "Zeigen", + "ephemeralNote": "Diese Ergebnisse sind temporär — sie verschwinden automatisch, außer du siehst dir eines an oder speicherst es.", + "clearNow": "Jetzt löschen", + "cleared_one": "{{count}} Ergebnis gelöscht", + "cleared_other": "{{count}} Ergebnisse gelöscht", + "showHint": "Wie viele Ergebnisse auf einmal — Mehr laden holt einen weiteren Block dieser Größe." } } diff --git a/frontend/src/i18n/locales/de/quotaActions.json b/frontend/src/i18n/locales/de/quotaActions.json index 83cc503..7896ec4 100644 --- a/frontend/src/i18n/locales/de/quotaActions.json +++ b/frontend/src/i18n/locales/de/quotaActions.json @@ -13,5 +13,6 @@ "channels_subscribe": "Abonnieren", "channels_unsubscribe": "Abo beenden", "maintenance_revalidate": "Wartungs-Neuvalidierung", - "other": "Sonstiges" + "other": "Sonstiges", + "channels_explore": "Kanal erkunden" } diff --git a/frontend/src/i18n/locales/de/scheduler.json b/frontend/src/i18n/locales/de/scheduler.json index a7da5c2..8c68143 100644 --- a/frontend/src/i18n/locales/de/scheduler.json +++ b/frontend/src/i18n/locales/de/scheduler.json @@ -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.", "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.", - "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": { "rss_poll": "RSS-Abfrage (neue Uploads)", @@ -77,7 +78,8 @@ "subscriptions": "Abo-Resync", "playlist_sync": "YouTube-Wiedergabelisten-Sync", "maintenance": "Wartung + Validierung", - "demo_reset": "Demo-Konto-Reset" + "demo_reset": "Demo-Konto-Reset", + "explore_cleanup": "Bereinigung erkundeter Kanäle" }, "queue": { "recentPending": "Zu synchronisierende Kanäle", @@ -90,5 +92,8 @@ "shortsPendingHint": "Bereits angereicherte Videos, die noch auf die Shorts-Prüfung warten.", "liveRefresh": "Live zu aktualisieren", "liveRefreshHint": "Live-/bevorstehende Videos (und gerade beendete Streams ohne Dauer), bis sie sich stabilisieren." - } + }, + "purgeDiscovery": "Entdeckung leeren", + "purgeDiscoveryHint": "Alle nicht behaltenen Entdeckungsinhalte jetzt entfernen (von niemandem angesehene/gespeicherte Suchergebnisse und erkundete, nicht abonnierte Kanäle) — ohne Karenzzeit.", + "purgedDiscovery": "{{count}} Entdeckungselemente entfernt" } diff --git a/frontend/src/i18n/locales/en/channel.json b/frontend/src/i18n/locales/en/channel.json new file mode 100644 index 0000000..c295fb3 --- /dev/null +++ b/frontend/src/i18n/locales/en/channel.json @@ -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" +} diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index eef0389..5bd7c45 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -122,5 +122,10 @@ "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.", - "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." + } } diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 8082bbe..3091ff2 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -15,5 +15,6 @@ "accessRequestsMessage": "{{count}} pending — review them on the Users page.", "accessRequestsReview": "Review", "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" } diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json index ed003a9..df49ea3 100644 --- a/frontend/src/i18n/locales/en/config.json +++ b/frontend/src/i18n/locales/en/config.json @@ -9,29 +9,98 @@ "quota": "Quota", "backfill": "Backfill", "shorts": "Shorts probe", - "batch": "Batch sizes" + "batch": "Batch sizes", + "explore": "Channel explore" }, "fields": { - "smtp_host": { "label": "SMTP host", "hint": "e.g. smtp.gmail.com" }, - "smtp_port": { "label": "SMTP port", "hint": "Usually 587 (STARTTLS)." }, - "smtp_user": { "label": "SMTP username", "hint": "The sending account / login." }, - "smtp_from": { "label": "From address", "hint": "e.g. Siftlode . Defaults to the username." }, - "smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." }, - "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." } + "smtp_host": { + "label": "SMTP host", + "hint": "e.g. smtp.gmail.com" + }, + "smtp_port": { + "label": "SMTP port", + "hint": "Usually 587 (STARTTLS)." + }, + "smtp_user": { + "label": "SMTP username", + "hint": "The sending account / login." + }, + "smtp_from": { + "label": "From address", + "hint": "e.g. Siftlode . Defaults to the username." + }, + "smtp_password": { + "label": "SMTP password", + "hint": "App password. Stored encrypted; write-only — it's never shown back." + }, + "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", "saving": "Saving…", diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json index 30be33d..6781ba1 100644 --- a/frontend/src/i18n/locales/en/feed.json +++ b/frontend/src/i18n/locales/en/feed.json @@ -21,7 +21,8 @@ "title": "Name", "subscribers": "Channel subscribers", "priority": "Channel priority", - "shuffle": "Surprise me" + "shuffle": "Surprise me", + "relevance": "Relevance" }, "loadingMore": "Loading more…", "hiddenNamed": "Hidden “{{title}}”", @@ -32,7 +33,7 @@ "unwatch": "Unwatch", "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", "all": "Include search results", "only": "Search results only" @@ -47,6 +48,12 @@ "noResults": "No YouTube videos found.", "error": "YouTube search failed.", "loadMore": "Load more (uses quota)", - "loadMoreFree": "Load more" + "loadMoreFree": "Load more", + "show": "Show", + "ephemeralNote": "These results are temporary — they clear automatically unless you watch or save one.", + "clearNow": "Clear now", + "cleared_one": "Cleared {{count}} result", + "cleared_other": "Cleared {{count}} results", + "showHint": "How many results to fetch at a time — Load more pulls another batch of this size." } } diff --git a/frontend/src/i18n/locales/en/quotaActions.json b/frontend/src/i18n/locales/en/quotaActions.json index 1626398..2d8d98e 100644 --- a/frontend/src/i18n/locales/en/quotaActions.json +++ b/frontend/src/i18n/locales/en/quotaActions.json @@ -13,5 +13,6 @@ "channels_subscribe": "Subscribe", "channels_unsubscribe": "Unsubscribe", "maintenance_revalidate": "Maintenance re-validation", - "other": "Other" + "other": "Other", + "channels_explore": "Channel explore" } diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index fabe864..9106858 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -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.", "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.", - "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": { "rss_poll": "RSS poll (new uploads)", @@ -77,7 +78,8 @@ "subscriptions": "Subscription resync", "playlist_sync": "YouTube playlist sync", "maintenance": "Maintenance + validation", - "demo_reset": "Demo account reset" + "demo_reset": "Demo account reset", + "explore_cleanup": "Explored-channel cleanup" }, "queue": { "recentPending": "Channels to sync", @@ -90,5 +92,8 @@ "shortsPendingHint": "Enriched videos still awaiting the Shorts probe.", "liveRefresh": "Live to refresh", "liveRefreshHint": "Live/upcoming videos (and just-ended streams without a duration yet) re-checked until they settle." - } + }, + "purgeDiscovery": "Purge discovery", + "purgeDiscoveryHint": "Remove all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.", + "purgedDiscovery": "Removed {{count}} discovery items" } diff --git a/frontend/src/i18n/locales/hu/channel.json b/frontend/src/i18n/locales/hu/channel.json new file mode 100644 index 0000000..6357dfe --- /dev/null +++ b/frontend/src/i18n/locales/hu/channel.json @@ -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" +} diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 6b9fa1e..5ff911f 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -122,5 +122,10 @@ "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.", - "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." + } } diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index c004de7..b5b9efb 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -15,5 +15,6 @@ "accessRequestsMessage": "{{count}} függőben — nézd át a Felhasználók oldalon.", "accessRequestsReview": "Áttekintés", "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" } diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index 4122383..b56fdcf 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -9,29 +9,98 @@ "quota": "Kvóta", "backfill": "Letöltés (backfill)", "shorts": "Shorts-vizsgálat", - "batch": "Kötegméretek" + "batch": "Kötegméretek", + "explore": "Csatorna-felfedezés" }, "fields": { - "smtp_host": { "label": "SMTP-kiszolgáló", "hint": "pl. smtp.gmail.com" }, - "smtp_port": { "label": "SMTP-port", "hint": "Általában 587 (STARTTLS)." }, - "smtp_user": { "label": "SMTP-felhasználónév", "hint": "A küldő fiók / bejelentkezés." }, - "smtp_from": { "label": "Feladó cím", "hint": "pl. Siftlode . 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." }, - "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." } + "smtp_host": { + "label": "SMTP-kiszolgáló", + "hint": "pl. smtp.gmail.com" + }, + "smtp_port": { + "label": "SMTP-port", + "hint": "Általában 587 (STARTTLS)." + }, + "smtp_user": { + "label": "SMTP-felhasználónév", + "hint": "A küldő fiók / bejelentkezés." + }, + "smtp_from": { + "label": "Feladó cím", + "hint": "pl. Siftlode . 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." + }, + "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", "saving": "Mentés…", diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json index 04b2b05..54e02b9 100644 --- a/frontend/src/i18n/locales/hu/feed.json +++ b/frontend/src/i18n/locales/hu/feed.json @@ -21,7 +21,8 @@ "title": "Név", "subscribers": "Csatorna feliratkozók", "priority": "Csatorna prioritás", - "shuffle": "Meglepetés" + "shuffle": "Meglepetés", + "relevance": "Relevancia" }, "loadingMore": "Továbbiak betöltése…", "hiddenNamed": "Elrejtve: „{{title}}”", @@ -32,7 +33,7 @@ "unwatch": "Megnézés visszavonása", "source": { "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", "all": "Kereséssel együtt", "only": "Csak keresésből" @@ -47,6 +48,12 @@ "noResults": "Nincs YouTube-találat.", "error": "A YouTube-keresés nem sikerült.", "loadMore": "Továbbiak betöltése (kvótát fogyaszt)", - "loadMoreFree": "Továbbiak betöltése" + "loadMoreFree": "Továbbiak betöltése", + "show": "Mutat", + "ephemeralNote": "Ezek a találatok ideiglenesek — maguktól eltűnnek, hacsak meg nem nézel vagy el nem mentesz egyet.", + "clearNow": "Törlés most", + "cleared_one": "{{count}} találat törölve", + "cleared_other": "{{count}} találat törölve", + "showHint": "Hány találatot szerezzen egyszerre — a Több betöltése ennyivel tölt tovább." } } diff --git a/frontend/src/i18n/locales/hu/quotaActions.json b/frontend/src/i18n/locales/hu/quotaActions.json index 92f0030..bd68866 100644 --- a/frontend/src/i18n/locales/hu/quotaActions.json +++ b/frontend/src/i18n/locales/hu/quotaActions.json @@ -13,5 +13,6 @@ "channels_subscribe": "Feliratkozás", "channels_unsubscribe": "Leiratkozás", "maintenance_revalidate": "Karbantartó újraellenőrzés", - "other": "Egyéb" + "other": "Egyéb", + "channels_explore": "Csatorna-felfedezés" } diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 5e35c7a..dc90f61 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -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.", "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.", - "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": { "rss_poll": "RSS-lekérdezés (új feltöltések)", @@ -77,7 +78,8 @@ "subscriptions": "Feliratkozások újraszinkronja", "playlist_sync": "YouTube lejátszási listák szinkronja", "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": { "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.", "liveRefresh": "Frissítendő élő", "liveRefreshHint": "Élő/közelgő videók (és a frissen véget ért, hossz nélküli adások), amíg le nem ülepednek." - } + }, + "purgeDiscovery": "Felfedezés ürítése", + "purgeDiscoveryHint": "Most eltávolítja az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.", + "purgedDiscovery": "{{count}} felfedezés-elem eltávolítva" } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1ae261d..b77c16b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -76,6 +76,38 @@ export interface VideoDetail { 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 { items: Video[]; 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 // popping the global modal. `cursor` is the next-page token (an InnerTube continuation token // in scrape mode, or a Data API pageToken in api mode); the response's `source` says which. - searchYoutube: (q: string, cursor: string | null): Promise => { + searchYoutube: (q: string, cursor: string | null, limit?: number): Promise => { const p = new URLSearchParams({ q }); if (cursor) p.set("cursor", cursor); + if (limit) p.set("limit", String(limit)); return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true }); }, + // Discard a set of live-search results "as if never added" (drops your search-finds + deletes + // the now-orphaned, un-kept videos). Returns how many videos were removed. + clearSearch: (videoIds: string[]): Promise<{ removed: number }> => + req("/api/search/clear", { method: "POST", body: JSON.stringify({ video_ids: videoIds }) }), facets: (f: FeedFilters): Promise<{ counts: Record }> => req(`/api/facets?${filterParams(f).toString()}`), // idempotent: setting a state / saving a progress checkpoint is safe to replay, so these @@ -628,6 +665,29 @@ export const api = { subscribeChannel: (id: string) => req(`/api/channels/${id}/subscribe`, { method: "POST" }), syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), + // --- channel page (About detail + ephemeral exploration of un-subscribed channels) --- + channelDetail: (id: string): Promise => 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 => { + 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> => + req("/api/admin/purge-discovery", { method: "POST" }), // --- playlists --- playlists: (containsVideoId?: string): Promise => diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 46362c8..270fff2 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -66,9 +66,17 @@ export function useBackToClose(onClose: () => void): void { const idx = overlayStack.lastIndexOf(token); if (idx !== -1) { // 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); suppressPop = true; + const clearSuppress = () => { + suppressPop = false; + window.removeEventListener("popstate", clearSuppress); + }; + window.addEventListener("popstate", clearSuppress); window.history.back(); } }; diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index b70ab1b..c06ca0a 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,23 @@ export interface 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", date: "2026-06-29",