Merge: promote dev to prod
This commit is contained in:
commit
789f93816e
37 changed files with 1108 additions and 38 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.17.0
|
0.18.0
|
||||||
|
|
|
||||||
46
backend/alembic/versions/0028_live_search_provenance.py
Normal file
46
backend/alembic/versions/0028_live_search_provenance.py
Normal 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")
|
||||||
31
backend/alembic/versions/0029_unaccent_search.py
Normal file
31
backend/alembic/versions/0029_unaccent_search.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
221
backend/app/routes/search.py
Normal file
221
backend/app/routes/search.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
184
backend/app/youtube/search_scrape.py
Normal file
184
backend/app/youtube/search_scrape.py
Normal 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)}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 ?? {};
|
||||||
|
|
|
||||||
|
|
@ -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." },
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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." },
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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." },
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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ő",
|
||||||
|
|
|
||||||
|
|
@ -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ő"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
13
frontend/src/lib/useDebounced.ts
Normal file
13
frontend/src/lib/useDebounced.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue