From bc4c3624239c0399cd4a06ac4adf6cdd874d48b1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 30 Jun 2026 02:52:44 +0200 Subject: [PATCH 01/14] =?UTF-8?q?feat(channels):=20channel-explore=20backe?= =?UTF-8?q?nd=20=E2=80=94=20about=20metadata,=20ephemeral=20provenance,=20?= =?UTF-8?q?cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the backend for a dedicated channel page + ephemeral browsing of un-subscribed channels: - migration 0033: channels.{total_view_count,published_at,banner_url,external_links, from_explore}, videos.via_explore, explored_channels (per-user, grace-clocked). - enrich channels with part=brandingSettings (banner/links) + statistics.viewCount + snippet.publishedAt — no extra quota. - GET /api/channels/{id}: About detail + this user's relationship; lazy-enriches the new About fields (published_at sentinel). - POST /api/channels/{id}/explore (require_human, quota-reserve guarded): records the exploration, flags the channel ephemeral unless followed, ingests one page of uploads (via_explore) + enriches; returns next_page_token for load-more. - feed: per-explorer gate — a via_explore video is visible only to users who explored or subscribe to its channel, so exploration never leaks into everyone's catalog. - subscribe = keep: clears from_explore + via_explore on the channel's videos. - scheduler explore_cleanup job + explore_grace_days config: hard-delete explored-but- unkept channels and their untouched ephemeral videos after a grace period. --- .../alembic/versions/0033_channel_explore.py | 83 ++++++++++ backend/app/config.py | 6 + backend/app/models.py | 47 ++++++ backend/app/quota.py | 1 + backend/app/routes/channels.py | 143 ++++++++++++++++- backend/app/routes/feed.py | 18 +++ backend/app/scheduler.py | 9 ++ backend/app/sync/explore.py | 145 ++++++++++++++++++ backend/app/sync/subscriptions.py | 28 ++++ backend/app/sysconfig.py | 3 + backend/app/youtube/client.py | 2 +- 11 files changed, 481 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/0033_channel_explore.py create mode 100644 backend/app/sync/explore.py 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/app/config.py b/backend/app/config.py index 78c1bd8..cdd2a2f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -118,6 +118,12 @@ 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 # 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 a35a10f..8c4ab52 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -165,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)) @@ -183,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") @@ -260,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 @@ -388,6 +410,31 @@ class SearchFind(Base, TimestampMixin): ) +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 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/channels.py b/backend/app/routes/channels.py index f1d00eb..6ffdd7f 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -5,15 +5,16 @@ 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 ( Channel, ChannelTag, + ExploredChannel, Playlist, PlaylistItem, Subscription, @@ -22,6 +23,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 +207,133 @@ def discover_channels( ] +def _channel_detail_dict( + channel: Channel, *, subscribed: bool, explored: 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, + "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 + stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0 + return _channel_detail_dict( + channel, subscribed=subscribed, explored=explored, stored=int(stored) + ) + + +@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 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 +461,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} diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 528a42e..5969edb 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -16,6 +16,7 @@ from app.db import get_db from app.models import ( Channel, ChannelTag, + ExploredChannel, Playlist, PlaylistItem, SearchFind, @@ -180,6 +181,23 @@ def _filtered_query( # either way they shouldn't show in any feed view meanwhile. query = query.where(Video.unavailable_since.is_(None)) + # 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), + ) + ) + # 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, diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index fd8de99..b39bd95 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_unkept_explored 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: + # Hard-delete explored-but-never-kept channels and their untouched ephemeral videos after + # the grace period. 0 quota (pure DB), so no quota attribution. + _job("explore_cleanup", purge_unkept_explored) + + # 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..7257862 --- /dev/null +++ b/backend/app/sync/explore.py @@ -0,0 +1,145 @@ +"""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, + } 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/sysconfig.py b/backend/app/sysconfig.py index c6d8004..133b0bb 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -51,6 +51,9 @@ SPECS: tuple[ConfigSpec, ...] = ( # --- Batch sizes --- ConfigSpec("enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50), ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000), + # --- Channel explore --- + # 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), # --- 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, }, From cc1e670202ae0d1041594bab2368c42446034f5d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 30 Jun 2026 03:08:52 +0200 Subject: [PATCH 02/14] feat(channels): dedicated channel page + ephemeral explore UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend for the channel-explore feature: - ChannelPage: banner/avatar/stats header, Subscribe/unsubscribe, an "exploring" badge while browsing an un-subscribed channel, Videos/About tabs. Reuses Feed scoped to the channel (scope=all + source=all so the per-user ephemeral videos show). Auto-ingests recent uploads on first visit (background, with a loading note) + "Load more from YouTube" to page deeper; skipped for demo / already-subscribed channels. - App: openChannel/closeChannel as a Back-aware sub-view (history.state._chan, mirrors the YT-search _yt pattern); ChannelPage takes over the content column, nav rail stays. - ChannelLink/cards/player: the channel name now opens our channel page (onChannelFilter → onOpenChannel); the in-card "only this channel" filter button is dropped (the page subsumes it). PlayerModal channel-name wiring follows in the next commit. - api: channelDetail + exploreChannel; ChannelDetail/ExploreResult types. - i18n EN/HU/DE: channel namespace, explore_cleanup scheduler labels, explore config group, channels_explore quota label. --- frontend/src/App.tsx | 43 ++- frontend/src/components/ChannelPage.tsx | 293 ++++++++++++++++++ frontend/src/components/Feed.tsx | 14 +- frontend/src/components/VideoCard.tsx | 52 ++-- frontend/src/components/VirtualFeed.tsx | 8 +- frontend/src/i18n/locales/de/channel.json | 20 ++ frontend/src/i18n/locales/de/config.json | 107 +++++-- .../src/i18n/locales/de/quotaActions.json | 3 +- frontend/src/i18n/locales/de/scheduler.json | 6 +- frontend/src/i18n/locales/en/channel.json | 20 ++ frontend/src/i18n/locales/en/config.json | 107 +++++-- .../src/i18n/locales/en/quotaActions.json | 3 +- frontend/src/i18n/locales/en/scheduler.json | 6 +- frontend/src/i18n/locales/hu/channel.json | 20 ++ frontend/src/i18n/locales/hu/config.json | 107 +++++-- .../src/i18n/locales/hu/quotaActions.json | 3 +- frontend/src/i18n/locales/hu/scheduler.json | 6 +- frontend/src/lib/api.ts | 46 +++ 18 files changed, 744 insertions(+), 120 deletions(-) create mode 100644 frontend/src/components/ChannelPage.tsx create mode 100644 frontend/src/i18n/locales/de/channel.json create mode 100644 frontend/src/i18n/locales/en/channel.json create mode 100644 frontend/src/i18n/locales/hu/channel.json diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b777c3a..5c4b503 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,7 @@ 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 Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels"; import Playlists from "./components/Playlists"; import Stats from "./components/Stats"; @@ -140,6 +141,22 @@ 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>(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"); @@ -247,7 +264,8 @@ export default function App() { 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 || {}; + const { _yt: _staleYt, _chan: _staleChan, _chanName: _staleChanName, ...rest } = + window.history.state || {}; window.history.replaceState({ ...rest, sfPage: page }, ""); function onPop(e: PopStateEvent) { const p = (e.state?.sfPage as Page) ?? "feed"; @@ -273,6 +291,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 +539,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 +629,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. */} diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx new file mode 100644 index 0000000..fef3c01 --- /dev/null +++ b/frontend/src/components/ChannelPage.tsx @@ -0,0 +1,293 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, Check, ExternalLink, Loader2, Plus, RefreshCw } from "lucide-react"; +import 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"] }); + }) + .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) return; + if (ch.explored && ch.stored_videos > 0) return; + autoTried.current = channelId; + runExplore().catch(() => {}); + }, [ch, channelId, me.is_demo, runExplore]); + + 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; + 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 */} +
+ {ch?.banner_url ? ( +
+ ) : ( +
+ )} + +
+ +
+ {/* Identity + actions */} +
+ +
+
+

{name}

+ {ch?.explored && !ch?.subscribed && ( + + {t("channel.exploringBadge")} + + )} +
+ {ch?.handle && ( +
@{ch.handle.replace(/^@/, "")}
+ )} +
+
+ + + + {!me.is_demo && + (ch?.subscribed ? ( + + ) : ( + + ))} +
+
+ + {/* Stats */} +
+ {ch?.subscriber_count != null && ( + {t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) })} + )} + {ch?.video_count != null && · {t("channel.videoCount", { count: ch.video_count })}} + {ch?.total_view_count != null && ( + · {t("channel.totalViews", { formatted: formatViews(ch.total_view_count) })} + )} + {joined && · {t("channel.joined", { date: joined })}} +
+ {exploring && ( +
+ + {t("channel.loadingVideos")} +
+ )} + + {/* Tabs */} +
+ + +
+
+ + {/* Tab content */} + {tab === "videos" ? ( + <> + {}} + ytSearch={null} + onYtSearch={() => {}} + onExitYtSearch={() => {}} + onOpenChannel={onOpenChannel} + /> + {!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/Feed.tsx b/frontend/src/components/Feed.tsx index 7205633..8a1062b 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -77,6 +77,7 @@ export default function Feed({ ytSearch, onYtSearch, onExitYtSearch, + onOpenChannel, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; @@ -91,6 +92,9 @@ 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; }) { const { t } = useTranslation(); const [overrides, setOverrides] = useState>({}); @@ -257,11 +261,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( @@ -347,7 +351,7 @@ export default function Feed({ view={view} onState={onState} onToggleSave={onToggleSave} - onChannelFilter={onChannelFilter} + onOpenChannel={handleOpenChannel} onResetState={onResetState} onOpen={openVideo} hasNextPage={false} @@ -568,7 +572,7 @@ export default function Feed({ view={view} onState={onState} onToggleSave={onToggleSave} - onChannelFilter={onChannelFilter} + onOpenChannel={handleOpenChannel} onResetState={onResetState} onOpen={openVideo} hasNextPage={!!hasNextPage} diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 5087913..541c86e 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -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(); @@ -303,18 +287,18 @@ function VideoCard({ - + ); } @@ -335,17 +319,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..c302589 --- /dev/null +++ b/frontend/src/i18n/locales/de/channel.json @@ -0,0 +1,20 @@ +{ + "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." +} diff --git a/frontend/src/i18n/locales/de/config.json b/frontend/src/i18n/locales/de/config.json index 76e68f5..e799cd9 100644 --- a/frontend/src/i18n/locales/de/config.json +++ b/frontend/src/i18n/locales/de/config.json @@ -9,29 +9,94 @@ "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." + } }, "save": "Speichern", "saving": "Speichern…", 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..29ac42e 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", diff --git a/frontend/src/i18n/locales/en/channel.json b/frontend/src/i18n/locales/en/channel.json new file mode 100644 index 0000000..d7c006e --- /dev/null +++ b/frontend/src/i18n/locales/en/channel.json @@ -0,0 +1,20 @@ +{ + "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." +} diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json index ed003a9..40fec51 100644 --- a/frontend/src/i18n/locales/en/config.json +++ b/frontend/src/i18n/locales/en/config.json @@ -9,29 +9,94 @@ "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." + } }, "save": "Save", "saving": "Saving…", 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..4979b2d 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", diff --git a/frontend/src/i18n/locales/hu/channel.json b/frontend/src/i18n/locales/hu/channel.json new file mode 100644 index 0000000..5c79491 --- /dev/null +++ b/frontend/src/i18n/locales/hu/channel.json @@ -0,0 +1,20 @@ +{ + "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." +} diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index 4122383..3cd0fdc 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -9,29 +9,94 @@ "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." + } }, "save": "Mentés", "saving": "Mentés…", 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..6854a0f 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", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1ae261d..0be8293 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -76,6 +76,37 @@ 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; + 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; @@ -628,6 +659,21 @@ 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 } + ); + }, // --- playlists --- playlists: (containsVideoId?: string): Promise => From 641fc393a74ff060d3bc31ccbe917f5ad3b7740d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 30 Jun 2026 03:12:44 +0200 Subject: [PATCH 03/14] feat(player): channel name opens the in-app channel page The PlayerModal channel name now opens our channel page (closing the player first); a small external-link icon beside it keeps the open-on-YouTube behaviour. Threaded onOpenChannel from Feed into both PlayerModal mounts. --- frontend/src/components/Feed.tsx | 1 + frontend/src/components/PlayerModal.tsx | 33 +++++++++++++++++++------ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 8a1062b..4d097ee 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -382,6 +382,7 @@ export default function Feed({ startAt={activeVideo.startAt} onClose={() => setActiveVideo(null)} onState={onState} + onOpenChannel={onOpenChannel} /> )} diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index fc324db..056dd42 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,27 @@ export default function PlayerModal({ {liveData?.author ?? ""} ) ) : ( - - {active.channel_title} - +
+ + + + +
)} {!navigated ? (
From 419231bddbf5bb083d2749568a17c0ab772df935 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 30 Jun 2026 04:14:14 +0200 Subject: [PATCH 04/14] fix(player): open the channel page from the player without it bouncing back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs made clicking the channel name in the video modal just close the player: - the second PlayerModal mount (main feed path) was missing onOpenChannel, so the handler hit its no-op early return; - opening the channel synchronously pushed the _chan history entry, which the player's own useBackToClose teardown (history.back on unmount) then immediately popped. Now the open is deferred to a one-shot popstate listener that fires AFTER that teardown, so the channel entry lands at the feed level. Verified: player → channel name → channel page; Back → feed (player does not reappear). --- frontend/src/components/Feed.tsx | 1 + frontend/src/components/PlayerModal.tsx | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 4d097ee..f7fc781 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -587,6 +587,7 @@ export default function Feed({ startAt={activeVideo.startAt} onClose={() => setActiveVideo(null)} onState={onState} + onOpenChannel={onOpenChannel} /> )} {isFetchingNextPage && ( diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 056dd42..973ada7 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -559,8 +559,19 @@ export default function PlayerModal({
- {/* Identity + actions */} -
+ {/* 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). */} +
Date: Tue, 30 Jun 2026 04:32:43 +0200 Subject: [PATCH 07/14] fix(channel): render the banner wide like YouTube MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stored bannerExternalUrl is the full 16:9 banner template at a low default size (512x288), so object-contain showed the whole padded image tiny and centered. Now request a crisp wide version (=w1707) and object-cover the desktop safe-area band (centre 2560x423, ~6:1) at full width — matching YouTube's banner crop. --- frontend/src/components/ChannelPage.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 25906c7..4488d5b 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -133,13 +133,14 @@ export default function ChannelPage({ {/* Banner + back */}
{ch?.banner_url ? ( - // Keep the banner's real aspect ratio (object-contain, capped height); letterbox with - // the surface colour rather than stretching/cropping it to fill. -
+ // 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 — at full width. +
) : ( From c59b53a51b556bf3980cde5def6e07b171169875 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 30 Jun 2026 04:37:00 +0200 Subject: [PATCH 08/14] fix(channel): drop channel-constant sort options on the channel page Subscribers and Channel-priority sorts are meaningless when the feed is scoped to one channel (both are constant across its videos), so hide them there via a channelScoped flag on Feed. The main feed keeps all sorts. --- frontend/src/components/ChannelPage.tsx | 1 + frontend/src/components/Feed.tsx | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 4488d5b..122772b 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -255,6 +255,7 @@ export default function ChannelPage({ onYtSearch={() => {}} onExitYtSearch={() => {}} onOpenChannel={onOpenChannel} + channelScoped /> {!ch?.subscribed && nextToken && (
diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index f7fc781..345a485 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -78,6 +78,7 @@ export default function Feed({ onYtSearch, onExitYtSearch, onOpenChannel, + channelScoped, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; @@ -95,8 +96,14 @@ export default function Feed({ // 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). @@ -519,7 +526,7 @@ export default function Feed({ className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent" > {/* Relevance only ranks meaningfully when there's a search term — offer it then. */} - {(filters.q.trim() ? (["relevance", ...SORT_KEYS] as SortKey[]) : SORT_KEYS).map((k) => ( + {(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => ( From 50a77745885015aa76f04d7a996c6dd722ec742e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 30 Jun 2026 04:43:37 +0200 Subject: [PATCH 09/14] =?UTF-8?q?polish(channel):=20compact=20header=20?= =?UTF-8?q?=E2=80=94=20stats=20inline,=20slimmer=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the channel stats (subscribers / videos / views / joined) onto one meta line beside the handle under the name, dropping the separate stats row. - Cap the banner at 150px tall so it no longer dominates the page; the wide YouTube-style crop is kept. Net: the video grid starts much higher. --- frontend/src/components/ChannelPage.tsx | 37 ++++++++++++++----------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 122772b..1572aa7 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -123,6 +123,14 @@ export default function ChannelPage({ 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" @@ -136,7 +144,10 @@ export default function ChannelPage({ // 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 — at full width. -
+
)}
- {ch?.handle && ( -
@{ch.handle.replace(/^@/, "")}
- )} +
+ {metaParts.map((part, i) => ( + + {i > 0 && ·} + {part} + + ))} +
- {/* Stats */} -
- {ch?.subscriber_count != null && ( - {t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) })} - )} - {ch?.video_count != null && · {t("channel.videoCount", { count: ch.video_count })}} - {ch?.total_view_count != null && ( - · {t("channel.totalViews", { formatted: formatViews(ch.total_view_count) })} - )} - {joined && · {t("channel.joined", { date: joined })}} -
{exploring && (
@@ -231,7 +236,7 @@ export default function ChannelPage({ )} {/* Tabs */} -
+
From a6c4888e92b15b36517a024440fdf1e7a5d49c7d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 30 Jun 2026 04:47:54 +0200 Subject: [PATCH 10/14] fix(channel): let the nav rail navigate away from a channel page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel page overlays the content column via channelView, which setPage didn't clear — so clicking a rail item did nothing (and the next===page early-return blocked 'Feed' when a channel was opened from the feed). setPage now clears channelView and proceeds even when the underlying page is unchanged. --- frontend/src/App.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 882b168..62cd479 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -208,14 +208,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. From 5e588d15ffa297f184827e860e720bc01eec47f9 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 30 Jun 2026 04:48:46 +0200 Subject: [PATCH 11/14] polish(channel): inset, rounded banner card (option C) Instead of a full-bleed banner, inset it with a margin and rounded corners so it reads as a contained card; the avatar overlaps its bottom-left. Airier and more designed than edge-to-edge. --- frontend/src/components/ChannelPage.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 1572aa7..fc856f7 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -138,14 +138,14 @@ export default function ChannelPage({ return (
- {/* Banner + back */} -
+ {/* 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 — at full width. + // desktop "safe area" — the centre 2560×423 (~6:1) band.
) : ( -
+
)}
-
- +