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.
A live search ingests new catalog videos, but the normal feed query was disabled during
search mode and kept its pre-search cache — so 'Back to feed' showed the stale (often empty)
result until a manual reload. Drop the feed / feed-count / facets caches when leaving search
mode so the feed re-fetches fresh and immediately reflects the just-ingested videos.
Feed text search used plain ILIKE, which is case- but not diacritic-insensitive, so 'tiesto'
missed the many titles spelled 'Tiësto' — a search that ingested ~45 results showed only ~12.
Enable the postgres unaccent extension (migration 0029) and wrap both sides of the title/
channel match in unaccent(), so 'tiesto' now matches 'Tiësto'. Applies to feed, count and
facets alike.
The new library_source provenance filter wasn't serialised into the Share-view URL, so a
copied link lost the 'search results only' (or 'include search') selection. Add it as the
'source' param (emitted only in 'all' scope, omitted for the default 'organic').
Typing in the search box changed the feed/count/facets query keys on every keystroke, so
each query dropped to its loading state and blanked the content — the whole feed area
flickered. Debounce the search term feeding the queries (the input still updates instantly)
so they only re-run after a pause, and keep previous results on screen during a refetch via
placeholderData: keepPreviousData, so the feed and tag counts update in place without blanking.
Replace the binary 'show search-discovered' toggle with a 3-way Source selector in the
Library toolbar, so users can also see ONLY search-discovered videos — not just hide or
mix them. Backend: feed param library_source = organic (default, hides via_search) | all
(both) | search (only via_search), applied in scope=all. Strings in HU/EN/DE.
Surface live YouTube search in the existing feed, triggered explicitly so the
expensive API call is never per-keystroke.
- Header: the search box still filters the local catalog as you type; Enter or a
YouTube button escalates the term to a live search (hidden for the demo account).
- Feed: a dedicated infinite query renders results in the same VirtualFeed cards +
in-app player, under a banner with a back button and a quota note. No auto-paginate
(each page spends 100 units) — an explicit 'Load more (uses quota)' button instead;
quota/limit errors (incl. 429) shown inline. The empty local feed offers a
'Search YouTube for <q>' CTA.
- Library: a 'Search-discovered' toggle reveals search-ingested videos (hidden by
default); sent as exclude_search_discovered.
- Admin: search_daily_limit_per_user config field; new videos_search quota label.
- All new strings translated in HU/EN/DE.
Add a live YouTube search that materialises results into the shared catalog so
they render with the normal feed cards + in-app player and gain per-user state.
- YouTubeClient.search_videos(): search.list (100 units), embeddable-only, returns
flat stubs + nextPageToken; surfaces liveBroadcastContent for live filtering.
- routes/search.py GET /api/search/youtube: require_human + per-user daily cap
(search_daily_limit_per_user, default 70) + can_spend pre-check (429 on either);
drops live/upcoming, upserts channel stubs (channels.list) + video stubs, enriches
(videos.list), runs the youtube.com/shorts probe, then excludes Shorts/live and
returns feed cards in relevance order with the YouTube pageToken as the cursor.
- Provenance: videos.via_search / channels.from_search (migration 0028) flag
search-discovered rows; the feed hides them from the Library (scope=all) by default
via exclude_search_discovered, leaving the Mine feed untouched.
- quota.actions_today() counts a user's per-action events today for the cap; only the
search.list call is attributed VIDEOS_SEARCH so the counter is exactly 1 per search.
Fix: switching accounts no longer lands the new user on a stale chat thread inherited
from the previous account (an empty self-thread where new messages never appeared).
Clear _sub/_ov from history.state before the switch reload + guard partnerId===meId.
User-confirmed on localdev with two real accounts.
Switching accounts does location.reload(), which preserves history.state — so the new
account re-mounted the Messages module from the previous account's _sub sub-view, whose
partnerId belongs to the OLD identity (often the new account's own id → an empty
self-thread). Symptom: after switching to the recipient you land on a blank thread and
the just-received message never shows (F5 keeps history.state); only going back to the
list and reopening the real conversation works.
Fix: (1) strip _sub/_ov from history.state before the switch reload so the new account
starts at the module root; (2) guard Messages so a thread view with partnerId === meId
falls back to the conversation list (can't message yourself).
Moves the ~140 lines of pure URL/timestamp/hashtag parsing + renderDescription out
of the 659-line PlayerModal into a standalone, testable module. No behavior change.
- formatEta was hardcoded English (violated the trilingual rule) -> time.eta.* keys
in EN/HU/DE; Scheduler's countdown 'now' likewise.
- relativeFromMs added to format.ts; NotificationsPanel drops its duplicate
relativeTime/relativeFromMs and the now-orphaned notifications.time.* keys.
- Channels' fmtTotalDuration moved to format.ts as formatTotalHours.
lib/useDismiss.ts replaces the identical mousedown-outside + Escape effect that
DataTable (filter popover), Channels (tag picker) and AddToPlaylist each hand-rolled.
Positioning stays with each caller (it genuinely varies); AddToPlaylist keeps its
own resize/scroll reposition effect.
One Save/Discard bar with an inline (Settings card) and floating (Config page)
variant, replacing the two near-identical hand-rolled bars + their state machine.
components/ui/form.tsx replaces the per-panel copies:
- Switch — was duplicated in SettingsPanel (Switch) + ConfigPanel (Toggle) +
Sidebar (inline labeled toggle).
- Section (with a card variant) — was in SettingsPanel + Stats (plain) and
AdminUsers (glass card).
- SettingRow + HintLabel — the label+hint+control row was identical in SettingsPanel + Stats.
No visual change intended (Sidebar's toggle gains the standard knob shadow).
- errorDialog + hints now use createStore (drop their bespoke listener arrays).
- theme/sidebarLayout/notifications/App filters/Playlists plSort use readMerged/
readJSON/writeJSON instead of inline try/JSON.parse/catch.
- Stats, SettingsPanel and App's channel filter/view tabs use usePersistedState
(the 3 sites that reinvented usePersistedTab inline).
- Every siftlode.* key now sourced from the LS registry (no scattered literals).
- lib/store.ts: createStore<T> — one observable-value primitive (get/set/subscribe/use)
replacing the hand-rolled listeners/subscribe/emit triad several modules each had.
- lib/storage.ts: LS key registry (every siftlode.* key in one place), readMerged/
readJSON/writeJSON (the try/JSON.parse/merge/catch done once), and usePersistedState
(the reactive persisted-string hook, generalizing usePersistedTab).
TimestampMixin (created_at) + UpdatedAtMixin (updated_at) replace the repeated
column blocks across 9 + 3 models. Models that index created_at or default it
Python-side keep their own column on purpose. Verified zero schema drift via an
identical alembic-check diff before/after.
- feed_columns() is the single feed-row projection, consumed by both /feed and the
playlist-detail query (was copy-pasted, drift risk on every new column).
- _replace_items_from_youtube() dedups the YouTube playlist-mirror block shared by
sync_user_playlists and repull_playlist.
- _next_playlist_position/_next_item_position/_add_item replace the append-position
idiom (x4) and the exists-then-insert blocks (x2).
- quota.measured() context manager folds the before/after units diff that the
manual sync routes each re-spelled (also makes quota_remaining_today consistent).
- sync pause/resume now use Depends(admin_user) instead of inline role checks.
- is_messageable_user() unifies the 'real, active, non-suspended human' rule that
was encoded three ways (WS auth, send recipient, and the SQL _messageable()).
- app/utils.py: shared valid_email() + now_utc() (one email regex, was duplicated
in auth.py and admin.py with a looser "@"-in check elsewhere).
- security.hash_token(): one SHA-256 token hasher (was duplicated in auth.py + state.py).
- auth.admin_user dependency + count_admins() helper, replacing the inline role
checks and the last-admin count query repeated across admin.py/me.py/tags.py.
Watch later became a built-in playlist, so "saved" is no longer a VideoState
status — the scan matched nothing and under-counted users impacted by a video
removal. The playlist loop already covers Watch later, so impact is now correct.
Route the local _now through the shared utils.now_utc.
In-app history only tracked the top-level page, so Back from a module sub-view
(e.g. a Messages thread) or with a modal open jumped straight to the previous
module. Add two history primitives: useHistorySubview (a module's sub-view rides
in history.state, so Back returns to its root first) and useBackToClose (a
mounted overlay occupies one history entry; Back closes the topmost, nesting-safe
so a button-close doesn't trip the modals underneath). Apply to the Messages page
views and to PlayerModal + the shared Modal. setPage now pushes a clean entry so
each page starts at its root.
The admin access-requests notice was issued from the on-user-load effect (keyed
on user id), so it only re-resolved on a fresh load — it lingered until F5 after
an approval dropped the pending count to 0. Move it to its own effect keyed on
the pending count, so approving/denying (which refetches /api/me) clears or
re-counts the notice live.
The pre-login ?access=requested (and ?verify/?reset) lingered in the address bar
after login. Strip the query string on auth (the logged-in app reads nothing from
the URL); Welcome still reads its params while logged out. stripUrlParams now
preserves history.state so it's safe to call after the in-app page stamp.
The access-requests notice's Review link navigated to the Users page but the
persisted tab (often Demo) loaded instead. Pre-select the access tab before
navigating (focusAccessRequestsTab writes the persisted-tab key, which the page
reads on its fresh mount).
New youtube_api_proxy setting (env fallback + admin Config UI, env YOUTUBE_API_PROXY):
when set, the YouTubeClient routes all its httpx traffic through that HTTP(S) proxy.
Lets a dynamic-IP host send API calls through a fixed-IP host (e.g. the server over
private tunnel) so an IP-restricted API key keeps working. Empty = direct.
Eight confirmations stated a bare fact without saying what they acted on. Pass
the entity through the mutation variables and interpolate it: channel
unsubscribe/reset name the channel, whitelist/demo-whitelist adds name the
email, tag delete names the tag, and playlist revert/push name the playlist.
EN/HU/DE.
The ring-based flash relied on ring-accent and a CSS transition that never
restarted on a repeat message, so it was effectively invisible. Replace it with
a keyed inset-glow overlay (animation replays on every flash counter bump, and
inset shadow isn't clipped by the window's overflow-hidden) — flashes whether
the window is expanded, freshly auto-opened, or minimised.
The Messages UI trusted only the per-device IndexedDB key, so if the server's
key record was gone (deleted, DB-restored, admin-reset) while a stale private
key lingered in the browser, the app looked 'unlocked' but no one could be
messaged and no setup was offered. Add useKeyState (server 'configured' AND
local unlock): show setup when the server has no key (setup overwrites the stale
local key), unlock when it has one this device hasn't opened, ready otherwise.
ChatThread is now self-contained. Also fix the header title on the Messages and
Notifications pages (was falling through to 'Channel manager').
The live-message push now carries both parties, so an incoming message opens
that conversation's dock window if it's closed, or flashes it once if it's
already open (expanded or minimised, without disturbing the minimised state) —
only for messages from someone else, never your own echo.
Dock state (open windows + minimised state) is persisted per user, so a reload
restores exactly what was open, minimised, or closed.
Move messaging out of the notification center into its own left-nav module
(own page, own unread badge), so a reload returns to it and notifications stay
separate. Add a Messenger-style floating dock (bottom-right): pop a conversation
out from the page, keep chatting across navigation, minimise (rollup) or close.
- Messages is now a routed page; NotificationsPanel reverts to inbox-only and the
nav badge no longer mixes in messages.
- Extract shared KeyGate + ChatThread (used by both the page and dock windows);
e2ee exposes a shared unlock subscription so page and dock agree.
- ChatDock (always mounted for human users) owns the app-wide live-message
subscription and per-device key restore. EN/HU/DE strings.
A Messages tab in the notification center (hidden for demo): set up secure
messaging with a passphrase (key generated + wrapped in-browser via WebCrypto,
stored non-extractable per device), unlock on other devices, then chat with
end-to-end encrypted, live-delivered messages. The server-authored Siftlode
welcome is readable before any key setup.
- lib/e2ee.ts: ECDH P-256 + HKDF + AES-GCM, PBKDF2-wrapped key, IndexedDB.
- lib/messagesSocket.ts: WebSocket client with backoff reconnect.
- Messages.tsx: key gate, conversation list with decrypted previews, directory,
thread with client-side decrypt + encrypt-on-send. Unread feeds the nav badge.
EN/HU/DE strings.
Private user-to-user messages are end-to-end encrypted: the server only ever
stores ciphertext + iv and acts as a key directory (public keys) plus an opaque
store for each user's private key, wrapped client-side with a passphrase the
server never sees — so not even an admin can read a conversation. A separate
kind=system message (plaintext, no sender) powers a server-authored Siftlode
welcome shown on first open, reusable later for announcements.
- models: rework Message (kind, nullable sender/body, ciphertext+iv) + MessageKey;
migrations 0026 (table) + 0027 (E2EE rework).
- routes/messages.py: key directory/blob endpoints, ciphertext send, conversations
+ threads (system + user), lazy welcome, all gated by require_human.
- realtime.py: in-process WebSocket connection registry; /ws delivers sent
messages to a user's open tabs instantly (sync-callable push, single-process).
Render only the visible rows via @tanstack/react-virtual (useVirtualizer
against the app's <main> scroll container, per-row dynamic measurement),
so the DOM no longer accumulates every loaded card. A ResizeObserver keeps
the responsive grid column count in sync and chunks cards into virtualized
rows; the list view virtualizes single-card rows. Infinite scroll now
triggers from the virtual range and pages via next_cursor instead of
offset; feed/count drops its unused paging args.
Replace deep-OFFSET paging (which scans-and-discards skipped rows on the
233k-row catalog and shifts/duplicates rows as the scheduler ingests mid-
scroll) with stable keyset pagination over an opaque cursor.
A single sort registry drives both the ORDER BY and a generic, NULL-aware
keyset WHERE so they can never drift, with Video.id as the unique final
tiebreaker. Covers every sort: newest/oldest, views, duration, title,
subscribers (joined), priority (coalesced multi-key) and the seeded
shuffle. The response now returns next_cursor instead of offset.