Merge: live YouTube search

Search YouTube live from the app's search box (explicit action, not per-keystroke),
materialise results into the shared catalog so they render with the normal feed cards +
in-app player and gain per-user state. Provenance flags (via_search/from_search) let the
Library hide search-discovered videos by default with an organic/all/search-only Source
filter. Shorts and live/upcoming are never ingested this way; per-user daily cap (default
70) + budget pre-check gate the 100-unit search.list calls; demo excluded.

Also: accent-insensitive feed search (unaccent), debounced+keepPreviousData search box
(no flicker), source filter in share URLs, and a fresh feed on returning from search.

Migrations 0028 (provenance) + 0029 (unaccent). Not shipped — staying on dev.
This commit is contained in:
npeter83 2026-06-29 02:46:14 +02:00
commit 17c17c1d3c
29 changed files with 696 additions and 25 deletions

View file

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

View file

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

View file

@ -71,6 +71,9 @@ class Settings(BaseSettings):
youtube_api_proxy: str = ""
# Daily quota budget (units). Default leaves headroom under the 10,000/day free limit.
quota_daily_budget: int = 9000
# Per-user cap on live YouTube searches per day. search.list costs 100 units each, so the
# shared budget only affords ~80-90/day total — this stops one user draining it. Admin-tunable.
search_daily_limit_per_user: int = 70
# Recent-first backfill: how far back to fetch on the first pass per channel.
backfill_recent_max_videos: int = 100
backfill_recent_max_days: int = 365

View file

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

View file

@ -175,6 +175,12 @@ class Channel(Base, TimestampMixin):
backfill_done: Mapped[bool] = mapped_column(
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")
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel")
@ -238,6 +244,12 @@ class Video(Base, TimestampMixin):
shorts_probed: Mapped[bool] = mapped_column(
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
live_status: Mapped[str] = mapped_column(
String(16), default="none", server_default="none", index=True

View file

@ -36,6 +36,7 @@ class QuotaAction:
VIDEOS_BACKFILL_FULL = "videos_backfill_full"
VIDEOS_ENRICH = "videos_enrich"
VIDEOS_LOOKUP = "videos_lookup"
VIDEOS_SEARCH = "videos_search"
CHANNELS_DISCOVER = "channels_discover"
CHANNELS_SUBSCRIBE = "channels_subscribe"
CHANNELS_UNSUBSCRIBE = "channels_unsubscribe"
@ -109,6 +110,26 @@ def can_spend(db: Session, units: int) -> bool:
return remaining_today(db) >= units
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:
"""Atomically add `units` to today's counter (upsert) and log an attribution event."""
if units <= 0:

View file

@ -124,6 +124,7 @@ def _filtered_query(
include_live: bool,
show: str,
scope: str = "my",
library_source: str = "organic",
exclude_tag_category: str | None = None,
) -> tuple[Select, object]:
"""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.
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:
query = query.where(Video.channel_id == channel_id)
if min_duration is not None:
@ -193,8 +205,15 @@ def _filtered_query(
) + timedelta(days=1)
query = query.where(Video.published_at < end)
if q:
like = f"%{q}%"
query = query.where(or_(Video.title.ilike(like), Channel.title.ilike(like)))
# Accent-insensitive: unaccent() both sides so "tiesto" matches "Tiësto" (ILIKE alone
# 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:
# AND across tag categories (e.g. language AND topic narrows), OR within a
@ -271,6 +290,7 @@ def _feed_params(
include_live: bool = False,
show: str = "unwatched",
scope: str = "my",
library_source: str = "organic",
) -> dict:
return {
"tags": tags,
@ -287,6 +307,7 @@ def _feed_params(
"include_live": include_live,
"show": show,
"scope": scope,
"library_source": library_source,
}

View file

@ -0,0 +1,204 @@
"""Live YouTube search.
Searches YouTube directly (search.list) 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). search.list costs **100 quota units per call**, so it's the most expensive
read by far: it's gated behind a per-user daily cap + a budget pre-check, and only real users
(never the shared demo account) may call it.
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.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.")
# Guard the shared quota BEFORE spending: per-user daily cap, then today's budget.
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.",
)
if 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:
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_SEARCH):
page = yt.search_videos(term, page_token=cursor)
except YouTubeError 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"]),
}
# 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"]),
}

View file

@ -39,6 +39,7 @@ SPECS: tuple[ConfigSpec, ...] = (
# --- Quota (shared YouTube Data API daily budget) ---
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("search_daily_limit_per_user", "int", "quota", "search_daily_limit_per_user", min=0, max=100_000),
# --- 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_days", "int", "backfill", "backfill_recent_max_days", min=1, max=36_500),

View file

@ -351,6 +351,49 @@ class YouTubeClient:
"""Delete a whole playlist on YouTube. 50 units."""
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]:
items: list[dict] = []
for batch in _chunks(video_ids, 50):

View file

@ -122,6 +122,9 @@ export default function App() {
const saveMsgTimer = useRef<number | undefined>(undefined);
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
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).
const [ytSearch, setYtSearch] = useState<string | null>(null);
const [wizardOpen, setWizardOpen] = useState(false);
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all");
@ -153,6 +156,8 @@ export default function App() {
};
useEffect(() => {
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]);
function openReleaseNotes(highlight?: string) {
@ -500,6 +505,7 @@ export default function App() {
filters={filters}
setFilters={setFilters}
page={page}
onYtSearch={setYtSearch}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
@ -572,6 +578,8 @@ export default function App() {
canRead={meQuery.data!.can_read}
isDemo={meQuery.data!.is_demo}
onOpenWizard={() => setWizardOpen(true)}
ytSearch={ytSearch}
setYtSearch={setYtSearch}
/>
)}
</main>

View file

@ -1,10 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, RefreshCw } from "lucide-react";
import { api, type FeedFilters, type Video } from "../lib/api";
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, ArrowLeft, RefreshCw, Youtube } from "lucide-react";
import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
import i18n from "../i18n";
import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import VirtualFeed from "./VirtualFeed";
import PlayerModal from "./PlayerModal";
@ -70,6 +71,8 @@ export default function Feed({
canRead,
isDemo = false,
onOpenWizard,
ytSearch,
setYtSearch,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
@ -77,6 +80,10 @@ export default function Feed({
canRead: boolean;
isDemo?: boolean;
onOpenWizard: () => void;
// Live YouTube search: the active search term (null = normal feed), and its setter so the
// empty-state CTA / "back to feed" can toggle it. Search affordances are hidden for demo.
ytSearch: string | null;
setYtSearch: (q: string | null) => void;
}) {
const { t } = useTranslation();
const [overrides, setOverrides] = useState<Record<string, string>>({});
@ -92,11 +99,34 @@ 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({
queryKey: ["feed", filters],
queryFn: ({ pageParam }) => api.feed(filters, pageParam as string | null, PAGE),
queryKey: ["feed", queryFilters],
queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE),
initialPageParam: null as string | null,
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. Each page spends 100 quota units, so we never auto-paginate
// on scroll — the user pulls more with an explicit button. 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
@ -113,9 +143,11 @@ export default function Feed({
}, [query.dataUpdatedAt]);
const countQuery = useQuery({
queryKey: ["feed-count", filters],
queryFn: () => api.feedCount(filters),
queryKey: ["feed-count", queryFilters],
queryFn: () => api.feedCount(queryFilters),
staleTime: 30_000,
enabled: !ytActive,
placeholderData: keepPreviousData,
});
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
@ -232,6 +264,94 @@ export default function Feed({
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v))
.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;
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.
setYtSearch(null);
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">{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") : 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.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
@ -311,6 +431,29 @@ export default function Feed({
</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 className="flex items-center gap-2 border-t border-border pt-2.5">
<span className="text-sm text-muted">
@ -372,7 +515,18 @@ export default function Feed({
<div className="p-4">
{toolbar}
{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={() => setYtSearch(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
items={items}

View file

@ -1,5 +1,5 @@
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 { Page } from "../lib/urlState";
import SyncStatus from "./SyncStatus";
@ -13,14 +13,20 @@ export default function Header({
setFilters,
page,
onGoToFullHistory,
onYtSearch,
}: {
me: Me;
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
page: Page;
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 canSearchYt = !me.is_demo;
const trimmedQ = filters.q.trim();
return (
<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" ? (
<div className="flex-1 max-w-xl mx-auto relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input
value={filters.q}
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
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 className="flex-1 max-w-xl mx-auto flex items-center gap-2">
<div className="flex-1 relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input
value={filters.q}
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
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 className="flex-1 text-center text-sm font-semibold">

View file

@ -1,6 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
Check,
ChevronDown,
@ -36,6 +36,7 @@ import {
} from "../lib/sidebarLayout";
import { shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import { Switch } from "./ui/form";
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,
// 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.
// 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({
queryKey: ["facets", filters],
queryFn: () => api.facets(filters),
queryKey: ["facets", facetFilters],
queryFn: () => api.facets(facetFilters),
staleTime: 30_000,
placeholderData: keepPreviousData,
});
const facetsReady = !!facetsQuery.data;
const facetCounts = facetsQuery.data?.counts ?? {};

View file

@ -19,6 +19,7 @@
"smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." },
"quota_daily_budget": { "label": "Tägliches Kontingentbudget", "hint": "YouTube-Data-API-Einheiten pro Tag (kostenloses Limit 10.000). Standard 9000 lässt Puffer." },
"backfill_quota_reserve": { "label": "Nachlade-Kontingentreserve", "hint": "Reservierte Einheiten, damit geplantes Nachladen interaktive Syncs / Anreicherung nie aushungert." },
"search_daily_limit_per_user": { "label": "Live-Suche — Tageslimit pro Nutzer", "hint": "Maximale Live-YouTube-Suchen pro Nutzer und Tag. Jede Suche kostet 100 Einheiten, das gemeinsame Budget reicht also für insgesamt nur ~80-90/Tag. 0 = Live-Suche deaktiviert." },
"backfill_recent_max_videos": { "label": "Aktuelles Nachladen — max. Videos", "hint": "Neueste Videos pro Kanal beim ersten Durchlauf." },
"backfill_recent_max_days": { "label": "Aktuelles Nachladen — max. Alter (Tage)", "hint": "Wie weit der erste Durchlauf pro Kanal zurückreicht." },
"shorts_probe_max_seconds": { "label": "Shorts-Prüfung — max. Dauer (s)", "hint": "Nur Videos bis zu dieser Länge werden als mögliche Shorts geprüft." },

View file

@ -29,5 +29,22 @@
"undo": "Rückgängig",
"markedWatchedNamed": "Als angesehen markiert: „{{title}}”",
"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",
"back": "Zurück zum Feed",
"searching": "Suche auf YouTube…",
"noResults": "Keine YouTube-Videos gefunden.",
"error": "YouTube-Suche fehlgeschlagen.",
"loadMore": "Mehr laden (verbraucht Kontingent)"
}
}

View file

@ -1,6 +1,8 @@
{
"feed": "Feed",
"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",
"usageStats": "Nutzung & Statistik",
"scheduler": "Planer",

View file

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

View file

@ -19,6 +19,7 @@
"smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." },
"quota_daily_budget": { "label": "Daily quota budget", "hint": "Total YouTube Data API units to spend per day (free tier is 10,000). Default 9000 leaves headroom." },
"backfill_quota_reserve": { "label": "Backfill quota reserve", "hint": "Units kept in reserve so scheduled backfill never starves interactive syncs / enrichment." },
"search_daily_limit_per_user": { "label": "Live search — daily limit per user", "hint": "Max live YouTube searches per user per day. Each search costs 100 units, so the shared budget only affords ~80-90/day total. Set 0 to disable live search." },
"backfill_recent_max_videos": { "label": "Recent backfill — max videos", "hint": "Newest videos fetched per channel on the first pass." },
"backfill_recent_max_days": { "label": "Recent backfill — max age (days)", "hint": "How far back the first pass reaches per channel." },
"shorts_probe_max_seconds": { "label": "Shorts probe — max duration (s)", "hint": "Only videos at or below this length are probed as possible Shorts." },

View file

@ -29,5 +29,22 @@
"undo": "Undo",
"markedWatchedNamed": "Marked watched “{{title}}”",
"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",
"back": "Back to feed",
"searching": "Searching YouTube…",
"noResults": "No YouTube videos found.",
"error": "YouTube search failed.",
"loadMore": "Load more (uses quota)"
}
}

View file

@ -1,6 +1,8 @@
{
"feed": "Feed",
"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",
"usageStats": "Usage & stats",
"scheduler": "Scheduler",

View file

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

View file

@ -19,6 +19,7 @@
"smtp_password": { "label": "SMTP-jelszó", "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra." },
"quota_daily_budget": { "label": "Napi kvótakeret", "hint": "Naponta elkölthető YouTube Data API-egységek (az ingyenes keret 10 000). Az alap 9000 tartalékot hagy." },
"backfill_quota_reserve": { "label": "Letöltési kvótatartalék", "hint": "Tartalékban tartott egységek, hogy az ütemezett letöltés ne éheztesse ki az interaktív szinkront / gazdagítást." },
"search_daily_limit_per_user": { "label": "Élő keresés — napi limit / felhasználó", "hint": "Maximális élő YouTube-keresés felhasználónként naponta. 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. 0 = élő keresés kikapcsolva." },
"backfill_recent_max_videos": { "label": "Friss letöltés — max videó", "hint": "Csatornánként az első körben letöltött legújabb videók száma." },
"backfill_recent_max_days": { "label": "Friss letöltés — max kor (nap)", "hint": "Az első kör csatornánként meddig nyúl vissza." },
"shorts_probe_max_seconds": { "label": "Shorts-vizsgálat — max hossz (mp)", "hint": "Csak az ennél nem hosszabb videókat vizsgáljuk lehetséges Shortsként." },

View file

@ -29,5 +29,22 @@
"undo": "Visszavonás",
"markedWatchedNamed": "Megnézettnek jelölve: „{{title}}”",
"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",
"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)"
}
}

View file

@ -1,6 +1,8 @@
{
"feed": "Hírfolyam",
"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ő",
"usageStats": "Használat és statisztika",
"scheduler": "Ütemező",

View file

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

View file

@ -141,6 +141,9 @@ export interface FeedFilters {
includeShorts: boolean;
includeLive: boolean;
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;
channelName?: string;
maxAgeDays?: number;
@ -306,6 +309,8 @@ function filterParams(f: FeedFilters): URLSearchParams {
if (f.dateTo) p.set("published_before", f.dateTo);
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
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;
}
@ -558,6 +563,14 @@ export const api = {
req(`/api/feed?${feedQuery(f, cursor, limit)}`),
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
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 YouTube nextPageToken (each page spends 100 units).
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> }> =>
req(`/api/facets?${filterParams(f).toString()}`),
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these

View file

@ -9,6 +9,7 @@ const KEYS = [
"show",
"sort",
"scope",
"source",
"normal",
"shorts",
"live",
@ -28,6 +29,9 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
if (f.show && f.show !== "unwatched") p.set("show", f.show);
if (f.sort && f.sort !== "newest") p.set("sort", f.sort);
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.includeShorts) p.set("shorts", "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("sort")) f.sort = params.get("sort") || "newest";
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("shorts")) f.includeShorts = params.get("shorts") === "1";
if (params.has("live")) f.includeLive = params.get("live") === "1";

View file

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