Merge: promote dev to prod

This commit is contained in:
npeter83 2026-06-29 23:43:44 +02:00
commit 789f93816e
37 changed files with 1108 additions and 38 deletions

View file

@ -1 +1 @@
0.17.0 0.18.0

View file

@ -0,0 +1,46 @@
"""live YouTube search: provenance flags
Revision ID: 0028_live_search_provenance
Revises: 0027_message_e2ee
Create Date: 2026-06-29
Adds provenance flags so videos (and channels) that entered the catalog only because a live
YouTube search surfaced them can be told apart from organically-synced content and hidden from
the Library by default:
- videos.via_search this video first arrived via search
- channels.from_search this channel exists only because search surfaced one of its videos
Both default false (every existing row is organic), indexed for the feed's exclude filter.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0028_live_search_provenance"
down_revision: Union[str, None] = "0027_message_e2ee"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"videos",
sa.Column(
"via_search", sa.Boolean(), nullable=False, server_default="false"
),
)
op.create_index("ix_videos_via_search", "videos", ["via_search"])
op.add_column(
"channels",
sa.Column(
"from_search", sa.Boolean(), nullable=False, server_default="false"
),
)
op.create_index("ix_channels_from_search", "channels", ["from_search"])
def downgrade() -> None:
op.drop_index("ix_channels_from_search", table_name="channels")
op.drop_column("channels", "from_search")
op.drop_index("ix_videos_via_search", table_name="videos")
op.drop_column("videos", "via_search")

View file

@ -0,0 +1,31 @@
"""accent-insensitive feed search (unaccent)
Revision ID: 0029_unaccent_search
Revises: 0028_live_search_provenance
Create Date: 2026-06-29
Enables the PostgreSQL `unaccent` contrib extension so the feed's text search can match
regardless of diacritics e.g. searching "tiesto" finds titles spelled "Tiësto". The feed
query wraps both sides in unaccent(); this migration just makes the function available.
`unaccent` ships with the official postgres image (postgresql-contrib). The DB role is a
superuser in our deployments, so CREATE EXTENSION succeeds; IF NOT EXISTS keeps it idempotent.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0029_unaccent_search"
down_revision: Union[str, None] = "0028_live_search_provenance"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS unaccent")
def downgrade() -> None:
# Intentionally left in place: other code/queries may rely on unaccent() and dropping a
# contrib extension is rarely what's wanted on a downgrade. (The feed query reverts with
# the code, not this migration.)
pass

View file

@ -71,6 +71,13 @@ class Settings(BaseSettings):
youtube_api_proxy: str = "" youtube_api_proxy: str = ""
# Daily quota budget (units). Default leaves headroom under the 10,000/day free limit. # Daily quota budget (units). Default leaves headroom under the 10,000/day free limit.
quota_daily_budget: int = 9000 quota_daily_budget: int = 9000
# Per-user cap on live YouTube searches per day. With the official API (search_source="api")
# each search.list costs 100 units, so the shared budget only affords ~80-90/day total; with
# the zero-quota scrape source it's a pure anti-abuse rate limit. Admin-tunable.
search_daily_limit_per_user: int = 70
# Source for live YouTube search: "scrape" (InnerTube, no API quota) or "api" (search.list,
# 100 units/page). Scrape is the default; flip to "api" if scraping is blocked/breaks.
search_source: str = "scrape"
# Recent-first backfill: how far back to fetch on the first pass per channel. # Recent-first backfill: how far back to fetch on the first pass per channel.
backfill_recent_max_videos: int = 100 backfill_recent_max_videos: int = 100
backfill_recent_max_days: int = 365 backfill_recent_max_days: int = 365

View file

@ -39,6 +39,7 @@ from app.routes import (
playlists, playlists,
quota, quota,
scheduler as scheduler_routes, scheduler as scheduler_routes,
search as search_routes,
setup as setup_routes, setup as setup_routes,
sync, sync,
tags, tags,
@ -113,6 +114,7 @@ app.include_router(auth.router)
app.include_router(sync.router) app.include_router(sync.router)
app.include_router(tags.router) app.include_router(tags.router)
app.include_router(feed.router) app.include_router(feed.router)
app.include_router(search_routes.router)
app.include_router(me.router) app.include_router(me.router)
app.include_router(notifications.router) app.include_router(notifications.router)
app.include_router(messages.router) app.include_router(messages.router)

View file

@ -175,6 +175,12 @@ class Channel(Base, TimestampMixin):
backfill_done: Mapped[bool] = mapped_column( backfill_done: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false" Boolean, default=False, server_default="false"
) )
# True when this channel only entered the catalog because a live YouTube search
# surfaced one of its videos (nobody subscribes to it / it has no organic upload sync).
# Lets the Library hide search-only "noise" by default.
from_search: 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")
@ -238,6 +244,12 @@ class Video(Base, TimestampMixin):
shorts_probed: Mapped[bool] = mapped_column( shorts_probed: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True Boolean, default=False, server_default="false", index=True
) )
# True when this video first entered the catalog via a live YouTube search (not an
# organic channel/playlist sync). Used to hide search-discovered "noise" from the
# Library by default; left False by the organic ingest paths.
via_search: 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

View file

@ -36,6 +36,7 @@ class QuotaAction:
VIDEOS_BACKFILL_FULL = "videos_backfill_full" VIDEOS_BACKFILL_FULL = "videos_backfill_full"
VIDEOS_ENRICH = "videos_enrich" VIDEOS_ENRICH = "videos_enrich"
VIDEOS_LOOKUP = "videos_lookup" VIDEOS_LOOKUP = "videos_lookup"
VIDEOS_SEARCH = "videos_search"
CHANNELS_DISCOVER = "channels_discover" CHANNELS_DISCOVER = "channels_discover"
CHANNELS_SUBSCRIBE = "channels_subscribe" CHANNELS_SUBSCRIBE = "channels_subscribe"
CHANNELS_UNSUBSCRIBE = "channels_unsubscribe" CHANNELS_UNSUBSCRIBE = "channels_unsubscribe"
@ -109,6 +110,35 @@ def can_spend(db: Session, units: int) -> bool:
return remaining_today(db) >= units return remaining_today(db) >= units
def log_action(db: Session, user_id: int, action: str) -> None:
"""Record a zero-cost action event so per-user daily caps still count it.
`record_usage` only logs when units are actually charged; a scrape-based search spends no
quota yet must still be rate-limited per user, so it logs its event here directly."""
db.add(QuotaEvent(user_id=user_id, action=action, units=0))
db.commit()
def actions_today(db: Session, user_id: int, action: str) -> int:
"""How many quota events of `action` this user has logged so far in the current Pacific
day for per-user, per-action daily caps (e.g. the live-search limit). Counts events,
not units, so it only works for actions charged exactly once per user action."""
from sqlalchemy import func, select # local import: keep the module's import head lean
return (
db.scalar(
select(func.count())
.select_from(QuotaEvent)
.where(
QuotaEvent.user_id == user_id,
QuotaEvent.action == action,
QuotaEvent.created_at >= pacific_day_start_utc(),
)
)
or 0
)
def record_usage(db: Session, units: int) -> None: def record_usage(db: Session, units: int) -> None:
"""Atomically add `units` to today's counter (upsert) and log an attribution event.""" """Atomically add `units` to today's counter (upsert) and log an attribution event."""
if units <= 0: if units <= 0:

View file

@ -124,6 +124,7 @@ def _filtered_query(
include_live: bool, include_live: bool,
show: str, show: str,
scope: str = "my", scope: str = "my",
library_source: str = "organic",
exclude_tag_category: str | None = None, exclude_tag_category: str | None = None,
) -> tuple[Select, object]: ) -> tuple[Select, object]:
"""Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count. """Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count.
@ -171,6 +172,17 @@ def _filtered_query(
# either way they shouldn't show in any feed view meanwhile. # either way they shouldn't show in any feed view meanwhile.
query = query.where(Video.unavailable_since.is_(None)) query = query.where(Video.unavailable_since.is_(None))
# Provenance filter for the Library: search-discovered videos are "noise" hidden by
# default ("organic"), can be mixed in ("all"), or shown exclusively ("search"). Only
# meaningful in "all" scope: "my" already restricts to subscribed channels, where a
# once-searched video is legitimately the user's.
if scope == "all":
if library_source == "organic":
query = query.where(Video.via_search.is_(False))
elif library_source == "search":
query = query.where(Video.via_search.is_(True))
# "all" → both organic and search-discovered videos
if channel_id: if channel_id:
query = query.where(Video.channel_id == channel_id) query = query.where(Video.channel_id == channel_id)
if min_duration is not None: if min_duration is not None:
@ -193,8 +205,15 @@ def _filtered_query(
) + timedelta(days=1) ) + timedelta(days=1)
query = query.where(Video.published_at < end) query = query.where(Video.published_at < end)
if q: if q:
like = f"%{q}%" # Accent-insensitive: unaccent() both sides so "tiesto" matches "Tiësto" (ILIKE alone
query = query.where(or_(Video.title.ilike(like), Channel.title.ilike(like))) # is case- but not diacritic-insensitive). unaccent is enabled by migration 0029.
like = func.unaccent(f"%{q}%")
query = query.where(
or_(
func.unaccent(Video.title).ilike(like),
func.unaccent(Channel.title).ilike(like),
)
)
if tags: if tags:
# AND across tag categories (e.g. language AND topic narrows), OR within a # AND across tag categories (e.g. language AND topic narrows), OR within a
@ -271,6 +290,7 @@ def _feed_params(
include_live: bool = False, include_live: bool = False,
show: str = "unwatched", show: str = "unwatched",
scope: str = "my", scope: str = "my",
library_source: str = "organic",
) -> dict: ) -> dict:
return { return {
"tags": tags, "tags": tags,
@ -287,6 +307,7 @@ def _feed_params(
"include_live": include_live, "include_live": include_live,
"show": show, "show": show,
"scope": scope, "scope": scope,
"library_source": library_source,
} }

View file

@ -0,0 +1,221 @@
"""Live YouTube search.
Searches YouTube directly and materialises the results into the shared catalog so they render
with the normal feed cards + in-app player and gain per-user state (watch / save / resume). The
source is admin-selectable (``search_source``): **scrape** (InnerTube, the website's own
endpoint, zero API quota the default) or **api** (search.list, **100 quota units per page**).
Either way it's gated behind a per-user daily cap, the API source additionally behind a budget
pre-check, and only real users (never the shared demo account) may call it. The result ids are
enriched through the cheap shared videos.list path (1 unit / 50) regardless of source.
Ingested results are flagged (videos.via_search / channels.from_search) so the Library can hide
this search-discovered "noise" by default. Shorts and currently-live/upcoming videos are never
ingested this way (Shorts are confirmed by the youtube.com/shorts probe; live/upcoming are
dropped from the search response up front)."""
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, select
from sqlalchemy.orm import Session, aliased
from app import quota, sysconfig
from app.auth import require_human
from app.db import get_db
from app.models import Channel, User, Video, VideoState
from app.routes.feed import _serialize, feed_columns
from app.sync.subscriptions import apply_channel_details
from app.sync.videos import _insert_stubs, apply_video_details, parse_dt
from app.youtube import search_scrape
from app.youtube.client import YouTubeClient, YouTubeError
from app.youtube.shorts import make_client, probe_is_short
router = APIRouter(prefix="/api/search", tags=["search"])
# search.list costs this many quota units per page.
SEARCH_COST = 100
# live_status values we never surface from a live search (was_live VODs are real content).
_LIVE_HIDDEN = ("live", "upcoming")
def _classify_shorts(db: Session, videos: list[Video]) -> None:
"""Confirm/deny Shorts for the freshly-ingested videos so none slip into the feed.
Mirrors the scheduler's run_shorts_classification, but scoped to just these rows and run
synchronously (the search is an explicit, awaited action). Non-candidates (too long, no
duration, or live) are finalised without a probe; the rest hit youtube.com/shorts (no
quota). Undetermined probes are left for the scheduler to retry."""
probe_max = sysconfig.get_int(db, "shorts_probe_max_seconds")
to_probe: list[Video] = []
for v in videos:
if v.shorts_probed:
continue
if (
v.live_status != "none"
or v.duration_seconds is None
or v.duration_seconds > probe_max
):
v.is_short = False
v.shorts_probed = True
else:
to_probe.append(v)
if to_probe:
with make_client() as client:
def work(v: Video):
return v, probe_is_short(client, v.id)
with ThreadPoolExecutor(max_workers=16) as pool:
for v, result in pool.map(work, to_probe):
if result is None:
continue
v.is_short = result
v.shorts_probed = True
db.commit()
def _serialize_ids(db: Session, user: User, ids: list[str]) -> list[dict]:
"""Serialize the given video ids with the shared feed projection (joined channel + this
user's watch state + saved), preserving the input (search relevance) order."""
if not ids:
return []
state = aliased(VideoState)
rows = db.execute(
select(*feed_columns(user, state))
.join(Channel, Channel.id == Video.channel_id)
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
.where(Video.id.in_(ids))
).all()
by_id = {r.id: _serialize(r) for r in rows}
return [by_id[i] for i in ids if i in by_id]
@router.get("/youtube")
def search_youtube(
q: str | None = None,
cursor: str | None = None,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""One page of live YouTube search results, materialised into the catalog. `cursor` is the
YouTube nextPageToken (each page is another 100-unit search)."""
term = (q or "").strip()
if not term:
raise HTTPException(status_code=400, detail="A search term is required.")
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
source = (sysconfig.get_str(db, "search_source") or "scrape").lower()
if source != "scrape":
source = "api"
# Per-user daily cap applies to both sources (anti-abuse); with scrape it's the only guard
# since the search itself spends no quota.
daily_limit = sysconfig.get_int(db, "search_daily_limit_per_user")
used = quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
if used >= daily_limit:
raise HTTPException(
status_code=429,
detail=f"You've reached today's YouTube search limit ({daily_limit}). Try again tomorrow.",
)
# The 100-unit budget pre-check only applies to the API source.
if source == "api" and not quota.can_spend(db, SEARCH_COST):
raise HTTPException(
status_code=429,
detail="The shared YouTube quota for today is used up. Try again tomorrow.",
)
with YouTubeClient(db, user) as yt:
try:
if source == "api":
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_SEARCH):
page = yt.search_videos(term, page_token=cursor)
else:
page = search_scrape.search(term, continuation=cursor)
# Zero-cost, but logged so the per-user daily cap above keeps counting it.
quota.log_action(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
except (YouTubeError, search_scrape.ScrapeError) as exc:
raise HTTPException(status_code=502, detail=f"YouTube search failed: {exc}")
# Drop currently-live/upcoming results (search can't filter them and we never ingest
# them this way) and anything missing a channel id.
candidates = [
it
for it in page["items"]
if it["live_broadcast"] == "none" and it["channel_id"]
]
ids = [it["id"] for it in candidates]
if not ids:
return {
"items": [],
"next_cursor": page["next_page_token"],
"has_more": bool(page["next_page_token"]),
"source": source,
}
# 1) Channels are the FK target — create stubs for unknown ones (flagged from_search),
# then enrich the new ones (channels.list, 1 unit). Stubs survive an enrich failure.
channel_ids = list({it["channel_id"] for it in candidates})
existing = set(
db.scalars(select(Channel.id).where(Channel.id.in_(channel_ids))).all()
)
new_channel_ids = [c for c in channel_ids if c not in existing]
if new_channel_ids:
titles = {it["channel_id"]: it["channel_title"] for it in candidates}
for cid in new_channel_ids:
db.add(Channel(id=cid, title=titles.get(cid), from_search=True))
db.commit()
try:
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER):
details = yt.get_channels(new_channel_ids)
apply_channel_details(db, details)
db.commit()
except YouTubeError:
db.rollback() # details can be backfilled later; the stubs remain
# 2) Video stubs, flagged via_search. Existing rows keep their flags (ON CONFLICT DO
# NOTHING), so an organically-synced video stays organic.
_insert_stubs(
db,
[
{
"id": it["id"],
"channel_id": it["channel_id"],
"title": it["title"],
"published_at": parse_dt(it["published_at"]),
"thumbnail_url": it["thumbnail_url"],
"via_search": True,
}
for it in candidates
],
)
# 3) Enrich (videos.list, 1 unit/50) so cards have duration/stats and we can judge live.
videos = db.scalars(select(Video).where(Video.id.in_(ids))).all()
try:
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
detail_by_id = {it["id"]: it for it in yt.get_videos(ids)}
except YouTubeError:
detail_by_id = {}
now = datetime.now(timezone.utc)
for v in videos:
item = detail_by_id.get(v.id)
if item is None:
continue
apply_video_details(v, item)
v.enriched_at = now
db.commit()
# 4) Confirm/deny Shorts (no quota) so none enter via search.
_classify_shorts(db, videos)
# 5) Exclude Shorts and still-live results; keep the search's relevance order.
keep = {
v.id for v in videos if not v.is_short and v.live_status not in _LIVE_HIDDEN
}
ordered = [vid for vid in ids if vid in keep]
return {
"items": _serialize_ids(db, user, ordered),
"next_cursor": page["next_page_token"],
"has_more": bool(page["next_page_token"]),
"source": source,
}

View file

@ -39,6 +39,9 @@ SPECS: tuple[ConfigSpec, ...] = (
# --- Quota (shared YouTube Data API daily budget) --- # --- Quota (shared YouTube Data API daily budget) ---
ConfigSpec("quota_daily_budget", "int", "quota", "quota_daily_budget", min=1, max=1_000_000), ConfigSpec("quota_daily_budget", "int", "quota", "quota_daily_budget", min=1, max=1_000_000),
ConfigSpec("backfill_quota_reserve", "int", "quota", "backfill_quota_reserve", min=0, max=1_000_000), ConfigSpec("backfill_quota_reserve", "int", "quota", "backfill_quota_reserve", min=0, max=1_000_000),
ConfigSpec("search_daily_limit_per_user", "int", "quota", "search_daily_limit_per_user", min=0, max=100_000),
# Live-search source: "scrape" (InnerTube, zero API quota) or "api" (search.list, 100u/page).
ConfigSpec("search_source", "str", "quota", "search_source"),
# --- Backfill (recent-first first pass per channel) --- # --- Backfill (recent-first first pass per channel) ---
ConfigSpec("backfill_recent_max_videos", "int", "backfill", "backfill_recent_max_videos", min=1, max=100_000), ConfigSpec("backfill_recent_max_videos", "int", "backfill", "backfill_recent_max_videos", min=1, max=100_000),
ConfigSpec("backfill_recent_max_days", "int", "backfill", "backfill_recent_max_days", min=1, max=36_500), ConfigSpec("backfill_recent_max_days", "int", "backfill", "backfill_recent_max_days", min=1, max=36_500),

View file

@ -351,6 +351,49 @@ class YouTubeClient:
"""Delete a whole playlist on YouTube. 50 units.""" """Delete a whole playlist on YouTube. 50 units."""
self._write("DELETE", "playlists", params={"id": playlist_id}) self._write("DELETE", "playlists", params={"id": playlist_id})
def search_videos(
self, q: str, page_token: str | None = None, order: str = "relevance"
) -> dict:
"""Live YouTube search (search.list) for embeddable videos. **100 quota units per
call** by far the most expensive read, so callers must gate it (per-user cap +
budget pre-check). Public read: prefers the API key over OAuth.
Returns one page (up to 50) as ``{"items": [...], "next_page_token": str|None}``
where each item is a flat dict ready for stub insertion. `liveBroadcastContent` is
surfaced so the caller can drop currently-live/upcoming results (search can't filter
those out for us). Snippet carries no duration/stats the caller enriches via
videos.list afterwards."""
params = {
"part": "snippet",
"q": q,
"type": "video",
"maxResults": 50,
"order": order,
"videoEmbeddable": "true",
"safeSearch": "none",
}
if page_token:
params["pageToken"] = page_token
data = self._get("search", params, cost=100)
items: list[dict] = []
for it in data.get("items", []):
vid = it.get("id", {}).get("videoId")
if not vid:
continue
sn = it.get("snippet", {})
items.append(
{
"id": vid,
"channel_id": sn.get("channelId"),
"channel_title": sn.get("channelTitle"),
"title": sn.get("title"),
"published_at": sn.get("publishedAt"),
"thumbnail_url": best_thumbnail(sn.get("thumbnails")),
"live_broadcast": sn.get("liveBroadcastContent") or "none",
}
)
return {"items": items, "next_page_token": data.get("nextPageToken")}
def get_videos(self, video_ids: list[str]) -> list[dict]: def get_videos(self, video_ids: list[str]) -> list[dict]:
items: list[dict] = [] items: list[dict] = []
for batch in _chunks(video_ids, 50): for batch in _chunks(video_ids, 50):

View file

@ -0,0 +1,184 @@
"""Zero-quota YouTube search via InnerTube (the website's own youtubei/v1/search endpoint).
Live YouTube search **without spending any YouTube Data API quota**: instead of search.list
(100 units per page) we POST to YouTube's internal InnerTube endpoint — the same one the website
calls and read the result list straight out of the JSON. The returned video ids then go
through the normal enrich path (videos.list, 1 unit / 50), exactly as the official search does,
so the search itself is effectively free and only the (cheap, shared) enrichment is charged.
The SOCS=CAI consent-skip cookie is used like the Shorts probe so a server-side request isn't
bounced to consent.youtube.com. This deliberately scrapes an undocumented endpoint: if YouTube
changes the JSON shape or blocks the host, an admin can flip ``search_source`` back to ``api``.
The returned page mirrors :meth:`YouTubeClient.search_videos` exactly
(``{"items": [...], "next_page_token": str | None}``) so the route is source-agnostic the
only difference is the cursor is an InnerTube *continuation token*, not a Data API pageToken.
``published_at`` is left ``None`` (the scrape only exposes a relative time like "3 years ago");
the real date is filled by the videos.list enrich. ``live_broadcast`` is reported as ``"none"``
and live/upcoming filtering happens post-enrich via ``live_status`` (same as the API path)."""
import re
import httpx
_SEARCH_URL = "https://www.youtube.com/youtubei/v1/search"
_RESULTS_URL = "https://www.youtube.com/results"
# Public WEB InnerTube key + a recent client version. These are also embedded in every results
# page; we fetch the live pair once and cache it, falling back to these constants.
_FALLBACK_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
_FALLBACK_CLIENT_VERSION = "2.20240101.00.00"
# sp / params value selecting the "type: video" search filter (base64 protobuf EgIQAQ==).
_TYPE_VIDEO = "EgIQAQ=="
_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
# SOCS skips YouTube's cookie-consent interstitial (otherwise every server-side request is
# redirected to consent.youtube.com).
"Cookie": "SOCS=CAI",
"Accept-Language": "en-US,en;q=0.9",
}
_KEY_RE = re.compile(r'"INNERTUBE_API_KEY":"([^"]+)"')
_VER_RE = re.compile(r'"INNERTUBE_CLIENT_VERSION":"([^"]+)"')
# Cached (api_key, client_version) extracted from a results page; refreshed on demand.
_config: tuple[str, str] | None = None
class ScrapeError(Exception):
"""A scrape-based search could not be completed (network, blocked, or unexpected shape)."""
def _runs_text(node: dict | None) -> str | None:
if not node:
return None
if node.get("simpleText"):
return node["simpleText"]
runs = node.get("runs") or []
if runs:
return "".join(r.get("text", "") for r in runs)
return None
def _get_config(client: httpx.Client) -> tuple[str, str]:
"""The current InnerTube (api_key, client_version), cached after the first fetch."""
global _config
if _config is not None:
return _config
try:
resp = client.get(_RESULTS_URL, params={"search_query": "music"})
html = resp.text
key = _KEY_RE.search(html)
ver = _VER_RE.search(html)
_config = (
key.group(1) if key else _FALLBACK_KEY,
ver.group(1) if ver else _FALLBACK_CLIENT_VERSION,
)
except httpx.HTTPError:
_config = (_FALLBACK_KEY, _FALLBACK_CLIENT_VERSION)
return _config
def _video_item(vr: dict) -> dict | None:
"""Flatten a YouTube `videoRenderer` into the same shape as YouTubeClient.search_videos."""
vid = vr.get("videoId")
if not vid:
return None
owner = vr.get("ownerText") or vr.get("longBylineText") or {}
runs = owner.get("runs") or []
channel_id = channel_title = None
if runs:
channel_title = runs[0].get("text")
channel_id = (
runs[0]
.get("navigationEndpoint", {})
.get("browseEndpoint", {})
.get("browseId")
)
thumbs = (vr.get("thumbnail") or {}).get("thumbnails") or []
return {
"id": vid,
"channel_id": channel_id,
"channel_title": channel_title,
"title": _runs_text(vr.get("title")),
"published_at": None, # scrape exposes only a relative time; enrich fills the real date
"thumbnail_url": thumbs[-1]["url"] if thumbs else None,
"live_broadcast": "none", # live/upcoming filtered post-enrich via live_status
}
def _collect_videos(obj, out: list[dict], seen: set[str]) -> None:
"""Walk the response collecting videoRenderer items in document (relevance) order. Only
`videoRenderer` is taken, so channels/playlists/Shorts shelves (reelItemRenderer) are
naturally excluded."""
if isinstance(obj, dict):
vr = obj.get("videoRenderer")
if isinstance(vr, dict):
item = _video_item(vr)
if item and item["id"] not in seen:
seen.add(item["id"])
out.append(item)
for v in obj.values():
_collect_videos(v, out, seen)
elif isinstance(obj, list):
for v in obj:
_collect_videos(v, out, seen)
def _find_continuation(obj) -> str | None:
"""First continuationItemRenderer token (the 'load more' cursor), or None at the end."""
if isinstance(obj, dict):
cir = obj.get("continuationItemRenderer")
if isinstance(cir, dict):
tok = (
cir.get("continuationEndpoint", {})
.get("continuationCommand", {})
.get("token")
)
if tok:
return tok
for v in obj.values():
tok = _find_continuation(v)
if tok:
return tok
elif isinstance(obj, list):
for v in obj:
tok = _find_continuation(v)
if tok:
return tok
return None
def search(q: str, continuation: str | None = None) -> dict:
"""One page of zero-quota YouTube search results.
`continuation` is the InnerTube continuation token from a previous page (None for the first
page). Returns ``{"items": [...], "next_page_token": str | None}`` the same shape as
YouTubeClient.search_videos so the route handles both sources identically."""
with httpx.Client(timeout=20.0, headers=_HEADERS) as client:
api_key, client_version = _get_config(client)
context = {
"client": {
"clientName": "WEB",
"clientVersion": client_version,
"hl": "en",
"gl": "US",
}
}
body: dict = {"context": context}
if continuation:
body["continuation"] = continuation
else:
body["query"] = q
body["params"] = _TYPE_VIDEO
try:
resp = client.post(_SEARCH_URL, params={"key": api_key}, json=body)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as exc:
raise ScrapeError(f"InnerTube request failed: {exc}") from exc
except ValueError as exc: # non-JSON body (blocked / consent / captcha)
raise ScrapeError("InnerTube returned a non-JSON response") from exc
items: list[dict] = []
_collect_videos(data, items, set())
return {"items": items, "next_page_token": _find_continuation(data)}

View file

@ -122,6 +122,24 @@ export default function App() {
const saveMsgTimer = useRef<number | undefined>(undefined); const saveMsgTimer = useRef<number | undefined>(undefined);
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout); const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
const [page, setPageState] = useState<Page>(loadInitialPage); const [page, setPageState] = useState<Page>(loadInitialPage);
// Live YouTube search term (null = normal feed). Ephemeral: not persisted and not in the URL,
// so a reload returns to the normal feed (a search spends quota, so we never auto-replay it).
// The search is a feed SUB-VIEW that owns a browser-history entry (history.state._yt): the
// popstate handler below derives ytSearch from it, so Back steps player → search → feed in
// that order (a player opened over the results pops first, then the search, then the feed) —
// instead of the search vanishing because it had no history entry of its own.
const [ytSearch, setYtSearch] = useState<string | null>(null);
const enterYtSearch = useCallback((q: string) => {
setYtSearch(q);
const st = window.history.state || {};
if (st._yt) window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
}, []);
const exitYtSearch = useCallback(() => {
// Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
if (window.history.state?._yt) window.history.back();
else setYtSearch(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");
@ -153,6 +171,8 @@ export default function App() {
}; };
useEffect(() => { useEffect(() => {
if (page !== "channels") setFocusChannelName(null); if (page !== "channels") setFocusChannelName(null);
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed.
if (page !== "feed") setYtSearch(null);
}, [page]); }, [page]);
function openReleaseNotes(highlight?: string) { function openReleaseNotes(highlight?: string) {
@ -225,10 +245,10 @@ export default function App() {
// from history.state on popstate. (stripUrlParams preserves history.state, so this stamp // from history.state on popstate. (stripUrlParams preserves history.state, so this stamp
// survives a later query-string strip.) // survives a later query-string strip.)
useEffect(() => { useEffect(() => {
window.history.replaceState( // Drop any stale _yt from a prior session (a reload starts on the normal feed; ytSearch
{ ...window.history.state, sfPage: page }, // begins null), so the first Back doesn't resurrect a search we're no longer showing.
"" const { _yt: _staleYt, ...rest } = window.history.state || {};
); window.history.replaceState({ ...rest, sfPage: page }, "");
function onPop(e: PopStateEvent) { function onPop(e: PopStateEvent) {
const p = (e.state?.sfPage as Page) ?? "feed"; const p = (e.state?.sfPage as Page) ?? "feed";
// Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings // Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings
@ -250,6 +270,9 @@ export default function App() {
} }
setPageState(p); setPageState(p);
localStorage.setItem(PAGE_KEY, p); localStorage.setItem(PAGE_KEY, p);
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
// clears it to match the entry we landed on (and a player popped first leaves it intact).
setYtSearch((e.state?._yt as string) ?? null);
} }
window.addEventListener("popstate", onPop); window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop);
@ -500,6 +523,7 @@ export default function App() {
filters={filters} filters={filters}
setFilters={setFilters} setFilters={setFilters}
page={page} page={page}
onYtSearch={enterYtSearch}
onGoToFullHistory={() => { onGoToFullHistory={() => {
setChannelFilter("needs_full"); setChannelFilter("needs_full");
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
@ -572,6 +596,9 @@ export default function App() {
canRead={meQuery.data!.can_read} canRead={meQuery.data!.can_read}
isDemo={meQuery.data!.is_demo} isDemo={meQuery.data!.is_demo}
onOpenWizard={() => setWizardOpen(true)} onOpenWizard={() => setWizardOpen(true)}
ytSearch={ytSearch}
onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch}
/> />
)} )}
</main> </main>

View file

@ -1,10 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, RefreshCw } from "lucide-react"; import { ArrowDown, ArrowUp, ArrowLeft, RefreshCw, Youtube } from "lucide-react";
import { api, type FeedFilters, type Video } from "../lib/api"; import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
import i18n from "../i18n"; import i18n from "../i18n";
import { notify, resolveVideo } from "../lib/notifications"; import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import VirtualFeed from "./VirtualFeed"; import VirtualFeed from "./VirtualFeed";
import PlayerModal from "./PlayerModal"; import PlayerModal from "./PlayerModal";
@ -70,6 +71,9 @@ export default function Feed({
canRead, canRead,
isDemo = false, isDemo = false,
onOpenWizard, onOpenWizard,
ytSearch,
onYtSearch,
onExitYtSearch,
}: { }: {
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
@ -77,6 +81,13 @@ export default function Feed({
canRead: boolean; canRead: boolean;
isDemo?: boolean; isDemo?: boolean;
onOpenWizard: () => void; onOpenWizard: () => void;
// Live YouTube search: the active search term (null = normal feed). Entering a search and
// leaving it both step browser history (the search is a feed sub-view with its own history
// entry), so the empty-state CTA enters via onYtSearch and "back to feed" pops via
// onExitYtSearch. Search affordances are hidden for demo.
ytSearch: string | null;
onYtSearch: (q: string) => void;
onExitYtSearch: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [overrides, setOverrides] = useState<Record<string, string>>({}); const [overrides, setOverrides] = useState<Record<string, string>>({});
@ -92,11 +103,35 @@ export default function Feed({
[] []
); );
const ytActive = !!ytSearch;
// Debounce only the search term feeding the queries: the input still updates instantly (it
// owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch —
// and re-fetched pages keep the previous results on screen, so the feed never blanks/flickers.
const debouncedQ = useDebounced(filters.q, 300);
const queryFilters: FeedFilters = { ...filters, q: debouncedQ };
const query = useInfiniteQuery({ const query = useInfiniteQuery({
queryKey: ["feed", filters], queryKey: ["feed", queryFilters],
queryFn: ({ pageParam }) => api.feed(filters, pageParam as string | null, PAGE), queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE),
initialPageParam: null as string | null, initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined, getNextPageParam: (last) => last.next_cursor ?? undefined,
enabled: !ytActive, // don't load the normal feed while showing live YouTube results
placeholderData: keepPreviousData,
});
// Live YouTube search results. We never auto-paginate on scroll — the user pulls more with an
// explicit button (each api-source page spends 100 quota units; scrape-source pages are free).
// retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
// revisited search from re-spending.
const ytQuery = useInfiniteQuery({
queryKey: ["yt-search", ytSearch],
queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
enabled: ytActive,
staleTime: 5 * 60_000,
retry: false,
}); });
// Drop optimistic status overrides when filters change OR fresh server data // Drop optimistic status overrides when filters change OR fresh server data
@ -113,9 +148,11 @@ export default function Feed({
}, [query.dataUpdatedAt]); }, [query.dataUpdatedAt]);
const countQuery = useQuery({ const countQuery = useQuery({
queryKey: ["feed-count", filters], queryKey: ["feed-count", queryFilters],
queryFn: () => api.feedCount(filters), queryFn: () => api.feedCount(queryFilters),
staleTime: 30_000, staleTime: 30_000,
enabled: !ytActive,
placeholderData: keepPreviousData,
}); });
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query; const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
@ -232,6 +269,103 @@ export default function Feed({
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v)) .map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v))
.filter((v) => matchesView(v.status, filters.show)); .filter((v) => matchesView(v.status, filters.show));
// --- Live YouTube search mode -------------------------------------------------------------
// A separate data source rendered in the same cards/player. No watch-state view filter (the
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
if (ytActive) {
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
loadedRef.current = ytLoaded;
const ytItems: Video[] = ytLoaded
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v));
const ytError =
ytQuery.error instanceof HttpError
? ytQuery.error.detail || t("feed.yt.error")
: ytQuery.isError
? t("feed.yt.error")
: null;
// The backend reports which source served the search; scrape spends no search quota, so we
// drop the quota warning + "uses quota" wording in that mode.
const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape";
return (
<div className="p-4">
<div className="flex items-center gap-2 flex-wrap pb-3 mb-1 border-b border-border">
<button
onClick={() => {
// The search just ingested new catalog videos, so the normal feed (which was
// disabled and still holds its pre-search cache) is stale. Drop those caches so
// it re-fetches fresh on return instead of showing the old (often empty) result.
onExitYtSearch();
qc.removeQueries({ queryKey: ["feed"] });
qc.removeQueries({ queryKey: ["feed-count"] });
qc.removeQueries({ queryKey: ["facets"] });
}}
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0"
>
<ArrowLeft className="w-4 h-4" />
{t("feed.yt.back")}
</button>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<Youtube className="w-4 h-4 text-accent shrink-0" />
<span className="text-sm font-semibold truncate">
{t("feed.yt.resultsFor", { query: ytSearch })}
</span>
<span className="text-xs text-muted">
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
</span>
</div>
{ytQuery.isLoading ? (
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
) : ytError ? (
<div className="py-16 text-center text-muted max-w-md mx-auto">{ytError}</div>
) : ytItems.length === 0 ? (
<div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div>
) : (
<>
<VirtualFeed
items={ytItems}
view={view}
onState={onState}
onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter}
onResetState={onResetState}
onOpen={openVideo}
hasNextPage={false}
isFetchingNextPage={false}
fetchNextPage={() => {}}
/>
{ytQuery.hasNextPage && (
<div className="text-center pt-4">
<button
onClick={() => ytQuery.fetchNextPage()}
disabled={ytQuery.isFetchingNextPage}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-border bg-card text-sm font-medium hover:border-accent hover:text-accent disabled:opacity-50 transition"
>
{ytQuery.isFetchingNextPage
? t("feed.loadingMore")
: zeroQuota
? t("feed.yt.loadMoreFree")
: t("feed.yt.loadMore")}
</button>
</div>
)}
</>
)}
{activeVideo && (
<PlayerModal
video={activeVideo.video}
startAt={activeVideo.startAt}
onClose={() => setActiveVideo(null)}
onState={onState}
/>
)}
</div>
);
}
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>; if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>; if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
// Empty "my" feed with no YouTube access. The shared demo account can never connect // Empty "my" feed with no YouTube access. The shared demo account can never connect
@ -311,6 +445,29 @@ export default function Feed({
</button> </button>
); );
})} })}
{filters.scope === "all" && (
<>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<label className="inline-flex items-center gap-1.5 text-xs text-muted">
<span>{t("feed.source.label")}</span>
<select
value={filters.librarySource ?? "organic"}
onChange={(e) =>
setFilters({
...filters,
librarySource: e.target.value as FeedFilters["librarySource"],
})
}
title={t("feed.source.tip")}
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
>
<option value="organic">{t("feed.source.organic")}</option>
<option value="all">{t("feed.source.all")}</option>
<option value="search">{t("feed.source.only")}</option>
</select>
</label>
</>
)}
</div> </div>
<div className="flex items-center gap-2 border-t border-border pt-2.5"> <div className="flex items-center gap-2 border-t border-border pt-2.5">
<span className="text-sm text-muted"> <span className="text-sm text-muted">
@ -372,7 +529,18 @@ export default function Feed({
<div className="p-4"> <div className="p-4">
{toolbar} {toolbar}
{items.length === 0 ? ( {items.length === 0 ? (
<div className="py-16 text-center text-muted">{t("feed.noMatches")}</div> <div className="py-16 text-center text-muted">
<p>{t("feed.noMatches")}</p>
{filters.q.trim() && !isDemo && (
<button
onClick={() => onYtSearch(filters.q.trim())}
className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<Youtube className="w-4 h-4" />
{t("feed.yt.searchFor", { query: filters.q.trim() })}
</button>
)}
</div>
) : ( ) : (
<VirtualFeed <VirtualFeed
items={items} items={items}

View file

@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Library, Search, User } from "lucide-react"; import { Library, Search, User, Youtube } from "lucide-react";
import type { FeedFilters, Me } from "../lib/api"; import type { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import SyncStatus from "./SyncStatus"; import SyncStatus from "./SyncStatus";
@ -13,14 +13,20 @@ export default function Header({
setFilters, setFilters,
page, page,
onGoToFullHistory, onGoToFullHistory,
onYtSearch,
}: { }: {
me: Me; me: Me;
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
page: Page; page: Page;
onGoToFullHistory: () => void; onGoToFullHistory: () => void;
// Trigger a live YouTube search for the current term (hidden for the demo account, which
// can't spend the shared quota).
onYtSearch: (q: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const canSearchYt = !me.is_demo;
const trimmedQ = filters.q.trim();
return ( return (
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20"> <header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
@ -56,14 +62,31 @@ export default function Header({
)} )}
{page === "feed" ? ( {page === "feed" ? (
<div className="flex-1 max-w-xl mx-auto relative"> <div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" /> <div className="flex-1 relative">
<input <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
value={filters.q} <input
onChange={(e) => setFilters({ ...filters, q: e.target.value })} value={filters.q}
placeholder={t("header.searchPlaceholder")} onChange={(e) => setFilters({ ...filters, q: e.target.value })}
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent" onKeyDown={(e) => {
/> // Enter runs a live YouTube search for the typed term (the box itself filters
// the local catalog as you type; Enter escalates to the YouTube API).
if (e.key === "Enter" && trimmedQ && canSearchYt) onYtSearch(trimmedQ);
}}
placeholder={t("header.searchPlaceholder")}
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
/>
</div>
{trimmedQ && canSearchYt && (
<button
onClick={() => onYtSearch(trimmedQ)}
title={t("header.searchYoutubeTip")}
className="shrink-0 inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-2 text-xs font-medium text-muted hover:text-accent hover:border-accent transition"
>
<Youtube className="w-4 h-4" />
<span className="hidden md:inline">{t("header.searchYoutube")}</span>
</button>
)}
</div> </div>
) : ( ) : (
<div className="flex-1 text-center text-sm font-semibold"> <div className="flex-1 text-center text-sm font-semibold">

View file

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, X } from "lucide-react"; import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist"; import AddToPlaylist from "./AddToPlaylist";
import { api, type Video } from "../lib/api"; import { api, type Video } from "../lib/api";
@ -88,6 +88,45 @@ export default function PlayerModal({
const mountRef = useRef<HTMLDivElement | null>(null); const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null); const playerRef = useRef<any>(null);
const autoMarkedRef = useRef(false); const autoMarkedRef = useRef(false);
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
// iframe) so F / Space / Esc keep working until the user clicks into YouTube's native
// controls; the interaction overlay below also stops the iframe stealing focus on a video
// click. `stageRef` is the element we fullscreen (so the volume overlay stays visible).
const cardRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const volTimerRef = useRef<number | undefined>(undefined);
// Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment.
const [volumeUi, setVolumeUi] = useState<number | null>(null);
const focusModal = () => cardRef.current?.focus();
const togglePlay = () => {
const p = playerRef.current;
if (!p || typeof p.getPlayerState !== "function") return;
if (p.getPlayerState() === 1) p.pauseVideo?.(); // 1 === playing
else p.playVideo?.();
};
const toggleFullscreen = () => {
const el = stageRef.current;
if (!el) return;
if (document.fullscreenElement) document.exitFullscreen?.();
else el.requestFullscreen?.();
};
const flashVolume = (level: number) => {
setVolumeUi(level);
window.clearTimeout(volTimerRef.current);
volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200);
};
const nudgeVolume = (delta: number) => {
const p = playerRef.current;
if (!p || typeof p.getVolume !== "function") return;
const cur = p.isMuted?.() ? 0 : p.getVolume?.() ?? 50;
const next = Math.max(0, Math.min(100, Math.round(cur + delta)));
if (next > 0) p.unMute?.();
p.setVolume?.(next);
if (next === 0) p.mute?.();
flashVolume(next);
};
// The player can navigate to other videos (YouTube links in a description). The // The player can navigate to other videos (YouTube links in a description). The
// currently-playing id may differ from the active item. // currently-playing id may differ from the active item.
@ -173,21 +212,59 @@ export default function PlayerModal({
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
// ESC + background scroll lock. // Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock.
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") {
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (document.fullscreenElement) return;
onClose();
return;
}
const el = document.activeElement as HTMLElement | null;
const tag = (el?.tagName || "").toLowerCase();
const typing = tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable;
if (e.key === "f" || e.key === "F") {
if (typing) return;
e.preventDefault();
toggleFullscreen();
} else if (e.key === " " || e.code === "Space") {
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
// page from scrolling).
if (typing || tag === "button" || tag === "a") return;
e.preventDefault();
togglePlay();
}
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
focusModal();
const prevOverflow = document.body.style.overflow; const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
return () => { return () => {
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow; document.body.style.overflow = prevOverflow;
window.clearTimeout(closeTimer.current); window.clearTimeout(closeTimer.current);
window.clearTimeout(volTimerRef.current);
}; };
}, [onClose]); }, [onClose]);
// Mouse-wheel volume. A cross-origin iframe swallows wheel events, so we catch them on the
// interaction overlay above it (non-passive so preventDefault stops the modal scrolling).
useEffect(() => {
const el = overlayRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
focusModal(); // re-grab focus if it had slipped into the native controls
nudgeVolume(e.deltaY < 0 ? 5 : -5);
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
// Re-attach if the overlay remounts (it's hidden while the embed shows an error).
}, [playerError]);
// Create the player, resume from the saved position, persist progress, and // Create the player, resume from the saved position, persist progress, and
// auto-mark watched once playback reaches the end. // auto-mark watched once playback reaches the end.
useEffect(() => { useEffect(() => {
@ -242,6 +319,8 @@ export default function PlayerModal({
playsinline: 1, playsinline: 1,
}, },
events: { events: {
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away.
onReady: () => focusModal(),
onStateChange: (e: any) => { onStateChange: (e: any) => {
// 1 === playing → sync the navigated-to video's title/author for display. // 1 === playing → sync the navigated-to video's title/author for display.
if (e?.data === 1) { if (e?.data === 1) {
@ -297,13 +376,44 @@ export default function PlayerModal({
aria-modal="true" aria-modal="true"
> >
<div <div
className="glass-card relative w-full max-w-4xl max-h-full overflow-y-auto rounded-2xl shadow-2xl" ref={cardRef}
tabIndex={-1}
className="glass-card relative w-full max-w-4xl max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-none"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden"> <div
ref={stageRef}
className="player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden"
>
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed {/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
through our overlay. */} through our overlay. */}
<div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} /> <div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} />
{/* Interaction layer over the video: catches the scroll wheel (volume) and click
(play/pause), and stops the iframe stealing keyboard focus. The bottom strip is
left uncovered so YouTube's native control bar (seek / settings / CC / fullscreen)
stays usable. Hidden on error so the "Open on YouTube" CTA is clickable. */}
{playerError == null && (
<div
ref={overlayRef}
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-label={t("player.shortcutsHint")}
className="absolute inset-x-0 top-0 bottom-14 z-10 cursor-pointer"
/>
)}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div className="h-full rounded-full bg-white transition-all" style={{ width: `${volumeUi}%` }} />
</div>
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
</div>
)}
{playerError != null && ( {playerError != null && (
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center"> <div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
<div className="max-w-sm"> <div className="max-w-sm">

View file

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { import {
Check, Check,
ChevronDown, ChevronDown,
@ -36,6 +36,7 @@ import {
} from "../lib/sidebarLayout"; } from "../lib/sidebarLayout";
import { shareUrl } from "../lib/urlState"; import { shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import { Switch } from "./ui/form"; import { Switch } from "./ui/form";
import TagManager from "./TagManager"; import TagManager from "./TagManager";
@ -131,10 +132,14 @@ export default function Sidebar({
// Live per-tag channel counts for the current filter context (scope, channel, date, // Live per-tag channel counts for the current filter context (scope, channel, date,
// search, watch state, other category's tags). Lets us show contextual counts and hide // search, watch state, other category's tags). Lets us show contextual counts and hide
// chips that no longer match anything. Keyed on filters so it refetches as they change. // chips that no longer match anything. Keyed on filters so it refetches as they change.
// Debounce the search term (matching the feed) so typing doesn't refire facets per
// keystroke, and keep the previous counts on screen during a refetch so chips don't flicker.
const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) };
const facetsQuery = useQuery({ const facetsQuery = useQuery({
queryKey: ["facets", filters], queryKey: ["facets", facetFilters],
queryFn: () => api.facets(filters), queryFn: () => api.facets(facetFilters),
staleTime: 30_000, staleTime: 30_000,
placeholderData: keepPreviousData,
}); });
const facetsReady = !!facetsQuery.data; const facetsReady = !!facetsQuery.data;
const facetCounts = facetsQuery.data?.counts ?? {}; const facetCounts = facetsQuery.data?.counts ?? {};

View file

@ -19,6 +19,8 @@
"smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." }, "smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." },
"quota_daily_budget": { "label": "Tägliches Kontingentbudget", "hint": "YouTube-Data-API-Einheiten pro Tag (kostenloses Limit 10.000). Standard 9000 lässt Puffer." }, "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." }, "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_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." }, "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_max_seconds": { "label": "Shorts-Prüfung — max. Dauer (s)", "hint": "Nur Videos bis zu dieser Länge werden als mögliche Shorts geprüft." },

View file

@ -29,5 +29,24 @@
"undo": "Rückgängig", "undo": "Rückgängig",
"markedWatchedNamed": "Als angesehen markiert: „{{title}}”", "markedWatchedNamed": "Als angesehen markiert: „{{title}}”",
"markedWatched": "Als angesehen markiert", "markedWatched": "Als angesehen markiert",
"unwatch": "Nicht mehr als angesehen" "unwatch": "Nicht mehr als angesehen",
"source": {
"label": "Quelle",
"tip": "Bibliothek danach filtern, wie Videos hierher kamen: nur organischer Katalog, mit Suchergebnissen, oder nur über Live-YouTube-Suche gefundene Videos.",
"organic": "Ohne Suchergebnisse",
"all": "Mit Suchergebnissen",
"only": "Nur Suchergebnisse"
},
"yt": {
"searchFor": "Auf YouTube suchen nach „{{query}}”",
"resultsFor": "YouTube-Ergebnisse für „{{query}}”",
"quotaNote": "Live-Ergebnisse — verbraucht gemeinsames API-Kontingent",
"freeNote": "Live-Ergebnisse — kein Suchkontingent verbraucht",
"back": "Zurück zum Feed",
"searching": "Suche auf YouTube…",
"noResults": "Keine YouTube-Videos gefunden.",
"error": "YouTube-Suche fehlgeschlagen.",
"loadMore": "Mehr laden (verbraucht Kontingent)",
"loadMoreFree": "Mehr laden"
}
} }

View file

@ -1,6 +1,8 @@
{ {
"feed": "Feed", "feed": "Feed",
"searchPlaceholder": "Deine Abos durchsuchen…", "searchPlaceholder": "Deine Abos durchsuchen…",
"searchYoutube": "Auf YouTube suchen",
"searchYoutubeTip": "Live auf YouTube nach diesem Begriff suchen (verbraucht gemeinsames API-Kontingent). Du kannst auch Enter drücken.",
"channelManager": "Kanalverwaltung", "channelManager": "Kanalverwaltung",
"usageStats": "Nutzung & Statistik", "usageStats": "Nutzung & Statistik",
"scheduler": "Planer", "scheduler": "Planer",

View file

@ -19,5 +19,6 @@
"unavailableTitle": "Hier nicht abspielbar", "unavailableTitle": "Hier nicht abspielbar",
"unavailableBody": "Dieses Video ist nicht verfügbar — möglicherweise privat, entfernt oder regional gesperrt.", "unavailableBody": "Dieses Video ist nicht verfügbar — möglicherweise privat, entfernt oder regional gesperrt.",
"embedDisabledBody": "Der Eigentümer hat die Wiedergabe auf anderen Seiten deaktiviert (häufig bei automatisch generierten „Topic“-Musiktiteln). Auf YouTube ist es weiterhin abspielbar.", "embedDisabledBody": "Der Eigentümer hat die Wiedergabe auf anderen Seiten deaktiviert (häufig bei automatisch generierten „Topic“-Musiktiteln). Auf YouTube ist es weiterhin abspielbar.",
"openOnYoutube": "Auf YouTube öffnen" "openOnYoutube": "Auf YouTube öffnen",
"shortcutsHint": "Klick: Wiedergabe/Pause · Scrollen: Lautstärke · F: Vollbild"
} }

View file

@ -8,6 +8,7 @@
"videos_backfill_full": "Vollständigen Verlauf laden", "videos_backfill_full": "Vollständigen Verlauf laden",
"videos_enrich": "Video-Anreicherung", "videos_enrich": "Video-Anreicherung",
"videos_lookup": "Video-Abfrage", "videos_lookup": "Video-Abfrage",
"videos_search": "Live-Suche",
"channels_discover": "Kanal-Entdeckung", "channels_discover": "Kanal-Entdeckung",
"channels_subscribe": "Abonnieren", "channels_subscribe": "Abonnieren",
"channels_unsubscribe": "Abo beenden", "channels_unsubscribe": "Abo beenden",

View file

@ -19,6 +19,8 @@
"smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." }, "smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." },
"quota_daily_budget": { "label": "Daily quota budget", "hint": "Total YouTube Data API units to spend per day (free tier is 10,000). Default 9000 leaves headroom." }, "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." }, "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_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." }, "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_max_seconds": { "label": "Shorts probe — max duration (s)", "hint": "Only videos at or below this length are probed as possible Shorts." },

View file

@ -29,5 +29,24 @@
"undo": "Undo", "undo": "Undo",
"markedWatchedNamed": "Marked watched “{{title}}”", "markedWatchedNamed": "Marked watched “{{title}}”",
"markedWatched": "Marked watched", "markedWatched": "Marked watched",
"unwatch": "Unwatch" "unwatch": "Unwatch",
"source": {
"label": "Source",
"tip": "Filter the Library by how videos got here: organic catalog only, plus search results, or only videos found via live YouTube search.",
"organic": "Hide search results",
"all": "Include search results",
"only": "Search results only"
},
"yt": {
"searchFor": "Search YouTube for “{{query}}”",
"resultsFor": "YouTube results for “{{query}}”",
"quotaNote": "Live results — uses shared API quota",
"freeNote": "Live results — no search quota used",
"back": "Back to feed",
"searching": "Searching YouTube…",
"noResults": "No YouTube videos found.",
"error": "YouTube search failed.",
"loadMore": "Load more (uses quota)",
"loadMoreFree": "Load more"
}
} }

View file

@ -1,6 +1,8 @@
{ {
"feed": "Feed", "feed": "Feed",
"searchPlaceholder": "Search your subscriptions…", "searchPlaceholder": "Search your subscriptions…",
"searchYoutube": "Search YouTube",
"searchYoutubeTip": "Search live on YouTube for this term (uses shared API quota). You can also press Enter.",
"channelManager": "Channel manager", "channelManager": "Channel manager",
"usageStats": "Usage & stats", "usageStats": "Usage & stats",
"scheduler": "Scheduler", "scheduler": "Scheduler",

View file

@ -19,5 +19,6 @@
"unavailableTitle": "Can't play this here", "unavailableTitle": "Can't play this here",
"unavailableBody": "This video isn't available — it may be private, removed, or region-restricted.", "unavailableBody": "This video isn't available — it may be private, removed, or region-restricted.",
"embedDisabledBody": "The owner disabled playback on other sites (common for auto-generated “Topic” music tracks). It still plays on YouTube.", "embedDisabledBody": "The owner disabled playback on other sites (common for auto-generated “Topic” music tracks). It still plays on YouTube.",
"openOnYoutube": "Open on YouTube" "openOnYoutube": "Open on YouTube",
"shortcutsHint": "Click: play/pause · Scroll: volume · F: fullscreen"
} }

View file

@ -8,6 +8,7 @@
"videos_backfill_full": "Full-history backfill", "videos_backfill_full": "Full-history backfill",
"videos_enrich": "Video enrichment", "videos_enrich": "Video enrichment",
"videos_lookup": "Video lookup", "videos_lookup": "Video lookup",
"videos_search": "Live search",
"channels_discover": "Channel discovery", "channels_discover": "Channel discovery",
"channels_subscribe": "Subscribe", "channels_subscribe": "Subscribe",
"channels_unsubscribe": "Unsubscribe", "channels_unsubscribe": "Unsubscribe",

View file

@ -19,6 +19,8 @@
"smtp_password": { "label": "SMTP-jelszó", "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra." }, "smtp_password": { "label": "SMTP-jelszó", "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra." },
"quota_daily_budget": { "label": "Napi kvótakeret", "hint": "Naponta elkölthető YouTube Data API-egységek (az ingyenes keret 10 000). Az alap 9000 tartalékot hagy." }, "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." }, "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_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." }, "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_max_seconds": { "label": "Shorts-vizsgálat — max hossz (mp)", "hint": "Csak az ennél nem hosszabb videókat vizsgáljuk lehetséges Shortsként." },

View file

@ -29,5 +29,24 @@
"undo": "Visszavonás", "undo": "Visszavonás",
"markedWatchedNamed": "Megnézettnek jelölve: „{{title}}”", "markedWatchedNamed": "Megnézettnek jelölve: „{{title}}”",
"markedWatched": "Megnézettnek jelölve", "markedWatched": "Megnézettnek jelölve",
"unwatch": "Megnézés visszavonása" "unwatch": "Megnézés visszavonása",
"source": {
"label": "Forrás",
"tip": "Szűrd a könyvtárat aszerint, hogyan kerültek be a videók: csak organikus katalógus, kereséssel együtt, vagy csak az élő YouTube-keresésből talált videók.",
"organic": "Keresés nélkül",
"all": "Kereséssel együtt",
"only": "Csak keresésből"
},
"yt": {
"searchFor": "Keresés a YouTube-on: „{{query}}”",
"resultsFor": "YouTube találatok erre: „{{query}}”",
"quotaNote": "Élő találatok — a közös API-kvótát fogyasztja",
"freeNote": "Élő találatok — nem fogyaszt keresési kvótát",
"back": "Vissza a hírfolyamhoz",
"searching": "Keresés a YouTube-on…",
"noResults": "Nincs YouTube-találat.",
"error": "A YouTube-keresés nem sikerült.",
"loadMore": "Továbbiak betöltése (kvótát fogyaszt)",
"loadMoreFree": "Továbbiak betöltése"
}
} }

View file

@ -1,6 +1,8 @@
{ {
"feed": "Hírfolyam", "feed": "Hírfolyam",
"searchPlaceholder": "Keresés a feliratkozásaid között…", "searchPlaceholder": "Keresés a feliratkozásaid között…",
"searchYoutube": "Keresés a YouTube-on",
"searchYoutubeTip": "Élő keresés a YouTube-on erre a kifejezésre (a közös API-kvótát fogyasztja). Entert is nyomhatsz.",
"channelManager": "Csatornakezelő", "channelManager": "Csatornakezelő",
"usageStats": "Használat és statisztika", "usageStats": "Használat és statisztika",
"scheduler": "Ütemező", "scheduler": "Ütemező",

View file

@ -19,5 +19,6 @@
"unavailableTitle": "Ez itt nem játszható le", "unavailableTitle": "Ez itt nem játszható le",
"unavailableBody": "Ez a videó nem elérhető — lehet privát, eltávolított vagy régiókorlátozott.", "unavailableBody": "Ez a videó nem elérhető — lehet privát, eltávolított vagy régiókorlátozott.",
"embedDisabledBody": "A tulajdonos letiltotta a lejátszást más oldalakon (gyakori az auto-generált „Topic” zenei sávoknál). A YouTube-on viszont lejátszható.", "embedDisabledBody": "A tulajdonos letiltotta a lejátszást más oldalakon (gyakori az auto-generált „Topic” zenei sávoknál). A YouTube-on viszont lejátszható.",
"openOnYoutube": "Megnyitás YouTube-on" "openOnYoutube": "Megnyitás YouTube-on",
"shortcutsHint": "Kattintás: lejátszás/szünet · Görgő: hangerő · F: teljes képernyő"
} }

View file

@ -8,6 +8,7 @@
"videos_backfill_full": "Teljes előzmény betöltése", "videos_backfill_full": "Teljes előzmény betöltése",
"videos_enrich": "Videó-gazdagítás", "videos_enrich": "Videó-gazdagítás",
"videos_lookup": "Videó-lekérdezés", "videos_lookup": "Videó-lekérdezés",
"videos_search": "Élő keresés",
"channels_discover": "Csatorna-felfedezés", "channels_discover": "Csatorna-felfedezés",
"channels_subscribe": "Feliratkozás", "channels_subscribe": "Feliratkozás",
"channels_unsubscribe": "Leiratkozás", "channels_unsubscribe": "Leiratkozás",

View file

@ -262,6 +262,15 @@ html[data-scheme="youtube"][data-theme="light"] {
} }
} }
/* The in-app player stage, when fullscreened via the F key / native button, fills the screen
instead of keeping its 16:9 letterbox (and drops the rounded top corners). */
.player-stage:fullscreen {
width: 100vw;
height: 100vh;
aspect-ratio: auto;
border-radius: 0;
}
/* Thin, theme-aware scrollbars */ /* Thin, theme-aware scrollbars */
* { * {
scrollbar-color: var(--border) transparent; scrollbar-color: var(--border) transparent;

View file

@ -82,6 +82,9 @@ export interface FeedResponse {
// Opaque keyset cursor for the next page, or null when this is the last page. // Opaque keyset cursor for the next page, or null when this is the last page.
next_cursor: string | null; next_cursor: string | null;
limit: number; limit: number;
// Only set by the live YouTube search: "scrape" (InnerTube, no API quota) or "api"
// (search.list, 100 units/page) — lets the UI drop the quota warning in scrape mode.
source?: "scrape" | "api";
} }
export interface Playlist { export interface Playlist {
@ -141,6 +144,9 @@ export interface FeedFilters {
includeShorts: boolean; includeShorts: boolean;
includeLive: boolean; includeLive: boolean;
show: string; show: string;
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
librarySource?: "organic" | "all" | "search";
channelId?: string; channelId?: string;
channelName?: string; channelName?: string;
maxAgeDays?: number; maxAgeDays?: number;
@ -306,6 +312,8 @@ function filterParams(f: FeedFilters): URLSearchParams {
if (f.dateTo) p.set("published_before", f.dateTo); if (f.dateTo) p.set("published_before", f.dateTo);
if (f.minDuration != null) p.set("min_duration", String(f.minDuration)); if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration)); if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
// Only relevant in Library scope; the backend ignores it for "my". Default = hide search.
p.set("library_source", f.librarySource ?? "organic");
return p; return p;
} }
@ -558,6 +566,15 @@ export const api = {
req(`/api/feed?${feedQuery(f, cursor, limit)}`), req(`/api/feed?${feedQuery(f, cursor, limit)}`),
feedCount: (f: FeedFilters): Promise<{ count: number }> => feedCount: (f: FeedFilters): Promise<{ count: number }> =>
req(`/api/feed/count?${filterParams(f).toString()}`), req(`/api/feed/count?${filterParams(f).toString()}`),
// Live YouTube search: results are materialised into the catalog and returned as feed cards.
// `quiet` so the YT-search panel can render quota/limit errors (incl. 429) inline instead of
// popping the global modal. `cursor` is the next-page token (an InnerTube continuation token
// in scrape mode, or a Data API pageToken in api mode); the response's `source` says which.
searchYoutube: (q: string, cursor: string | null): Promise<FeedResponse> => {
const p = new URLSearchParams({ q });
if (cursor) p.set("cursor", cursor);
return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true });
},
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> => facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
req(`/api/facets?${filterParams(f).toString()}`), req(`/api/facets?${filterParams(f).toString()}`),
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these // idempotent: setting a state / saving a progress checkpoint is safe to replay, so these

View file

@ -14,6 +14,21 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.18.0",
date: "2026-06-29",
summary: "Search all of YouTube from your feed, and new keyboard/scroll controls in the video player.",
features: [
"Live YouTube search: type a search and press Enter (or the YouTube button) to search all of YouTube right from the feed — results show up as normal cards you can play, save and add to playlists. They're kept out of your regular Library by default; a Source filter lets you show or hide search-found videos. Shorts and currently-live/upcoming results are left out automatically.",
"Live search uses no YouTube API quota by default, so it's no longer limited to a handful of searches per day. (Admins can switch back to the official API under Configuration → Quota if the quota-free source ever stops working.)",
"Video player shortcuts: press F for fullscreen, Space to play/pause, and scroll the mouse wheel over the video to change the volume (with an on-screen volume bar). YouTube's own controls — the seek bar, captions and settings — still work as before.",
"Feed search now ignores accents, so searching “tiesto” also finds “Tiësto”.",
],
fixes: [
"After a live YouTube search, the Back button now closes the open video player first and keeps you on the search results, instead of jumping straight back to your feed.",
"The search box no longer flickers or briefly blanks the feed while you type.",
],
},
{ {
version: "0.17.0", version: "0.17.0",
date: "2026-06-29", date: "2026-06-29",

View file

@ -9,6 +9,7 @@ const KEYS = [
"show", "show",
"sort", "sort",
"scope", "scope",
"source",
"normal", "normal",
"shorts", "shorts",
"live", "live",
@ -28,6 +29,9 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
if (f.show && f.show !== "unwatched") p.set("show", f.show); if (f.show && f.show !== "unwatched") p.set("show", f.show);
if (f.sort && f.sort !== "newest") p.set("sort", f.sort); if (f.sort && f.sort !== "newest") p.set("sort", f.sort);
if (f.scope === "all") p.set("scope", "all"); if (f.scope === "all") p.set("scope", "all");
// Library provenance filter (only meaningful in "all" scope); "organic" is the default.
if (f.scope === "all" && f.librarySource && f.librarySource !== "organic")
p.set("source", f.librarySource);
if (!f.includeNormal) p.set("normal", "0"); if (!f.includeNormal) p.set("normal", "0");
if (f.includeShorts) p.set("shorts", "1"); if (f.includeShorts) p.set("shorts", "1");
if (f.includeLive) p.set("live", "1"); if (f.includeLive) p.set("live", "1");
@ -55,6 +59,10 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
if (params.has("show")) f.show = params.get("show") || "unwatched"; if (params.has("show")) f.show = params.get("show") || "unwatched";
if (params.has("sort")) f.sort = params.get("sort") || "newest"; if (params.has("sort")) f.sort = params.get("sort") || "newest";
if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my"; if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my";
if (params.has("source")) {
const s = params.get("source");
f.librarySource = s === "all" || s === "search" ? s : "organic";
}
if (params.has("normal")) f.includeNormal = params.get("normal") !== "0"; if (params.has("normal")) f.includeNormal = params.get("normal") !== "0";
if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1"; if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1";
if (params.has("live")) f.includeLive = params.get("live") === "1"; if (params.has("live")) f.includeLive = params.get("live") === "1";

View file

@ -0,0 +1,13 @@
import { useEffect, useState } from "react";
// Returns `value` after it has stopped changing for `delay` ms. Lets a text input stay
// responsive (it owns the immediate value) while the query it drives only re-runs once the
// user pauses — so we don't refetch the feed on every keystroke.
export function useDebounced<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = window.setTimeout(() => setDebounced(value), delay);
return () => window.clearTimeout(id);
}, [value, delay]);
return debounced;
}