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,
},
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b777c3a..73d36d3 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -28,6 +28,8 @@ import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed";
+import ChannelPage from "./components/ChannelPage";
+import BackToTop from "./components/BackToTop";
import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels";
import Playlists from "./components/Playlists";
import Stats from "./components/Stats";
@@ -140,6 +142,27 @@ export default function App() {
if (window.history.state?._yt) window.history.back();
else setYtSearch(null);
}, []);
+ // The open channel page (null = no channel page). Like the YouTube-search sub-view it owns a
+ // browser-history entry (history.state._chan = channel id, _chanName = a display name to show
+ // before the detail loads), so Back closes the channel page (after any player opened over it)
+ // and reload returns to the normal app. Channels/videos are reached by id, so it's not in the URL.
+ const [channelView, setChannelView] = useState<{ id: string; name?: string } | null>(() => {
+ // Restore the open channel page across a reload (it rides in history.state, which survives F5),
+ // unlike the YouTube search which is intentionally dropped (it would re-spend quota).
+ const c = window.history.state?._chan as string | undefined;
+ return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null;
+ });
+ const openChannel = useCallback((id: string, name?: string) => {
+ setChannelView({ id, name });
+ const st = window.history.state || {};
+ if (st._chan)
+ window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "");
+ else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "");
+ }, []);
+ const closeChannel = useCallback(() => {
+ if (window.history.state?._chan) window.history.back();
+ else setChannelView(null);
+ }, []);
const [wizardOpen, setWizardOpen] = useState(false);
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all");
@@ -186,14 +209,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.
@@ -247,6 +273,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.
+ // Drop a stale _yt (a reload starts on the normal feed; a search would re-spend quota), but
+ // KEEP _chan/_chanName so the open channel page survives F5 (channelView restores from it above).
const { _yt: _staleYt, ...rest } = window.history.state || {};
window.history.replaceState({ ...rest, sfPage: page }, "");
function onPop(e: PopStateEvent) {
@@ -273,6 +301,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 +549,18 @@ export default function App() {
language={i18n.language as LangCode}
/>
+ >
+ )}
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
Anchored inside the content column so they clear the sidebar automatically
(collapsed or expanded) without tracking its width. */}
@@ -625,6 +668,8 @@ export default function App() {
setNotesOpen(false)} highlight={notesHighlight} />
)}
+ {/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
+
);
}
diff --git a/frontend/src/components/BackToTop.tsx b/frontend/src/components/BackToTop.tsx
new file mode 100644
index 0000000..26bc792
--- /dev/null
+++ b/frontend/src/components/BackToTop.tsx
@@ -0,0 +1,46 @@
+import { useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { ArrowUp } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+// A floating "back to top" button that fades in once the current page's scroll container is
+// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside its
+// own (the normal pages share App's; the channel page has its own),
+// so it targets the live and re-binds whenever `dep` changes (i.e. on navigation). Portaled
+// to so its fixed position is viewport-relative regardless of transformed ancestors.
+export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; threshold?: number }) {
+ const { t } = useTranslation();
+ const [show, setShow] = useState(false);
+ const scrollerRef = useRef(null);
+
+ useEffect(() => {
+ const el = document.querySelector("main");
+ scrollerRef.current = el;
+ if (!el) {
+ setShow(false);
+ return;
+ }
+ const onScroll = () => setShow(el.scrollTop > threshold);
+ onScroll(); // a freshly-bound page starts at the top → hidden
+ el.addEventListener("scroll", onScroll, { passive: true });
+ return () => el.removeEventListener("scroll", onScroll);
+ }, [dep, threshold]);
+
+ const toTop = () => scrollerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
+
+ // No aria-label/title attributes: ad-block "annoyance" cosmetic filters (e.g. Brave Shields)
+ // commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
+ // The accessible name comes from a visually-hidden child instead, which those selectors can't match.
+ return createPortal(
+ ,
+ document.body
+ );
+}
diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx
new file mode 100644
index 0000000..fc856f7
--- /dev/null
+++ b/frontend/src/components/ChannelPage.tsx
@@ -0,0 +1,309 @@
+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"] });
+ // Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
+ // first visit (the GET that drove this render predated the explore that just ran).
+ qc.invalidateQueries({ queryKey: ["channel", channelId] });
+ })
+ .finally(() => setExploring(false));
+ },
+ [channelId, qc]
+ );
+
+ // First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo
+ // account (explore spends shared quota) and when videos are already present (a revisit, or a
+ // subscribed channel whose uploads the scheduler backfills).
+ useEffect(() => {
+ if (!ch || me.is_demo) return;
+ if (autoTried.current === channelId) return;
+ if (ch.subscribed) 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;
+ // Handle + stats on one compact meta line under the name (saves the separate stats row).
+ const metaParts = [
+ ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
+ ch?.subscriber_count != null ? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) }) : null,
+ ch?.video_count != null ? t("channel.videoCount", { count: ch.video_count }) : null,
+ ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
+ joined ? t("channel.joined", { date: joined }) : null,
+ ].filter(Boolean) as string[];
+ const tabClass = (active: boolean) =>
+ active
+ ? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
+ : "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
+
+ return (
+
+ {/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
+
+ {ch?.banner_url ? (
+ // Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
+ // low default size, so request a crisp wide version (=w1707) and object-cover the
+ // desktop "safe area" — the centre 2560×423 (~6:1) band.
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Identity + actions. relative+z-10 so the avatar that overlaps up into the banner
+ paints ABOVE it (the banner's positioned container would otherwise cover it). */}
+
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/common.json b/frontend/src/i18n/locales/de/common.json
index 512fe96..f5ca1be 100644
--- a/frontend/src/i18n/locales/de/common.json
+++ b/frontend/src/i18n/locales/de/common.json
@@ -15,5 +15,6 @@
"accessRequestsMessage": "{{count}} ausstehend — prüfe sie auf der Benutzer-Seite.",
"accessRequestsReview": "Überprüfen",
"demoTitle": "Demo-Konto",
- "demoSharedNotice": "Du bist im gemeinsamen Demo-Konto. Alles, was du hier tust — Playlists, ausgeblendete Videos, Einstellungen — ist gemeinsam und kann von anderen Demo-Besuchern gleichzeitig geändert werden."
+ "demoSharedNotice": "Du bist im gemeinsamen Demo-Konto. Alles, was du hier tust — Playlists, ausgeblendete Videos, Einstellungen — ist gemeinsam und kann von anderen Demo-Besuchern gleichzeitig geändert werden.",
+ "backToTop": "Nach oben"
}
diff --git a/frontend/src/i18n/locales/de/config.json b/frontend/src/i18n/locales/de/config.json
index 76e68f5..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/common.json b/frontend/src/i18n/locales/en/common.json
index 8082bbe..3091ff2 100644
--- a/frontend/src/i18n/locales/en/common.json
+++ b/frontend/src/i18n/locales/en/common.json
@@ -15,5 +15,6 @@
"accessRequestsMessage": "{{count}} pending — review them on the Users page.",
"accessRequestsReview": "Review",
"demoTitle": "Demo account",
- "demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time."
+ "demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.",
+ "backToTop": "Back to top"
}
diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json
index ed003a9..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/common.json b/frontend/src/i18n/locales/hu/common.json
index c004de7..b5b9efb 100644
--- a/frontend/src/i18n/locales/hu/common.json
+++ b/frontend/src/i18n/locales/hu/common.json
@@ -15,5 +15,6 @@
"accessRequestsMessage": "{{count}} függőben — nézd át a Felhasználók oldalon.",
"accessRequestsReview": "Áttekintés",
"demoTitle": "Demo fiók",
- "demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják."
+ "demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.",
+ "backToTop": "Vissza a tetejére"
}
diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json
index 4122383..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 =>