Add scheduler_settings (per-job interval override, migration 0015) and a
PATCH /api/admin/scheduler/jobs/{id} that persists the override and live-
reschedules the running APScheduler job. Intervals load from the DB (env
defaults as fallback); the snapshot reports the live trigger interval.
localdev no longer points at the shared database; it now runs its own
local Postgres and its own scheduler, fully decoupled. Removes the shared-DB
migration coupling (no more redeploys after migrations) and lets the
background scheduler actually run during local dev.
New admin Scheduler page (left-nav entry) with a live, self-refreshing view of
job activity, queued work and quota. Polling is factored into a reusable
useLiveQuery hook (pauses when the tab is unfocused) that the notification bell
and future yt-dlp job queue will reuse instead of re-implementing.
Record each scheduler job's run activity in-process (running / last result /
error / timestamps) and expose a snapshot. New admin-only GET
/api/admin/scheduler returns the per-job activity (with APScheduler next-run
times), the DB-derived work still queued (channels/videos pending, deep ETA,
live-refresh backlog) and the shared quota picture.
The fallback used a translucent background and left the iframe mounted, so
YouTube's own 'Video unavailable' screen bled through and overlapped our
message. Use a solid background and hide the iframe while the error shows.
The in-app IFrame player showed YouTube's bare 'Video unavailable' screen
with no way out when a video couldn't be embedded (auto-generated Topic
art-tracks disable embedding -> error 101/150; removed/private -> 100).
Catch the IFrame onError and overlay a clear message + 'Open on YouTube'
button, with a tailored note for embedding-disabled videos.
The shared-account warning was a requiresInteraction toast that re-fired
on every reload. Replace it with a permanent, non-dismissible banner (like
the version banner) shown above the content while in the demo account.
enrich_pending only touches enriched_at IS NULL, so a video first seen
while live was stamped live + duration-null and never revisited — staying
'live' with no duration forever after the broadcast ended. Add refresh_live
(run after each enrich pass) that re-fetches anything still live/upcoming,
plus just-ended was_live videos that haven't got their duration yet, until
they settle. Cheap: videos.list is 1 unit per 50 ids.
Live streams and upcoming/premiere videos have no fixed duration, so the
playlist row and video card left a blank where the runtime usually sits.
Show a LIVE (red) or upcoming badge instead, so it's clear the missing
time is by design, not a sync gap.
The shared demo account no longer hits YouTube affordances: the onboarding
wizard never opens (or renders) for it, the empty-feed prompt nudges into
the shared library instead of the connect wizard, and the browser-facing
/auth/upgrade redirects the demo home instead of returning a raw 403 JSON.
Login page quietly probes /auth/demo (debounced) as a valid email is
typed/pasted and reloads into the app on a match — no visible button.
Demo sessions default to the whole library, get a one-time shared-account
warning, never see the YouTube-connect onboarding/access UI or sync
actions, and admins get a demo whitelist + reset panel in Settings.
Admin endpoints to manage the demo email whitelist (DB-backed, no env)
and a manual reset that wipes the demo account's per-user state and
re-seeds a few sample playlists from the shared catalog.
Whitelisted emails enter the shared demo user via /auth/demo (lazily
created, no OAuth token/scope), rate-limited per IP and answering
uniformly so it can't be hammered as an enumeration oracle. Add a
require_human dependency that blocks the demo account from quota-spending
sync endpoints and the OAuth upgrade flow, and surface is_demo on /api/me.
Add the shared demo-account plumbing: users.is_demo marks the single
shared demo user, demo_whitelist holds the admin-curated emails that may
enter it without Google sign-in, and a small in-process RateLimiter
(generic groundwork) for throttling the demo-login endpoint per IP.
Bump VERSION to 0.4.1 and add the 0.4.1 release-notes entry (feed view/content-type chips
above the videos, key+direction sort, exact view count + upload date on cards/player).
Mirror the card change in PlayerModal: append the precise locale-aware date after the
relative time, for both the active video and a linked (navigated) video's stats.
The abbreviated count (e.g. '9B' for ~9 billion) can read like '98' since the B glyph
resembles an 8. Add a title tooltip with the full localized count on the views label.
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
Move content type and ordering out of the filter sidebar into a feed toolbar (approved
proposal 2): a prominent content-type chip row (Normal/Shorts/Live toggle chips) with the
sort control + count + reshuffle in a quieter row beneath it. The toolbar renders even on
'no matches' so content-type can be turned back on after it filtered everything out. The
sidebar keeps Show / Upload date / Language / Topic; 'sort' and 'content' widgets removed
from the layout (normalizeLayout drops them from any saved layout). New feed.sortLabel
string (HU/EN/DE).
Bump VERSION to 0.4.0 and add the 0.4.0 release-notes entry (Epic N: left nav sidebar,
multi-account switch, Settings page, in-app back, frosted-glass refresh).
Sessions created before multi-session existed had no account_ids, so adding a second
account left the first one (e.g. the long-lived session) absent from the switcher.
current_user now ensures the active user_id is always present in account_ids, so the
switch list is never missing the account you're using.
Track every account that completes OAuth in a browser session (session 'account_ids',
~14-day cookie), so the user can switch between them without another Google round-trip.
New GET /api/me/accounts (switchable accounts, active flagged) and POST /api/me/switch
(guarded: target must be in the session list — proof it signed in here — and re-checked
against is_allowed in case the invite was revoked). Logout now signs out the active
account and falls back to the most recent remaining one, else clears the session. UI: the
account popover lists the other signed-in accounts (click to switch, reloads) plus an
'Add another account' action (-> /auth/login, which uses prompt=select_account).
Trilingual. Third step of Epic N.
Dark glass looked nearly flat because the blurred backdrop is itself dark. Add a
brightness lift to backdrop-filter in dark mode for .glass/.glass-menu so the frosted
sheen reads even over dark surfaces (over colourful content it just looks richer).
Perf-mode still disables the blur (later rule wins).
The full-screen transparent dismiss backdrop sat directly behind the popover and acted as
a compositing layer, so the popover's backdrop-filter sampled it (empty) instead of the
page — making the glass look fully solid. Drop the backdrop and dismiss via document
mousedown/Escape listeners (like the other menus). Now backdrop-filter samples real
content and the frost shows.
Now that the popover is portaled to body its backdrop-filter actually samples the content
behind it, so the near-opaque glass-menu (needed only as a workaround while blur was
trapped) made it look solid. Switch it back to the frosty .glass (78%); the working blur
softens the background so text stays readable without the bleed-through. Trapped header
menus (language/notifications) keep glass-menu.
The nav's .glass backdrop-filter makes it a containing block for fixed descendants and a
stacking context, so the account popover's fixed inset-0 backdrop only covered the nav (not
the viewport) and the popover sat below the main content — controls behind it stayed
clickable. Portal the popover + its dismiss backdrop to <body> with fixed coords computed
from the trigger rect, so it's truly top-most (z-50) and blocks clicks underneath.
Floating menus hover over undimmed content (no backdrop scrim like dialogs), so the
frosted .glass (78%) let the content bleed through and hurt readability. Add a near-opaque
.glass-menu (surface 92%, keeps blur) and use it for the account, language, notification
and add-to-playlist popovers. Dialogs/chrome keep the frostier .glass (they sit over a dark
scrim / the ambient bg). perf-mode disables its blur too.
The dark-mode experiment made .glass globally more translucent (surface 66%), which
also hit content-overlapping surfaces like the account popover — text became hard to
read over the feed behind it. Drop that override so .glass is back to the readable 78%
everywhere; keep the richer dark ambient (harmless to readability). Docked chrome loses
the marginal extra frost (which was barely noticeable anyway).
Push an in-app history entry on each page switch (page rides in history.state, URL stays
clean — filters still never go in the URL) and sync the page from history.state on
popstate. The initial entry is stamped after the strip-params effect (which nulls
history.state). Now the mouse/browser Back button steps through visited pages instead of
jumping straight to the Google consent/redirect. Second step of Epic N.
In dark mode the faint accent pools left the docked frosted chrome (nav/header) looking
flat — no content behind it to refract, unlike dialogs over the feed. Boost the dark-mode
ambient pools and make .glass a bit more translucent there so the chrome reads as glass
near the accent pools. Light mode unchanged. The full app-wide dark effect still needs
Phase 2 (ambient content/mosaic), deferred.
Apply the real frosted .glass surface (blur) to the few large chrome surfaces — the nav
sidebar, the header and all Modal-based dialogs (was glass-card / plain surfaces) — and
enrich the ambient backdrop a touch (three soft accent pools) so the glass has more to
refract app-wide. Popovers (notifications, language, account, add-to-playlist) were
already glass. Bulk feed cards stay glass-card (no per-card blur) for performance; the
existing perf-mode still disables blur. Phase 2 (ambient thumbnail mosaic / bg image +
toggle) deferred to end-of-project polish.
Move Settings out of the right-side overlay into a left-nav page (page='settings'),
so it opens where you're already looking — no cross-screen mouse travel. The Settings
rail item now sets the page (with active highlight) instead of opening a dialog; the
panel is refactored to an in-flow .glass card (keeps the frosted look over the ambient
backdrop), with the page title shown in the header. Removed the overlay + Esc/backdrop
close path.
Move the modules (Feed/Channels/Playlists/Stats) out of the avatar dropdown into a
persistent left sidebar with icon+label entries; collapses to a thin icon rail
(persisted in localStorage). Account actions (About, Sign out, admin badge) move to a
popover at the sidebar bottom; Settings is a rail entry. Header is now a contextual top
bar (sync status, feed scope + search or page title, language, notifications) — logo and
account menu removed from it. New nav.json strings (HU/EN/DE). First step of Epic N.