Merge: channel page + ephemeral exploration of un-subscribed channels

Dedicated channel page (inset banner, inline-stats header, Videos/About tabs) reusing the feed
scoped to one channel; ephemeral per-user browsing of un-subscribed channels (auto-ingest recent
uploads flagged via_explore, kept private to the explorer until they subscribe; grace-period
cleanup job). Channel name opens the in-app page everywhere (cards + player), Back-aware and
F5-persistent. Backend: about-metadata enrich, explore provenance + per-user feed gate,
subscribe=keep, explore_cleanup scheduler job (migration 0033).

General feed/UI polish (app-wide): one-row toolbar (count after Source, sort right-aligned),
channel-scoped sort options, nav-rail closes the channel page, and a filter-resistant floating
back-to-top button.
This commit is contained in:
npeter83 2026-06-30 05:20:06 +02:00
commit 72ae936958
34 changed files with 1358 additions and 143 deletions

View file

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

View file

@ -118,6 +118,12 @@ class Settings(BaseSettings):
# items). Deleted/private/paywalled get a recovery window; abandoned upcoming and # items). Deleted/private/paywalled get a recovery window; abandoned upcoming and
# confirmed-dead stuck-live have no age grace (deleted on the sweep that confirms them). # confirmed-dead stuck-live have no age grace (deleted on the sweep that confirms them).
maintenance_grace_days: int = 7 maintenance_grace_days: int = 7
# --- Channel-explore cleanup job ---
# Runs daily by default (admin-tunable via the Scheduler dashboard). An explored-but-never-
# kept channel (no subscription, no per-user state on its videos) is hard-deleted this many
# days after it was last explored — the grace clock is ExploredChannel.created_at.
explore_cleanup_minutes: int = 1440
explore_grace_days: int = 7
# live_status values hidden from the feed by default. Completed-stream VODs # live_status values hidden from the feed by default. Completed-stream VODs
# ("was_live") are real watchable content and stay visible. # ("was_live") are real watchable content and stay visible.
feed_default_hidden_live: str = "live,upcoming" feed_default_hidden_live: str = "live,upcoming"

View file

@ -165,6 +165,12 @@ class Channel(Base, TimestampMixin):
uploads_playlist_id: Mapped[str | None] = mapped_column(String(64)) uploads_playlist_id: Mapped[str | None] = mapped_column(String(64))
subscriber_count: Mapped[int | None] = mapped_column(BigInteger) subscriber_count: Mapped[int | None] = mapped_column(BigInteger)
video_count: Mapped[int | None] = mapped_column(BigInteger) video_count: Mapped[int | None] = mapped_column(BigInteger)
# Channel "About" metadata (fetched with the same channels.list call, no extra quota):
# lifetime view count, the channel's join date, banner image, and external links.
total_view_count: Mapped[int | None] = mapped_column(BigInteger)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
banner_url: Mapped[str | None] = mapped_column(String(1024))
external_links: Mapped[list | None] = mapped_column(JSON)
topic_categories: Mapped[list | None] = mapped_column(JSON) topic_categories: Mapped[list | None] = mapped_column(JSON)
default_language: Mapped[str | None] = mapped_column(String(16)) default_language: Mapped[str | None] = mapped_column(String(16))
country: Mapped[str | None] = mapped_column(String(8)) country: Mapped[str | None] = mapped_column(String(8))
@ -183,6 +189,14 @@ class Channel(Base, TimestampMixin):
from_search: Mapped[bool] = mapped_column( from_search: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True Boolean, default=False, server_default="false", index=True
) )
# True when this channel exists only because a user EXPLORED it (opened its page without
# subscribing) — an ephemeral, per-explorer addition. The cleanup job hard-deletes such a
# channel (and its via_explore videos) once nobody keeps it (no subscription, no live
# ExploredChannel row, no per-user state) after a grace period. Cleared when someone
# subscribes (the "keep" signal promotes it to organic).
from_explore: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True
)
videos: Mapped[list["Video"]] = relationship(back_populates="channel") videos: Mapped[list["Video"]] = relationship(back_populates="channel")
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel") subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel")
@ -260,6 +274,14 @@ class Video(Base, TimestampMixin):
via_search: Mapped[bool] = mapped_column( via_search: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True Boolean, default=False, server_default="false", index=True
) )
# True when this video entered the catalog because a user EXPLORED its (un-subscribed)
# channel. Like via_search it's hidden from the shared Library by default, but it's
# additionally PER-USER private: a via_explore video is only visible to users who
# explored or subscribe to its channel (a gate in the feed query), so one user's
# curiosity never floods everyone else's catalog. Left False by the organic ingest paths.
via_explore: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True
)
# none | live | upcoming | premiere | was_live # none | live | upcoming | premiere | was_live
live_status: Mapped[str] = mapped_column( live_status: Mapped[str] = mapped_column(
String(16), default="none", server_default="none", index=True String(16), default="none", server_default="none", index=True
@ -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): class ApiQuotaUsage(Base):
"""Tracks YouTube Data API units spent per Pacific-time day (the quota resets at """Tracks YouTube Data API units spent per Pacific-time day (the quota resets at
midnight Pacific). The whole app shares one daily budget.""" midnight Pacific). The whole app shares one daily budget."""

View file

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

View file

@ -5,15 +5,16 @@ import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, func, select from sqlalchemy import and_, func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota from app import quota, sysconfig
from app.auth import current_user, has_write_scope from app.auth import current_user, has_write_scope, require_human
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import (
Channel, Channel,
ChannelTag, ChannelTag,
ExploredChannel,
Playlist, Playlist,
PlaylistItem, PlaylistItem,
Subscription, Subscription,
@ -22,6 +23,7 @@ from app.models import (
Video, Video,
) )
from app.routes.admin import admin_user from app.routes.admin import admin_user
from app.sync.explore import explore_ingest_page
from app.sync.runner import run_recent_backfill from app.sync.runner import run_recent_backfill
from app.sync.subscriptions import apply_channel_details from app.sync.subscriptions import apply_channel_details
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError
@ -205,6 +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: def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription:
sub = db.execute( sub = db.execute(
select(Subscription).where( select(Subscription).where(
@ -332,6 +461,14 @@ def subscribe(
subscribed_at=datetime.now(timezone.utc), subscribed_at=datetime.now(timezone.utc),
) )
) )
# Subscribing is the "keep" signal: a previously-explored channel becomes a normal organic
# one. Drop its ephemeral flags so the cleanup job leaves it alone and its videos stop being
# per-user private (they join the shared Library like any subscribed channel's uploads).
if channel.from_explore:
channel.from_explore = False
db.execute(
update(Video).where(Video.channel_id == channel_id, Video.via_explore.is_(True)).values(via_explore=False)
)
db.commit() db.commit()
return {"subscribed": channel_id} return {"subscribed": channel_id}

View file

@ -16,6 +16,7 @@ from app.db import get_db
from app.models import ( from app.models import (
Channel, Channel,
ChannelTag, ChannelTag,
ExploredChannel,
Playlist, Playlist,
PlaylistItem, PlaylistItem,
SearchFind, SearchFind,
@ -180,6 +181,23 @@ def _filtered_query(
# either way they shouldn't show in any feed view meanwhile. # either way they shouldn't show in any feed view meanwhile.
query = query.where(Video.unavailable_since.is_(None)) query = query.where(Video.unavailable_since.is_(None))
# 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 # Source filter. In the shared Library ("all" scope) it uses the GLOBAL via_search flag
# (search-discovered by anyone). In "my" scope it's the user's OWN provenance: # (search-discovered by anyone). In "my" scope it's the user's OWN provenance:
# "organic" = your subscriptions, "search" = videos you found via your own search, # "organic" = your subscriptions, "search" = videos you found via your own search,

View file

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

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

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

View file

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

View file

@ -51,6 +51,9 @@ SPECS: tuple[ConfigSpec, ...] = (
# --- Batch sizes --- # --- Batch sizes ---
ConfigSpec("enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50), ConfigSpec("enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50),
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000), ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
# --- Channel explore ---
# 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) --- # --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True), ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
# Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key). # Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key).

View file

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

View file

@ -28,6 +28,8 @@ import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar"; import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed"; import Feed from "./components/Feed";
import ChannelPage from "./components/ChannelPage";
import BackToTop from "./components/BackToTop";
import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels"; import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels";
import Playlists from "./components/Playlists"; import Playlists from "./components/Playlists";
import Stats from "./components/Stats"; import Stats from "./components/Stats";
@ -140,6 +142,27 @@ export default function App() {
if (window.history.state?._yt) window.history.back(); if (window.history.state?._yt) window.history.back();
else setYtSearch(null); else setYtSearch(null);
}, []); }, []);
// The open channel page (null = no channel page). Like the YouTube-search sub-view it owns a
// browser-history entry (history.state._chan = channel id, _chanName = a display name to show
// before the detail loads), so Back closes the channel page (after any player opened over it)
// and reload returns to the normal app. Channels/videos are reached by id, so it's not in the URL.
const [channelView, setChannelView] = useState<{ id: string; name?: string } | null>(() => {
// Restore the open channel page across a reload (it rides in history.state, which survives F5),
// unlike the YouTube search which is intentionally dropped (it would re-spend quota).
const c = window.history.state?._chan as string | undefined;
return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null;
});
const openChannel = useCallback((id: string, name?: string) => {
setChannelView({ id, name });
const st = window.history.state || {};
if (st._chan)
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "");
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "");
}, []);
const closeChannel = useCallback(() => {
if (window.history.state?._chan) window.history.back();
else setChannelView(null);
}, []);
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render. // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all"); const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all");
@ -186,14 +209,17 @@ export default function App() {
} }
function setPage(next: Page) { function setPage(next: Page) {
if (next === page) return; // While a channel page is open it overlays the content column, so a nav click must close it
// even when the underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
if (next === page && !channelView) return;
const go = () => { const go = () => {
setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next); setPageState(next);
localStorage.setItem(PAGE_KEY, next); localStorage.setItem(PAGE_KEY, next);
// Push an in-app history entry so the browser/mouse Back button steps through pages // Push an in-app history entry so the browser/mouse Back button steps through pages
// instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean — // instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean —
// the page rides in history.state, not the query string (filters never go in the URL). // the page rides in history.state, not the query string (filters never go in the URL).
// A fresh { sfPage } (no carried-over _sub/_ov markers) so each page starts at its root. // A fresh { sfPage } (no carried-over _sub/_ov/_chan markers) so each page starts at its root.
window.history.pushState({ sfPage: next }, ""); window.history.pushState({ sfPage: next }, "");
}; };
// Guard leaving the Settings page with unsaved preference changes. // Guard leaving the Settings page with unsaved preference changes.
@ -247,6 +273,8 @@ export default function App() {
useEffect(() => { useEffect(() => {
// Drop any stale _yt from a prior session (a reload starts on the normal feed; ytSearch // 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. // 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 || {}; const { _yt: _staleYt, ...rest } = window.history.state || {};
window.history.replaceState({ ...rest, sfPage: page }, ""); window.history.replaceState({ ...rest, sfPage: page }, "");
function onPop(e: PopStateEvent) { 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 // The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
// clears it to match the entry we landed on (and a player popped first leaves it intact). // clears it to match the entry we landed on (and a player popped first leaves it intact).
setYtSearch((e.state?._yt as string) ?? null); setYtSearch((e.state?._yt as string) ?? null);
// The channel page rides in history.state._chan the same way.
const chan = e.state?._chan as string | undefined;
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
} }
window.addEventListener("popstate", onPop); window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop);
@ -518,6 +549,18 @@ export default function App() {
language={i18n.language as LangCode} language={i18n.language as LangCode}
/> />
<div className="relative flex-1 min-w-0 flex flex-col"> <div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? (
<ChannelPage
key={channelView.id}
channelId={channelView.id}
initialName={channelView.name}
me={meQuery.data!}
view={view}
onBack={closeChannel}
onOpenChannel={openChannel}
/>
) : (
<>
<Header <Header
me={meQuery.data!} me={meQuery.data!}
filters={filters} filters={filters}
@ -556,10 +599,7 @@ export default function App() {
setView={setChannelsView} setView={setChannelsView}
filtersResetToken={channelsFilterReset} filtersResetToken={channelsFilterReset}
onOpenWizard={() => setWizardOpen(true)} onOpenWizard={() => setWizardOpen(true)}
onViewChannel={(id, name) => { onViewChannel={(id, name) => openChannel(id, name)}
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
setPage("feed");
}}
onFilterByTag={(tagId, name) => { onFilterByTag={(tagId, name) => {
setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined }); setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined });
setPage("feed"); setPage("feed");
@ -599,10 +639,13 @@ export default function App() {
ytSearch={ytSearch} ytSearch={ytSearch}
onYtSearch={enterYtSearch} onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch} onExitYtSearch={exitYtSearch}
onOpenChannel={openChannel}
/> />
)} )}
</main> </main>
</div> </div>
</>
)}
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail. {/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
Anchored inside the content column so they clear the sidebar automatically Anchored inside the content column so they clear the sidebar automatically
(collapsed or expanded) without tracking its width. */} (collapsed or expanded) without tracking its width. */}
@ -625,6 +668,8 @@ export default function App() {
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} /> <ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
)} )}
<ErrorDialog /> <ErrorDialog />
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
</div> </div>
); );
} }

View file

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

View file

@ -0,0 +1,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<string | null | undefined>(undefined);
const [exploring, setExploring] = useState(false);
const autoTried = useRef<string | null>(null);
const runExplore = useCallback(
(token?: string | null) => {
setExploring(true);
return api
.exploreChannel(channelId, token ?? undefined)
.then((r) => {
setNextToken(r.next_page_token);
qc.invalidateQueries({ queryKey: ["feed"] });
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
// first visit (the GET that drove this render predated the explore that just ran).
qc.invalidateQueries({ queryKey: ["channel", channelId] });
})
.finally(() => setExploring(false));
},
[channelId, qc]
);
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo
// account (explore spends shared quota) and when videos are already present (a revisit, or a
// subscribed channel whose uploads the scheduler backfills).
useEffect(() => {
if (!ch || me.is_demo) return;
if (autoTried.current === channelId) return;
if (ch.subscribed) 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<FeedFilters>(() => ({
tags: [],
tagMode: "or",
q: "",
sort: "newest",
scope: "all",
librarySource: "all",
includeNormal: true,
includeShorts: false,
includeLive: true,
show: "all",
channelId,
channelName: initialName,
}));
const name = ch?.title ?? initialName ?? channelId;
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
const joined = ch?.published_at
? new Date(ch.published_at).toLocaleDateString(i18n.language, { year: "numeric", month: "short" })
: null;
// Handle + stats on one compact meta line under the name (saves the separate stats row).
const metaParts = [
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
ch?.subscriber_count != null ? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) }) : null,
ch?.video_count != null ? t("channel.videoCount", { count: ch.video_count }) : null,
ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
joined ? t("channel.joined", { date: joined }) : null,
].filter(Boolean) as string[];
const tabClass = (active: boolean) =>
active
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
return (
<main className="flex-1 min-w-0 overflow-y-auto">
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
<div className="relative px-4 sm:px-6 pt-3">
{ch?.banner_url ? (
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
// low default size, so request a crisp wide version (=w1707) and object-cover the
// desktop "safe area" — the centre 2560×423 (~6:1) band.
<div
className="w-full overflow-hidden rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
>
<img
src={`${ch.banner_url}=w1707`}
alt=""
className="w-full h-full object-cover object-center"
/>
</div>
) : (
<div className="h-14 w-full rounded-2xl bg-surface" />
)}
<button
onClick={onBack}
className="absolute top-5 left-7 sm:left-9 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm"
>
<ArrowLeft className="w-4 h-4" />
{t("channel.back")}
</button>
</div>
<div className="px-4 sm:px-6">
{/* Identity + actions. relative+z-10 so the avatar that overlaps up into the banner
paints ABOVE it (the banner's positioned container would otherwise cover it). */}
<div className="relative z-10 flex items-end gap-4 -mt-8">
<Avatar
src={ch?.thumbnail_url ?? null}
fallback={name}
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
/>
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-semibold truncate">{name}</h1>
{ch?.explored && !ch?.subscribed && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
{t("channel.exploringBadge")}
</span>
)}
</div>
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
{metaParts.map((part, i) => (
<span key={i}>
{i > 0 && <span className="mr-1.5">·</span>}
{part}
</span>
))}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 pb-1">
<a
href={ytUrl}
target="_blank"
rel="noreferrer"
title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg border border-border"
>
<ExternalLink className="w-4 h-4" />
</a>
{!me.is_demo &&
(ch?.subscribed ? (
<button
onClick={onUnsubscribe}
disabled={unsubscribe.isPending}
title={t("channel.unsubscribe")}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg disabled:opacity-50"
>
<Check className="w-4 h-4" />
{t("channel.subscribedState")}
</button>
) : (
<button
onClick={() => subscribe.mutate()}
disabled={subscribe.isPending}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50"
>
<Plus className="w-4 h-4" />
{t("channel.subscribe")}
</button>
))}
</div>
</div>
{exploring && (
<div className="flex items-center gap-2 text-xs text-muted mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("channel.loadingVideos")}
</div>
)}
{/* Tabs */}
<div className="flex items-center gap-4 mt-3 border-b border-border">
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
{t("channel.tabVideos")}
</button>
<button onClick={() => setTab("about")} className={tabClass(tab === "about")}>
{t("channel.tabAbout")}
</button>
</div>
</div>
{/* Tab content */}
{tab === "videos" ? (
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
canRead={me.can_read}
isDemo={me.is_demo}
onOpenWizard={() => {}}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
onOpenChannel={onOpenChannel}
channelScoped
/>
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<button
onClick={() => runExplore(nextToken)}
disabled={exploring}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border hover:bg-surface disabled:opacity-50"
>
{exploring ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
{t("channel.loadMore")}
</button>
</div>
)}
</>
) : (
<div className="px-4 sm:px-6 py-4 max-w-3xl">
{ch?.description ? (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{ch.description}</p>
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
{ch?.external_links && ch.external_links.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{ch.external_links.map((l, i) => (
<a
key={i}
href={l.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-accent hover:underline"
>
<ExternalLink className="w-3.5 h-3.5" />
{l.title || l.url}
</a>
))}
</div>
)}
</div>
)}
</main>
);
}

View file

@ -77,6 +77,8 @@ export default function Feed({
ytSearch, ytSearch,
onYtSearch, onYtSearch,
onExitYtSearch, onExitYtSearch,
onOpenChannel,
channelScoped,
}: { }: {
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
@ -91,8 +93,17 @@ export default function Feed({
ytSearch: string | null; ytSearch: string | null;
onYtSearch: (q: string) => void; onYtSearch: (q: string) => void;
onExitYtSearch: () => void; onExitYtSearch: () => void;
// Open a channel's dedicated page (from a card's channel name / avatar). Threaded down to
// the cards via VirtualFeed.
onOpenChannel?: (channelId: string, channelName?: string) => void;
// True when this feed is scoped to a single channel (the channel page). Drops sort options
// that are constant within one channel (subscribers, priority) — they'd be meaningless.
channelScoped?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const sortKeys = channelScoped
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
: SORT_KEYS;
const [overrides, setOverrides] = useState<Record<string, string>>({}); const [overrides, setOverrides] = useState<Record<string, string>>({});
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({}); const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
// The open player: which video and where to start (null = resume from saved position). // The open player: which video and where to start (null = resume from saved position).
@ -257,11 +268,11 @@ export default function Feed({
[qc] [qc]
); );
const onChannelFilter = useCallback( const handleOpenChannel = useCallback(
(channelId: string, channelName: string) => { (channelId: string, channelName: string) => {
setFilters({ ...filters, channelId, channelName }); onOpenChannel?.(channelId, channelName);
}, },
[filters, setFilters] [onOpenChannel]
); );
const onToggleSave = useCallback( const onToggleSave = useCallback(
@ -347,7 +358,7 @@ export default function Feed({
view={view} view={view}
onState={onState} onState={onState}
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter} onOpenChannel={handleOpenChannel}
onResetState={onResetState} onResetState={onResetState}
onOpen={openVideo} onOpen={openVideo}
hasNextPage={false} hasNextPage={false}
@ -378,6 +389,7 @@ export default function Feed({
startAt={activeVideo.startAt} startAt={activeVideo.startAt}
onClose={() => setActiveVideo(null)} onClose={() => setActiveVideo(null)}
onState={onState} onState={onState}
onOpenChannel={onOpenChannel}
/> />
)} )}
</div> </div>
@ -430,7 +442,7 @@ export default function Feed({
const toolbar = ( const toolbar = (
<div className="pb-3"> <div className="pb-3">
<div className="flex items-center gap-1.5 flex-wrap mb-2.5"> <div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
{SHOW_IDS.map((id) => ( {SHOW_IDS.map((id) => (
<button <button
key={id} key={id}
@ -486,9 +498,8 @@ export default function Feed({
</label> </label>
</> </>
)} )}
</div> <span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<div className="flex items-center gap-2 border-t border-border pt-2.5"> <span className="text-xs text-muted whitespace-nowrap">
<span className="text-sm text-muted">
{countQuery.data {countQuery.data
? t("feed.videoCount", { ? t("feed.videoCount", {
count: countQuery.data.count, count: countQuery.data.count,
@ -496,7 +507,7 @@ export default function Feed({
}) })
: " "} : " "}
</span> </span>
<div className="flex-1" /> <div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted">{t("feed.sortLabel")}</span> <span className="text-xs text-muted">{t("feed.sortLabel")}</span>
<select <select
value={sortKey} value={sortKey}
@ -514,7 +525,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" 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. */} {/* 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) => (
<option key={k} value={k}> <option key={k} value={k}>
{t("feed.sortKey." + k)} {t("feed.sortKey." + k)}
</option> </option>
@ -544,6 +555,7 @@ export default function Feed({
)} )}
</div> </div>
</div> </div>
</div>
); );
return ( return (
@ -568,7 +580,7 @@ export default function Feed({
view={view} view={view}
onState={onState} onState={onState}
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter} onOpenChannel={handleOpenChannel}
onResetState={onResetState} onResetState={onResetState}
onOpen={openVideo} onOpen={openVideo}
hasNextPage={!!hasNextPage} hasNextPage={!!hasNextPage}
@ -582,6 +594,7 @@ export default function Feed({
startAt={activeVideo.startAt} startAt={activeVideo.startAt}
onClose={() => setActiveVideo(null)} onClose={() => setActiveVideo(null)}
onState={onState} onState={onState}
onOpenChannel={onOpenChannel}
/> />
)} )}
{isFetchingNextPage && ( {isFetchingNextPage && (

View file

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

View file

@ -6,7 +6,6 @@ import {
CheckCheck, CheckCheck,
Eye, Eye,
EyeOff, EyeOff,
ListFilter,
Play, Play,
RotateCcw, RotateCcw,
} from "lucide-react"; } from "lucide-react";
@ -21,13 +20,11 @@ function Actions({
onState, onState,
onResetState, onResetState,
onToggleSave, onToggleSave,
onChannelFilter,
}: { }: {
video: Video; video: Video;
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onResetState?: (id: string) => void; onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void; onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const act = (status: string) => (e: React.MouseEvent) => { const act = (status: string) => (e: React.MouseEvent) => {
@ -97,19 +94,6 @@ function Actions({
<RotateCcw className="w-4 h-4" /> <RotateCcw className="w-4 h-4" />
</button> </button>
)} )}
{onChannelFilter && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChannelFilter(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
title={t("card.onlyThisChannel")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
>
<ListFilter className="w-4 h-4" />
</button>
)}
</div> </div>
); );
} }
@ -244,7 +228,7 @@ function VideoCard({
onState, onState,
onResetState, onResetState,
onToggleSave, onToggleSave,
onChannelFilter, onOpenChannel,
onOpen, onOpen,
}: { }: {
video: Video; video: Video;
@ -252,7 +236,7 @@ function VideoCard({
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onResetState?: (id: string) => void; onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void; onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void; onOpenChannel?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video, startAt?: number | null) => void; onOpen?: (v: Video, startAt?: number | null) => void;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@ -303,18 +287,18 @@ function VideoCard({
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} /> <Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{title} {title}
<a <button
href={video.channel_url} onClick={(e) => {
target="_blank" e.stopPropagation();
rel="noreferrer" onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
onClick={(e) => e.stopPropagation()} }}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg" className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
> >
{video.channel_title} {video.channel_title}
</a> </button>
<div className="text-xs text-muted mt-0.5">{meta}</div> <div className="text-xs text-muted mt-0.5">{meta}</div>
</div> </div>
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} /> <Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
</div> </div>
); );
} }
@ -335,17 +319,17 @@ function VideoCard({
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{title} {title}
<a <button
href={video.channel_url} onClick={(e) => {
target="_blank" e.stopPropagation();
rel="noreferrer" onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
onClick={(e) => e.stopPropagation()} }}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg" className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
> >
{video.channel_title} {video.channel_title}
</a> </button>
<div className="text-xs text-muted mt-0.5">{meta}</div> <div className="text-xs text-muted mt-0.5">{meta}</div>
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} /> <Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
</div> </div>
</div> </div>
</div> </div>

View file

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

View file

@ -0,0 +1,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."
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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."
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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."
}

View file

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

View file

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

View file

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

View file

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

View file

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