A reusable BackToTop fades in once the active page's <main> is scrolled past ~600px and
smooth-scrolls it back to the top. Rendered once at the App root, portaled to <body> for
viewport-fixed placement (bottom-right, below the chat dock), and re-binds to the live <main> on
navigation (page or channel change) so it works on every scrollable page incl. the channel page.
EN/HU/DE.
Collapse the feed toolbar's two rows into one: the video count sits right after the Source
dropdown, and the sort controls move onto the same row, pushed right with ml-auto (wraps
gracefully on narrow widths). Applies everywhere the Feed renders (main feed + channel page).
Instead of a full-bleed banner, inset it with a margin and rounded corners so it reads as a
contained card; the avatar overlaps its bottom-left. Airier and more designed than edge-to-edge.
The channel page overlays the content column via channelView, which setPage didn't clear — so
clicking a rail item did nothing (and the next===page early-return blocked 'Feed' when a channel
was opened from the feed). setPage now clears channelView and proceeds even when the underlying
page is unchanged.
- Move the channel stats (subscribers / videos / views / joined) onto one meta line beside the
handle under the name, dropping the separate stats row.
- Cap the banner at 150px tall so it no longer dominates the page; the wide YouTube-style crop
is kept. Net: the video grid starts much higher.
Subscribers and Channel-priority sorts are meaningless when the feed is scoped to one channel
(both are constant across its videos), so hide them there via a channelScoped flag on Feed.
The main feed keeps all sorts.
The stored bannerExternalUrl is the full 16:9 banner template at a low default size (512x288),
so object-contain showed the whole padded image tiny and centered. Now request a crisp wide
version (=w1707) and object-cover the desktop safe-area band (centre 2560x423, ~6:1) at full
width — matching YouTube's banner crop.
- F5 on a channel page kept bouncing to the feed — the App init dropped history.state._chan
like the (intentionally non-replayed) YouTube search. Now channelView restores from _chan on
load and the stamp preserves it, so a reload stays on the channel page.
- The banner was stretched/cropped (bg-cover). Render it as an <img object-contain> with a
capped height, letterboxed on the surface colour, so its real aspect ratio is kept.
- The round avatar's top was clipped by the banner: the banner's position:relative container
painted above the static identity row. Give that row relative z-10 so the overlapping avatar
sits on top.
Refetch the channel detail after the auto-explore ingest so `explored` flips true and the
"Exploring" badge appears immediately — the GET that drove the first render predated the
explore that just created the ExploredChannel row.
Two bugs made clicking the channel name in the video modal just close the player:
- the second PlayerModal mount (main feed path) was missing onOpenChannel, so the handler
hit its no-op early return;
- opening the channel synchronously pushed the _chan history entry, which the player's own
useBackToClose teardown (history.back on unmount) then immediately popped. Now the open is
deferred to a one-shot popstate listener that fires AFTER that teardown, so the channel
entry lands at the feed level. Verified: player → channel name → channel page; Back → feed
(player does not reappear).
The PlayerModal channel name now opens our channel page (closing the player first); a
small external-link icon beside it keeps the open-on-YouTube behaviour. Threaded
onOpenChannel from Feed into both PlayerModal mounts.
Frontend for the channel-explore feature:
- ChannelPage: banner/avatar/stats header, Subscribe/unsubscribe, an "exploring" badge
while browsing an un-subscribed channel, Videos/About tabs. Reuses Feed scoped to the
channel (scope=all + source=all so the per-user ephemeral videos show). Auto-ingests
recent uploads on first visit (background, with a loading note) + "Load more from
YouTube" to page deeper; skipped for demo / already-subscribed channels.
- App: openChannel/closeChannel as a Back-aware sub-view (history.state._chan, mirrors the
YT-search _yt pattern); ChannelPage takes over the content column, nav rail stays.
- ChannelLink/cards/player: the channel name now opens our channel page (onChannelFilter →
onOpenChannel); the in-card "only this channel" filter button is dropped (the page
subsumes it). PlayerModal channel-name wiring follows in the next commit.
- api: channelDetail + exploreChannel; ChannelDetail/ExploreResult types.
- i18n EN/HU/DE: channel namespace, explore_cleanup scheduler labels, explore config group,
channels_explore quota label.
Adds the backend for a dedicated channel page + ephemeral browsing of un-subscribed
channels:
- migration 0033: channels.{total_view_count,published_at,banner_url,external_links,
from_explore}, videos.via_explore, explored_channels (per-user, grace-clocked).
- enrich channels with part=brandingSettings (banner/links) + statistics.viewCount +
snippet.publishedAt — no extra quota.
- GET /api/channels/{id}: About detail + this user's relationship; lazy-enriches the new
About fields (published_at sentinel).
- POST /api/channels/{id}/explore (require_human, quota-reserve guarded): records the
exploration, flags the channel ephemeral unless followed, ingests one page of uploads
(via_explore) + enriches; returns next_page_token for load-more.
- feed: per-explorer gate — a via_explore video is visible only to users who explored or
subscribe to its channel, so exploration never leaks into everyone's catalog.
- subscribe = keep: clears from_explore + via_explore on the channel's videos.
- scheduler explore_cleanup job + explore_grace_days config: hard-delete explored-but-
unkept channels and their untouched ephemeral videos after a grace period.
feature/mine-search-finds-and-fts:
- per-user search finds surface in the Mine feed + Source filter there
- full-text relevance ranking (new Relevance sort, auto-selected when searching)
- search broadened to a weighted document (title + creator keywords + the
queries that surfaced a video + description), DB-generated search_vector.
Makes the local search behave more like YouTube's — finding videos by the
uploader's own keywords or the query that surfaced them, not only words in the
title. A DB-generated, weighted search_vector (migration 0032) replaces the
title-only FTS index:
- keywords: the creator's snippet.tags (free — already in the snippet we fetch),
stored on enrich.
- search_terms: distinct live-search queries that surfaced the video (across all
users), appended by the search route — folds YouTube's relevance into local
search (a video YT returned for a query becomes findable by it even without a
title match), the user's own idea.
- description (truncated) for broad recall on the existing catalog.
Weighted title(A) > keywords+queries(B) > description(C) so ts_rank keeps title
hits on top. A plain GIN index on the generated column guarantees index use (no
expression/param matching). Verified on localdev: recall 146->213 for one query;
7 'eurovision' hits via the document but not the title; index scan confirmed.
The relevance auto-select ran in a Feed effect that raced per-keystroke query
updates, so fast typing left the sort on the default. Move the switch into the
header input's onChange so it's set atomically with the query (only overriding
the default newest sort); the Feed effect now only reverts to newest when the
term is cleared. 'Back to feed' also sets relevance explicitly.
Frontend for the Mine search finds + relevance search:
- The Source filter (organic / include search / search-only) now shows in Mine
scope too, not just the Library.
- Returning from a YouTube search via 'Back to feed' switches the Source filter
to 'search' so your just-found videos show in the feed you land on (filtered
by the kept term).
- New 'Relevance' sort, offered while a search term is present and auto-selected
when you start searching (reverts to newest when you clear it). EN/HU/DE.
Two related search improvements:
1) Your own live-search results now belong to your Mine feed. A new per-user
search_finds table (migration 0030) records each video you surface via your
YouTube search (the route inserts them idempotently). The Mine feed becomes
'your non-hidden subscriptions OR your search finds', and the Source filter
now applies in Mine too: organic = subscriptions, search = your search finds,
all = both (default stays organic, so the main feed is unchanged). The shared
Library keeps using the global via_search flag.
2) Feed search ranks by relevance instead of a whole-phrase substring. A custom
unaccent_simple text-search config + GIN index (migration 0031) back a
YouTube-like fuzzy match: word-order-independent, multi-word AND, prefix on
the word being typed, accent-insensitive. A new 'relevance' sort orders by
ts_rank; the channel name still matches as a substring. The rank is scaled to
an integer so the keyset cursor pages it exactly (a raw float4 breaks paging).
_filtered_query returns the rank expr so only the feed list uses it.
feature/search-scrape-zero-quota: live YouTube search can use YouTube's internal
InnerTube endpoint (admin-selectable search_source, default scrape) so it spends
no API quota; and the live-search results view now owns a history entry so Back
closes the player first and keeps the results.
Adds player-modal shortcuts and keeps keyboard focus on the modal (not the
cross-origin player iframe) on open and on player-ready, so they work until you
click into YouTube's native controls:
- F: toggle fullscreen (the player stage element, so the volume flash stays
visible; a :fullscreen CSS rule drops the 16:9 letterbox). Esc in fullscreen
exits fullscreen only, without also closing the modal.
- Space: play/pause (ignored while a button/input is focused).
- Scroll wheel over the video: volume up/down with an auto-fading volume-bar
overlay. A transparent interaction layer over the iframe catches the wheel
(the cross-origin iframe otherwise swallows it) and maps click to play/pause;
it leaves the bottom strip uncovered so YouTube's native control bar (seek /
settings / captions / fullscreen) stays usable, and is hidden on an embed
error so the 'Open on YouTube' CTA stays clickable.
i18n shortcutsHint EN/HU/DE.
The live-search results view had no browser-history entry of its own, so a
player opened over the results sat directly on the feed page entry. Pressing
Back (e.g. the mouse back button over the player) could pop past both the
player and the search in one step, bouncing from the search results to the
normal feed instead of just closing the player.
The search is now a feed sub-view that owns a history entry (history.state._yt):
entering a search pushes it, the popstate handler derives ytSearch from it, and
"Back to feed" pops it. Back now steps player -> search -> feed: the first Back
closes only the player (results stay), the second returns to the normal feed.
A reload drops any stale _yt so the first Back can't resurrect a gone search.
The search response now reports its source; in scrape mode (zero quota) the
results banner and Load-more button drop the 'uses quota' wording. Adds the
search_source toggle's labels/hints and updates the per-user-limit hint to note
the cost only applies to the api source. EN/HU/DE.
Live YouTube search can now use YouTube's internal InnerTube endpoint instead
of search.list, materialising results through the same enrich/provenance path
at zero API quota (search.list costs 100 units/page; scrape costs nothing, only
the cheap shared videos.list enrich is charged).
- youtube/search_scrape.py: InnerTube search returning the same page shape as
YouTubeClient.search_videos (items + continuation cursor); SOCS consent cookie,
videoRenderer walk (channels/playlists/Shorts shelves naturally excluded),
type:video filter, cached InnerTube key/version with constant fallbacks.
- routes/search.py: admin-selectable source (search_source, default scrape).
Scrape path skips the 100-unit budget pre-check and logs a zero-cost search
event so the per-user daily cap still counts it; api path unchanged. Response
carries the active source.
- sysconfig/config: new search_source key (scrape|api).
- quota.log_action: record a zero-cost action event for per-user rate limits.
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.