- Link a Google account to a password account, and adopt the Google identity onto a matching
email account instead of 500ing on a duplicate; set or change a password from Settings.
- Expose has_google/has_password on /api/me for the Sign-in methods UI.
- Mark Google logins email-verified (backfill existing rows, migration 0024); stop a routine
login from clobbering an admin-assigned role (env ADMIN_EMAILS stays the bootstrap admin).
- Suspension login-gates (password + Google callback + current_user) with a rate-limited
'suspended' notice; shared purge_user (cascade delete + access-request cleanup + Google-grant
revoke) behind self- and admin-deletion; single app_base source for user-facing email links.
The pending-access-requests notification still said "review in Settings -> Account",
but that moved to the new Users page in 5a. Update the text (EN/HU/DE), and give the
notification a durable inbox link ("Review" -> Users page) via a new access-requests
meta kind (persists across reload, unlike a live action callback). Also stop it
piling up: persisted notices reload as dismissed history, so the old dedupe never
matched and a fresh copy was added every load — now removeByMetaKind drops any prior
access-requests notice before re-issuing, keeping exactly one current nudge.
Add DELETE /api/me/account: permanently erases the signed-in account and all its
personal data — OAuth tokens, subscriptions, tags, video states, playlists,
notifications all cascade on the users row (FK ON DELETE CASCADE); quota-audit
events are kept but anonymised (SET NULL); the email's invite/whitelist row is
removed too. Guards: the shared demo account can't be deleted, and the last
remaining admin can't delete itself. Frontend: a Danger zone in Settings -> Account
(non-demo) with a danger-confirm; on success the session clears and the app lands
on the welcome page. EN/HU/DE. Verified via curl: self-delete + session clear +
demo 403.
Replace the old Google-only login card with a public landing page (Welcome.tsx):
hero + auth card (email+password Sign in / Create account / Forgot password,
Continue with Google, explicit Try-the-demo) over a feature pitch (6 pain->solution
cards) and demo-account screenshot slots (graceful placeholder until the images are
dropped into frontend/public/welcome/). Auth forms wire to the 5a endpoints; errors
show inline via a new req() quiet flag (no global modal for a wrong password).
Handles ?verify, ?reset (set-new-password form), ?access banners. EN/HU/DE.
Verified end-to-end on a fresh load: render, register->approve->password-login.
New admin-only Users page (sidebar): Users & roles (list + promote/demote with
confirm; self/demo/last-admin guarded server-side), plus the Access requests
(Invite whitelist) and Demo whitelist+reset migrated out of Settings → Account
(same data/tables — UI relocated only). Settings → Account now holds personal
content only. ConfigPanel learns a boolean field type (toggle) for the new
allow_registration setting. api methods, new 'users' page/route/nav/header, and
EN/HU/DE strings (new users namespace + access group).
Cut a version marker for the accumulated work staged on dev (epics 1-4): reusable
Banner; Discover total-videos column + subscribe confirm; Stats consolidation +
quota-action taxonomy; DB-backed system_config + admin Configuration page. NOT
deployed to prod yet — dev/localdev report 0.12.0, prod stays 0.11.2 until a ship.
Alembic head at this marker: 0021.
New admin-only Configuration page that renders the system_config registry grouped
(Email/SMTP first), with per-field save/reset, write-only encrypted secret fields
(disabled with a hint when TOKEN_ENCRYPTION_KEY is unset), and a Send-test-email
button. New 'config' page + sidebar nav item + header title; api methods and
EN/HU/DE strings.
The quota-attribution action keys were inconsistent (mixed verbs, ad-hoc names,
English-only labels). Introduce a QuotaAction constants holder (one source of
truth), rename every attribution call site to it, and add migration 0020 to
rename historical quota_events.action values. The display label now resolves
from i18n (quotaActions.<key>, EN/HU/DE) instead of a hard-coded English map.
Scheduler job ids and progress phase labels are a separate namespace and are
left untouched.
The "avatar + name + open-on-YouTube" cell and the @handle-or-/channel/<id>
URL were copy-pasted across the channel manager, the discovery tab, the
subscribe notice and the player. Extract a single ChannelLink component
(optional in-app onView, middle-click opens YouTube) and a channelYouTubeUrl
helper, and route all four through them. Removes the NameCell / DiscoveryNameCell
duplication (the latter introduced with the discovery tab).
Extend the reload-safe meta+link pattern (introduced for "Subscribed") to
more inbox entries, so every notice that can point somewhere does:
- A "Marked watched" activity notice now offers "Find in feed" (jumping to
the feed filtered to Watched + that channel), alongside Unwatch — matching
what the "Hidden" notice already does. VideoWatchedMeta carries the
channel, and locate() handles both hidden and watched.
- A scheduler job-completion notice (System) now offers "Open Scheduler",
jumping to the dashboard.
Maintenance notices reference already-removed videos, so they stay
informational with nothing to navigate to.
The header's sync indicator spun "fetching history" whenever any channel
still lacked full history — i.e. perpetually, even when the scheduler was
idle between its periodic runs. It now spins only while a channel-sync job
(backfill or RSS poll) is actually running; otherwise pending deep-history
work shows as the calm, static "N without full history" link, and recent
work queued for the next run shows a static "N queued". This makes the
header coherent with the Scheduler dashboard's live state.
Backend exposes running_job_ids() from the scheduler activity and a derived
sync_active flag on /api/sync/my-status.
The dashboard now renders a progress bar for any running job — determinate
when counts are reported, an indeterminate "working" sliver otherwise — so a
scheduled run is as visible as a manual one (progress was never manual-only;
the wiring is shared, but only some jobs reported and the display gated on
counts).
Poll faster (1.5s) while any job runs, easing back to 4s when idle, derived
from the freshest data by react-query's functional refetchInterval. The
earlier React-state approach to this stalled the live updates (the row froze
on the first sampled value); useLiveQuery now accepts a function of the data.
Trilingual phase labels for the newly-reporting jobs.
Add a "Discover from playlists" tab to the Channel manager that lists
channels appearing in the user's playlists they don't subscribe to, with
a one-click Subscribe.
- GET /api/channels/discovery: local join (playlist_items -> videos ->
channels) minus the user's subscriptions and their own channel. Enriches
stub channels' metadata up front (title/thumbnail/subscriber count via the
API key) so the user can judge a channel before subscribing; videos are
not pulled (the scheduler picks those up).
- POST /api/channels/{id}/subscribe: write-scope gated, subscriptions.insert
+ local Subscription with the returned resource id.
- YouTubeClient.insert_subscription / get_my_channel_id.
- users.yt_channel_id (migration 0019) caches the user's own channel id so
discovery can exclude it.
- Frontend: ChannelDiscovery DataTable, Channels tab toggle (persisted),
api methods, trilingual strings. Subscribe ships a typed notification
payload (ChannelSubscribedMeta) for the inbox to act on.
The note claimed 'leaving the page with unsaved changes asks first', but the
beforeunload prompt was deliberately removed (native dialog; we use in-app
ones). Only in-app section switching is guarded — reword to say exactly that.
Settings-page prefs (theme/scheme/dark-mode/list-view/perf/hints/font + the
notification settings) were each auto-saved to the server on every toggle via
fire-and-forget savePrefs().catch(() => {}) — silent on failure, and no
positive confirmation on success, so the user had zero feedback either way.
Make them a draft instead: changes apply locally for instant preview but
persist only on an explicit Save (or revert on Discard). App owns the live
draft + the last-saved baseline, computes dirty, and exposes a controller to
the panel. The panel shows a Save/Discard bar with 'Saving…' → 'Settings
saved' (auto-clearing) / 'Couldn't save' feedback. Leaving the page with
unsaved changes prompts a confirm (in-app nav + browser Back), and a
beforeunload guards reload/close. savePrefs is now idempotent so the Save
survives a transient gateway blip; failures surface via the connection-lost
status. Language & sidebar layout stay instant (edited outside this page).
New i18n keys settings.save.* / settings.unsaved.* in EN/HU/DE.
The notice promised 'this will clear once it's back', but nothing ever
removed it: the toast auto-hid on a 15s timer (not on recovery) and the
v0.9.0 unified inbox kept every notify() in persistent history, so the
notice lingered there forever.
Model it as a single live status instead: one sticky, transient (never
persisted) notice while the server is unreachable, removed the moment any
request reaches the server again. Adds a 'transient' notification flag
(sticky toast + excluded from history so a reload can't orphan it) and
replaces the throttled-toast helper with a connectivity lost/restored pair.
i18n the strings (errors.offline.*) in EN/HU/DE — they were hardcoded English.
Retry GET (and opt-in idempotent writes: setState/clearState/saveProgress)
once on a fresh connection after a network error or 502/503/504, masking a
transient proxy<->upstream keepalive reset. If it still fails, treat
502/503/504 as the soft self-clearing 'Connection lost' toast instead of the
blocking error modal — a gateway that can't reach the app means 'restarting',
not a request the app refused. Genuine 500s keep the modal.
Fold the client-side transient bell into the inbox page so there's a single
notification indicator. The inbox now has two groups — "System" (durable,
server-backed) and "Activity" (client-side events with their Unhide/Unwatch/
Find-in-feed actions) — and the nav badge sums both unread counts. The separate
rail bell (NotificationCenter) is removed.
Activity items get a per-item clear (X) alongside the global Clear all. Unhiding
or unwatching a video from anywhere — a card, the toast's Undo, or the inbox —
now quietly resolves the original "Hidden/Watched X" entry (no duplicate "Unhidden
X" toast, no stale entry with a dead action), via a new resolveVideo store helper.
Expose the maintenance job's rolling re-validation batch (videos re-checked per
run) as an admin control on the Scheduler dashboard, stored in app_state
(migration 0018; NULL = the env/config default). The job reads the effective
value each run via a state helper, clamped to a sane range. Trilingual.
Add per-job "Run now" buttons and a "Start all now" button to the admin Scheduler
dashboard (admin-gated endpoints). Triggers run the job in a background thread
independent of its interval, refusing a concurrent run (409). While running, the
long jobs (maintenance, enrich, backfill, shorts) report live progress through a
decoupled contextvar sink, shown as a progress bar on the job row via the existing
4s poll. A manually-triggered run posts a completion notification to the triggering
admin's inbox (scheduled runs stay silent to avoid spam); the inbox renders the
"scheduler" type trilingually from type+data. While here, give the maintenance job
its missing dashboard label/description in all three languages.
Bump VERSION to 0.9.0 and add the user-facing release notes for the durable
notification inbox (P1) and the maintenance/validation job that retires videos
that can no longer be played.
Add a server-backed notification center that coexists with the client-side
transient bell: a per-user `notifications` table (type/title/body/data JSON/
read/dismissed), a `/api/me/notifications` CRUD API (list, unread_count, read,
read_all, dismiss, clear), and a left-nav inbox module with a live unread badge
polled via useLiveQuery. Known types render trilingual text from type+data
(English stored text is the fallback); read rows are trimmed past a soft cap.
Also adds the schema the maintenance job builds on: videos.list?part=status
columns (embeddable/privacy_status/upload_status) and the validation lifecycle
columns (last_checked_at, unavailable_since, unavailable_reason).
Page-id validation is centralized in one PAGES source of truth (isPage) so the
new page survives reload without a second allowlist to keep in sync.
- Channel-row tags show only what's attached; a '+' opens a per-channel picker. Clicking a tag
filters the feed by it; a 'Your tags' sidebar widget makes that filter visible/clearable.
- Tag manager dialog (add/rename/delete; delete only confirms when the tag is in use), reachable
from the Channel manager and the feed sidebar; hovering a tag's count lists its channels, each a
link that focuses it in the Channel manager (DataTable gains an external filter input).
- Video cards get a reset action that clears all watch state (incl. an in-progress position).
- api.req() now raises the error dialog on 5xx and 400/409/422 with the server's reason.
- Duplicate-tag create/rename hit the uq_tags_user_name constraint and 500'd; now caught and
returned as a clear 409 ('You already have a tag named …').
- New global error dialog (errorDialog store + ErrorDialog modal) for definitive server refusals;
the api layer wiring + mount land with the rest of the round.
- Modal now keeps a stack so ESC dismisses only the top modal — an error over the tag editor
closes the error and leaves the editor open.
- Add Last upload, total Length and a Normal/Shorts/Live breakdown, computed on read in the
existing grouped query (~35ms over the full catalog — measured, so no denormalization).
- Subscribers shown compact (919K), since the YouTube API already rounds them.
- Tag cell now shows only the tags actually attached to a channel; a '+' opens a per-channel
picker to attach/detach, instead of listing every user tag on every row.
POST /api/channels/{id}/reset-backfill (admin-only): clear the channel's backfill
markers, re-opt into deep backfill and re-run its recent pull now — a re-fetch-from-
scratch trigger regardless of current sync state. Idempotent (videos upsert by id).
Each job shows a tooltip (what it does + what happens if it stops), a status
dot legend + per-dot tooltips clarify the colours, and the interval is inline-
editable (pencil -> number -> save) wired to the new PATCH endpoint. HU/EN/DE.
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.
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.
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).
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).
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.
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.
Bump VERSION to 0.3.0 and add the 0.3.0 release-notes entry (local playlists, watch
later as a playlist, YouTube mirror + two-way sync, sort + undo/redo, rail sorting).