Commit Graph
100 Commits
Author SHA1 Message Date
peter 74830eb0eb chore: rename remaining subfeed references to siftlode
Replace the leftover 'subfeed' name across logger names + log_config,
frontend localStorage keys, Postgres user/db/volume defaults in the
compose files, .env.example, config.py, backup/restore scripts and the
README. Pure rename; no behavioural change. localStorage keys move from
subfeed.* to siftlode.* (one-time UI-state reset is acceptable).
2026-06-21 06:53:12 +02:00
peter 613822fdb3 Merge chore/remove-ci-workflow — drop the broken post-merge CI 2026-06-21 05:53:06 +02:00
peter 2cabe54fb8 ci: remove post-merge ci.yml workflow
It duplicated the developer-machine quality gate (tsc + npm build + compileall),
ran post-merge on main (not a pre-merge gate), and would put the heavy frontend
build on the server on every push — the very load deploy.yml deliberately
keeps on the dev box. The local pre-ship checks are the CI in this setup.
2026-06-21 05:53:06 +02:00
peter ea5c8466bb Merge feature/instant-deploy — event-driven prod deploy via Forgejo Actions 2026-06-21 05:26:24 +02:00
peter b4f4ff6198 ci(deploy): event-driven prod deploy via Forgejo Actions on push to main
On a push to main the server-hosted runner SSHes back to the host (forced-command
key, docker bridge gateway, port 2222) and runs deploy/deploy.sh — replacing the
2-minute systemd poller. Build/publish still happens on the dev machine before push.
2026-06-21 05:20:34 +02:00
peter 543b109acf chore(release): 0.14.0 2026-06-21 03:23:23 +02:00
peter 19c93fbd17 Merge feature/install-wizard — first-run install wizard + self-host package (epic 6) 2026-06-21 03:20:59 +02:00
peter e039c8f4ec fix(setup): hide YouTube/Google affordances when Google sign-in isn't configured
- /api/me exposes instance-wide google_enabled. The onboarding nudge no longer auto-opens, and
  Settings hides the YouTube-access section + the 'Connect Google' option, when the instance has
  no Google OAuth configured (e.g. an email+password-only self-host). A linked account still shows
  its 'Connected' status.
2026-06-21 02:06:37 +02:00
peter 4951c32f6d feat(selfhost): one-command self-host package (epic 6d)
- docker-compose.selfhost.yml pulls the published image (forge.b1fr0st.eu/peter/siftlode) with a
  bundled Postgres — no source build on the operator's host.
- install.sh / install.ps1 generate the four required secrets (.env: POSTGRES_PASSWORD, SECRET_KEY,
  a valid Fernet TOKEN_ENCRYPTION_KEY, OAUTH_REDIRECT_URL), start the stack, and print the first-run
  setup-wizard URL. Everything else is configured in the web wizard.
- docs/self-hosting.md: the copy-paste playbook (get the files, run the installer, finish in the
  wizard, optional Google/SMTP, HTTPS/reverse-proxy, updates + backups).
- Verified end-to-end: a fresh install from the published image boots into setup mode and serves
  the wizard.
2026-06-21 01:42:18 +02:00
peter 93e97873d5 feat(setup): install-wizard UI (epic 6c)
- Multi-step first-run wizard (intro → admin → Google → SMTP → finish), shown by App while the
  instance reports configured=false; the one-time token comes from the setup URL (?token=) and is
  sent as X-Setup-Token on every step. Google/SMTP steps appear only when secrets can be stored.
- App holds the 'me' query until setup status is known (so it doesn't 503 against the setup lock);
  a missing/invalid token shows a 'use the link from the logs' screen. EN/HU/DE.
2026-06-21 01:11:16 +02:00
peter 50c710ae9c feat(setup): install-wizard step endpoints (epic 6c, API)
- POST /api/setup/admin (create the first admin: active+verified+admin), /config (persist any
  registry-backed settings — Google creds, SMTP, quota…), /test-email, and /finish (mark
  configured + invalidate the token). All behind require_setup (valid token AND not configured).
- GET /api/setup/info reports secrets_manageable so the wizard hides the Google/SMTP secret steps
  on a box without TOKEN_ENCRYPTION_KEY.
2026-06-21 00:40:54 +02:00
peter ef6c915b12 feat(setup): first-boot detection + setup-mode gating + one-time token (epic 6a)
- app_state gains configured + setup_token_hash (migration 0025); existing installs (any users)
  are marked configured so they never see the wizard, a fresh DB starts in setup mode.
- On first boot the app mints a one-time setup token and logs the wizard URL (only the hash is
  stored); the token gates the setup steps (added in 6c).
- A middleware locks all /api + /auth routes (except /api/setup, /api/version, /healthz, static)
  to 503 until configured, with a DB-free cached fast-path once setup completes.
- GET /api/setup/status tells the SPA whether to show the wizard.
2026-06-21 00:38:47 +02:00
peter 5597e9fd3f feat(auth): manage Google OAuth credentials from the database (epic 6b)
- Build the Google OAuth client lazily from sysconfig (DB override, env fallback) and re-register
  when the credentials change, so the install wizard / admin can set them without a restart.
- New 'google' config group (google_client_id/secret, encrypted) on the Configuration page; the
  token-refresh reads the creds the same way.
- Public GET /auth/config exposes google_enabled; the login page hides 'Continue with Google'
  when Google OAuth isn't configured (email+password still works).
2026-06-20 20:04:23 +02:00
peter c4689639f1 chore(release): 0.13.0 2026-06-19 19:53:31 +02:00
peter e7e99202cf Merge feature/auth-5e-google-link — complete epic 5 (auth/account) + admin user management 2026-06-19 19:52:38 +02:00
peter e77c5f4780 feat(welcome): landing lightbox, prod-served screenshots & session-end UX
- Serve real files at the SPA root (e.g. /welcome/*.png, favicon) from the built app — the
  landing screenshots were only ever shown by the Vite dev server, never in production.
- Add the Channel-manager screenshot; the two secondary previews are smaller thumbnails that
  open in a custom full-size lightbox (fit-to-viewport, Esc/backdrop/✕ to close).
- Drop a suspended/deleted user to the login page on the next 401 (and poll the session only
  while signed in, so the public login page no longer flickers); 'suspended' and 'account
  deleted' confirmation banners; strip redirect query params for a clean address bar.
2026-06-19 19:52:29 +02:00
peter b6f6a4d72a feat(admin): user management — roles, suspend, delete + lifecycle emails
- New Users page tabs (Users & roles / Access requests / Demo) and a tab-ified Configuration
  page, both via a reusable Tabs component (persisted active tab).
- Admin can suspend/unsuspend (migration 0023 adds users.is_suspended) and delete accounts
  from the Users & roles tab, with guards (demo / self / last admin).
- Email notifications to the affected user: suspended (reactive, on a blocked sign-in),
  reinstated, role changed, and account deleted; the approval email now carries a clickable
  app link. The scheduled/manual demo reset also seeds sample channel subscriptions.
- Hide the 'unverified email' chip for Google accounts (Google attests the email).
2026-06-19 19:52:12 +02:00
peter e31a1eedb6 feat(auth): account lifecycle — Google linking, passwords, suspension & deletion plumbing
- 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.
2026-06-19 19:52:02 +02:00
peter dc5d648ce2 Merge fix/access-requests-notification
Correct the pending-access-requests nudge (points to Users now), add a Review jump
link, and keep it a singleton (no per-reload pile-up).
2026-06-19 16:10:08 +02:00
peter 63bb40afa3 fix(inbox): access-requests nudge points to Users + a jump link, no pile-up
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.
2026-06-19 16:10:08 +02:00
peter 04be3830b5 Merge feature/auth-5d-gdpr (GDPR account + data deletion)
Self-service DELETE /api/me/account (cascades all personal data; demo + last-admin
guarded) and a Danger zone in Settings -> Account.
2026-06-19 15:46:49 +02:00
peter fc8e04d20a feat(account): GDPR self-service account + data deletion (5d)
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.
2026-06-19 15:46:49 +02:00
peter ab56752a90 Merge feature/auth-5c-demo (demo security audit + scheduled reset)
Audit: no authorization/isolation gaps. Add an admin-tunable scheduled demo-reset
job (reuses the manual reset; no-op if no demo account).
2026-06-19 15:25:14 +02:00
peter 571f337fdf feat(demo): scheduled demo-account reset (5c)
A security audit of every mutating endpoint found no isolation gaps — all routes
scope by current_user, admin routes are gated, and the demo account is sandboxed
in its own user_id space (can't reach others' data, escalate, or hit admin
routes). The remaining demo concern is communal pollution of its own shared state,
so add an automatic reset: a new 'demo_reset' scheduler job (admin-tunable
interval, default 12h) reuses the manual reset logic, and no-ops without ever
creating a demo account if none exists. Verified: a triggered run wiped the demo's
video states and re-seeded its sample playlists.
2026-06-19 15:25:13 +02:00
peter 7ca28189bb Merge feature/auth-5b-welcome (welcome/landing + email-password login UI)
Public landing page with the redesigned auth card (email+password, Google,
explicit demo), feature pitch, and demo-screenshot slots. Wires the 5a auth
endpoints with inline errors. Login.tsx replaced by Welcome.tsx.
2026-06-19 15:16:07 +02:00
peter a81734b4d6 feat(auth): welcome/landing page + email-password login UI (5b)
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.
2026-06-19 15:15:58 +02:00
peter 732acfce52 Merge feature/auth-5a (email+password auth foundations + admin Users page)
Backend: argon2 email+password auth with two-gate (verify + admin approval)
activation, single-use hashed auth_tokens, allow_registration flag, admin
users-list + role endpoint, current_user is_active. Frontend: admin Users page
(roles + migrated Access requests / Demo), Settings>Account now personal-only,
ConfigPanel boolean field. Migration 0022. Public login UI redesign is 5b.
2026-06-19 14:16:57 +02:00
peter 737da6bd96 feat(auth): admin Users page + allow_registration toggle (5a frontend)
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).
2026-06-19 14:16:48 +02:00
peter 7efd4f4867 feat(auth): email+password registration with two-gate activation (5a backend)
Add email+password auth alongside Google: argon2id hashing; users gains
password_hash/email_verified/is_active and google_sub becomes nullable (migration
0022); a single-use, hashed auth_tokens table for email verification + password
reset. Registration creates a pending (inactive, unverified) account + an access
request; sign-in needs verified email AND admin approval. Anti-enumeration: uniform
register/login/reset responses (a correct-password owner still gets a specific
pending reason). allow_registration flag (DB-tunable). Admin users-list + role
endpoint (guards: not self, not demo, not the last admin); approving access (or a
manual whitelist add) now activates the matching pending account. current_user
rejects deactivated accounts. Verified end-to-end via curl + DB.
2026-06-19 14:03:11 +02:00
peter 4fc7e1e7df chore(release): 0.12.0
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.
2026-06-19 13:23:33 +02:00
peter b8223c5741 Merge feature/config-db-4c
Move youtube_api_key into the DB-backed system_config (encrypted). Google OAuth
creds + admin-role UI deferred to the wizard/auth epics (see commit body).
2026-06-19 13:19:06 +02:00
peter 2dc374cc7b feat(config): move youtube_api_key to DB (encrypted, admin-editable)
Register youtube_api_key in the sysconfig registry as a secret and resolve it from
the DB-override-or-env layer in YouTubeClient (self.db is in scope). Adds a YouTube
API group to the Configuration page (EN/HU/DE).

Google OAuth client id/secret stay env-only for now: the authlib client is
registered at module load (auth.py), and the token-refresh reads them too, so a
runtime DB override needs a lazy/re-registerable OAuth client — that belongs to the
install-wizard epic (first-boot OAuth config without restart). Admin-role-via-UI
moves to the auth-overhaul epic (user management lives there).
2026-06-19 13:19:06 +02:00
peter c953642e30 Merge feature/config-db-4b (epic 4b)
Move the operational params (quota budget/reserve, backfill window, shorts-probe,
enrich/autotag batch) off env into the DB-backed system_config + admin Configuration
page, wiring each read through the resolver.
2026-06-19 13:07:55 +02:00
peter 060dbcffb0 feat(config): render quota/backfill/shorts/batch groups on the Configuration page
Add the four new groups to the page's group order and EN/HU/DE labels + field
hints for the 8 newly DB-overridable settings. The page is registry-driven, so
this is purely presentational (group order + i18n).
2026-06-19 13:07:55 +02:00
peter e6bcf5ba1e feat(config): move operational params to DB (quota/backfill/shorts/batch)
Register 8 more keys in the sysconfig registry (quota daily budget + backfill
reserve, recent-backfill window, shorts-probe params, enrich/autotag batch sizes)
and route their reads through the DB-override-or-env resolver at every call site
(quota.py, sync/runner.py, sync/videos.py, sync/autotag.py, sync/maintenance.py,
routes/scheduler.py). All read sites already had a db session in scope. Reads are
read-through (no cache), matching app.state; admin can tune them live, no redeploy.
2026-06-19 13:07:45 +02:00
peter ff03b51352 Merge improvement/config-unified-savebar
Replace the Configuration page's per-field Save buttons with one drafted
Save/Discard bar, consistent with the Settings page.
2026-06-19 12:40:23 +02:00
peter 8198c7712e refactor(config): one Save/Discard bar instead of per-field Save
Per-field Save buttons were an unconventional, endpoint-driven choice. Replace
them with a single drafted Save/Discard bar (consistent with the Settings page):
edits are buffered and applied together on Save (each via the existing per-key
PATCH/DELETE), Discard reverts. Reset-to-default is a per-field affordance applied
on Save too — clearing a non-secret falls back to default; a secret (which can't
be cleared by emptying) gets a toggle that resets it on save. Backend unchanged.
2026-06-19 12:40:23 +02:00
peter 7d71c205b2 Merge feature/config-db-admin (epic 4a)
DB-backed system_config infrastructure (registry + resolver + Fernet-encrypted
secrets + admin API) and a registry-driven admin Configuration page, with the
Email/SMTP group wired end-to-end as the first vertical slice.
2026-06-19 12:23:08 +02:00
peter a71257f33f feat(config): admin Configuration page (registry-driven)
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.
2026-06-19 12:23:00 +02:00
peter 48cb6a5dbd feat(config): DB-backed system_config infrastructure + SMTP group
Add a generic admin-editable config layer (epic 4a): system_config KV table
(migration 0021), a sysconfig registry that is the single source of truth for
DB-overridable keys (type/group/default/bounds/secret) + a DB-override-or-env
resolver, and admin endpoints (GET/PATCH/DELETE /api/admin/config + a test-email
probe). Secrets are Fernet-encrypted at rest (TOKEN_ENCRYPTION_KEY); without it
they stay env-only. First group wired end-to-end: Email/SMTP — email.py now
reads host/port/user/from/password via the resolver (own session, since email
is sent from background tasks).
2026-06-19 12:22:36 +02:00
peter 24d626d05b Merge feature/stats-quota-taxonomy
- refactor: canonical quota-action taxonomy + rename migration 0020 + i18n labels
- feat: consolidate Settings>Sync into a per-user Stats page (admin System tab)
2026-06-19 11:48:29 +02:00
peter 17a0886d60 feat(stats): consolidate per-user sync status into the Stats page
The Settings > Sync tab moves into the Stats module: Stats is now a per-user
page (Overview tab = sync status + your own API usage + manual actions) with the
admin instance-wide quota dashboard + background-sync pause as an admin-only
System tab. The Stats nav item is visible to all users (was admin-only); the
Settings Sync tab is removed. Sync i18n strings move from settings.sync.* to
stats.sync.* (EN/HU/DE).
2026-06-19 11:48:20 +02:00
peter b23f805533 refactor(quota): canonical <entity>_<action> taxonomy for quota events
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.
2026-06-19 11:48:11 +02:00
peter 1ce035ca9e Merge feature/banner-refactor-discovery-polish
- refactor: reusable Banner base (VersionBanner + DemoBanner)
- feat: Discover tab total-videos column + subscribe confirm dialog (EN/HU/DE)
2026-06-19 11:18:37 +02:00
peter 94fc5c9806 feat(discovery): total-videos column + subscribe confirmation
Add a "Videos" column (channel total uploads, already in the discovery
response) to the Discover-from-playlists table, and guard the Subscribe button
with a confirm dialog warning it changes the real YouTube account and spends
quota (mirrors the unsubscribe guard). Strings added in EN/HU/DE.
2026-06-19 11:18:28 +02:00
peter 479574b726 refactor(banner): extract reusable Banner base for VersionBanner + DemoBanner
Both top-of-app bars shared the same shell (icon + content + border-b bar);
factor it into a tone-driven Banner with optional action/dismiss. The
version/demo logic stays in the wrappers. Leaves a clean extension point for
a future news-ticker variant.
2026-06-19 11:18:20 +02:00
peter a5de874dd3 Merge bug/youtube-error-status: YouTube failures as 422 not 502 (0.11.2) 2026-06-19 04:27:22 +02:00
peter 262ed06b3c release: 0.11.2 2026-06-19 04:27:22 +02:00
peter fa86e3d282 fix(api): surface YouTube action failures as 422, not 502
Same fix as the subscribe reconcile, applied to the remaining endpoints that
wrapped a YouTubeError in a 502: video lookup (feed) and playlist refresh,
push-plan, sync, and delete. A 502 is treated by the client as a transient
"connection lost" blip, so a real, explainable YouTube failure showed the wrong
message. 422 carries the clear detail to the error dialog instead.
2026-06-19 04:27:22 +02:00
peter 0d5e2dc331 Merge bug/subscribe-already-exists: reconcile already-followed channel (0.11.1) 2026-06-19 04:16:34 +02:00
peter 403a5608df release: 0.11.1 2026-06-19 04:16:23 +02:00
peter 9c7beec443 fix(channels): reconcile an already-followed channel instead of erroring
Subscribing to a channel the user already follows on YouTube (a local/YouTube
desync — e.g. a stale import dropped the local row, so it resurfaced in the
discovery list) made YouTube return 400 "subscription already exists", which
the endpoint reported as a 502 — and the client shows a 502 as a transient
"connection lost" blip, not the real reason. Treat the duplicate as success:
record the subscription locally (the desired end state already holds; the next
resync fills in the resource id). Surface any other YouTube error as 422 with a
clear message, on both subscribe and unsubscribe, so it isn't mistaken for a
connection drop.
2026-06-19 04:16:23 +02:00
peter d254cf4353 release: 0.11.0 2026-06-19 04:02:38 +02:00
peter b6404a5e55 Merge bug/channels-tab-focus: reliable channel-manager navigation (tab + stale filter) 2026-06-19 03:57:44 +02:00
peter 7aab0dd047 fix(channels): drop a stale column filter when sent to "without full history"
A focus-channel deep-link sets (and persists) the channel-name column filter
via the DataTable's externalFilter. That persisted value then survived into
unrelated visits: clicking the header's "N without full history" link landed
on the right tab with the right status chip, but a leftover name filter (e.g.
a channel focused in a past session) hid every row — "No channels".

Add a resetFiltersToken to DataTable that clears its column filters when it
advances, and bump it from the header intent. The token's ref starts at 0 so
the clear fires even when the table remounts on navigation, while a plain
reload (token still 0) keeps the user's persisted filters.
2026-06-19 03:55:55 +02:00
peter 1bedfca666 fix(channels): land on the subscriptions tab for channel-targeted navigation
The Channel manager's tab (subscriptions vs playlist discovery) was persisted
locally, so the header's "N without full history" link and the focus-channel
jump (subscribe notice, tag manager) could dump the user on the Discovery tab
while quietly applying a status/name filter that only affects the subscriptions
table — the targeted channel was there, just on the hidden tab.

Lift the tab state to App alongside the status filter it pairs with, and have
those navigation intents switch it back to "subscribed".
2026-06-19 03:38:57 +02:00
peter 141dd32855 Merge improvement/ui-polish-dry: shared ChannelLink/channelYouTubeUrl + adaptive header poll 2026-06-19 03:31:52 +02:00
peter 258aa5cc84 perf(header): poll sync status faster while work is in flight
The header polled every 30s, so the live "syncing" state (now gated on a real
running job) was usually missed between ticks. Poll every 8s while a job is
running or channels are pending, easing back to 30s once everything's settled,
via react-query's functional refetchInterval.
2026-06-19 03:24:45 +02:00
peter 5461cada84 refactor(channels): share one ChannelLink + channelYouTubeUrl helper
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).
2026-06-19 03:22:10 +02:00
peter 96f303b0cf Merge improvement/inbox-nav-links: navigation links on watched + scheduler notices 2026-06-19 03:07:35 +02:00
peter 85b3a9f95c Merge improvement/header-activity-coherent: header reflects live sync activity, not pending work 2026-06-19 03:07:35 +02:00
peter c2b877c14e feat(inbox): add navigation links to watched and scheduler notices
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.
2026-06-19 03:05:01 +02:00
peter f69e34e3a6 fix(header): show sync activity only while a job is actually running
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.
2026-06-19 02:57:45 +02:00
peter a004fa4107 Merge bug/scheduler-progress-visibility: live progress for any scheduler job + parallel RSS polling 2026-06-19 02:45:36 +02:00
peter d86aee262d Merge feature/s5-channel-discovery: discover & subscribe to channels from playlists 2026-06-19 02:45:36 +02:00
peter cdc6715935 fix(scheduler): show progress for any running job with reliable live updates
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.
2026-06-19 02:43:46 +02:00
peter e0971a23ec feat(scheduler): report progress from every job + parallelize RSS polling
Every background job now reports progress, so the dashboard can show a live
bar for any run (not only enrich/backfill/shorts/maintenance): rss_poll and
subscriptions (runner), autotag, and playlist_sync gained progress.report
calls over their loops.

RSS polling now fetches the channel feeds concurrently (16 workers) instead
of one-at-a-time — a slow/unreachable feed no longer blocks the rest, cutting
a full poll of the catalogue from minutes to seconds. Feed fetches are
network-bound and run in the pool; DB inserts stay on the session's thread
(SQLAlchemy sessions aren't thread-safe), applied as each fetch completes.
Split the DB-write half of poll_rss_channel into apply_rss_feed so the fetch
and apply phases compose cleanly.
2026-06-19 02:43:37 +02:00
peter 75ecf19147 feat(notifications): link the subscribe activity to the channel
The "Subscribed on YouTube" inbox entry now names the channel and offers
two convenience links that survive a reload (driven by the typed payload,
not the live callback): "Channel manager" (jumps to the manager focused on
that channel) and "Open on YouTube". Wires the panel to the app's
focus-channel navigation; trilingual strings.
2026-06-19 02:16:50 +02:00
peter fb6f0c5dcb feat(channels): discover & subscribe to channels from playlists
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.
2026-06-19 02:16:42 +02:00
peter b5141ab112 docs(release): correct 0.10.0 note — reload isn't guarded
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.
2026-06-19 00:20:38 +02:00
peter 1e41a624ff release: 0.10.0
Explicit Save/Discard for Settings; self-resolving connection-lost notice +
automatic retry of transient gateway 502s; state-change notices gated on
server confirmation; Settings panel height tidy-up.
2026-06-19 00:07:25 +02:00
peter e94dde1950 Merge improvement/settings-panel-trim: tidy Settings height, drop native unload prompt 2026-06-19 00:04:19 +02:00
peter 2e79074fca fix(settings): size panel to the active tab + drop the native unload prompt
- Render only the active settings tab instead of stacking all four in one grid
  cell. The stack forced the whole panel to the tallest tab's (Account) height,
  leaving the short tabs (Appearance/Notifications) with dead space and a
  pointless scrollbar. Now the panel sizes to its actual content.
- Remove the beforeunload guard: it could only raise the browser's own native
  'Reload site?' prompt (no in-app dialog possible there), which we avoid.
  Unsaved prefs are local-only and revert to the saved server baseline on the
  next load, so a reload/close loses nothing. The in-app confirm still guards
  in-app navigation and browser Back.
2026-06-19 00:04:19 +02:00
peter 53ba10e7c6 Merge feature/settings-explicit-save: explicit Save/Discard for preferences 2026-06-18 23:59:40 +02:00
peter 4a1a025353 feat(settings): explicit Save/Discard for preferences with dirty tracking
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.
2026-06-18 23:59:40 +02:00
peter d61e844f6b Merge bug/optimistic-state-notify-on-success: gate state-change notices on server confirmation 2026-06-18 23:27:59 +02:00
peter 9f9d2544ef fix(web): only announce a state change after the server confirms it
The 'Marked watched' / 'Hidden' notices (and resolving a stale notice on
unwatch/unhide) fired synchronously, before the api.setState call resolved.
When the write failed — e.g. the API was unreachable — the optimistic card
override was rolled back in .catch, but the notice had already been emitted
and lingered, falsely claiming a change that never persisted.

Move all three branches into the .then() so the notice is produced only once
the server has actually applied the change; on failure the card reverts and
nothing is announced (the connection-lost status already explains why).
2026-06-18 23:27:59 +02:00
peter 0ab6742b44 Merge improvement/connection-lost-self-resolving: honour the 'clears once it's back' promise 2026-06-18 23:17:33 +02:00
peter 1278d079e2 fix(web): make the 'connection lost' notice self-resolving
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.
2026-06-18 23:17:32 +02:00
peter 4f7aac7b92 Merge bug/proxy-keepalive-502-retry: stop player POSTs surfacing proxy keepalive 502s 2026-06-18 15:13:09 +02:00
peter 6bed9a1427 fix(web): retry idempotent calls on gateway 502/503/504, soften the rest
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.
2026-06-18 15:13:01 +02:00
peter 02a6e5ff94 fix(api): raise uvicorn keepalive above the proxy reuse window
uvicorn's default --timeout-keep-alive is 5s, shorter than Caddy's pooled
keepalive idle window, so the proxy reused connections uvicorn had just
closed -> broken pipe / connection reset -> 502. The player's progress/state
POSTs fire every 5s (resonant with the 5s default) and, being non-idempotent,
could not be auto-retried by the proxy, so those 502s reached the user.
Set --timeout-keep-alive 75 so the upstream outlives the proxy's reuse.
2026-06-18 15:13:01 +02:00
peter d5e4f5353e docs(release): reflect unified notification center in v0.9.0 notes 2026-06-18 04:37:51 +02:00
peter 310e9b63b6 Merge feature/followups-batch-and-bell-unify: maintenance batch knob + unified notification inbox 2026-06-18 04:37:27 +02:00
peter d3c6ce659c feat(notifications): unify the two rail indicators into one inbox
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.
2026-06-18 04:37:20 +02:00
peter dd83718304 feat(scheduler): admin-tunable maintenance re-validation batch size
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.
2026-06-18 04:37:08 +02:00
peter 264a3c74af docs(release): note admin scheduler run-now + live progress under v0.9.0 2026-06-18 04:02:11 +02:00
peter 89be480706 Merge feature/scheduler-run-now: run-now/run-all triggers, live progress, completion notices, nav badge fix 2026-06-18 04:01:38 +02:00
peter 00d07b9153 fix(nav): numeric inbox badge with active-state contrast
Show the unread count as a number on the collapsed nav rail (not just a dot), and
invert the badge colours on the active row so it isn't an accent-on-accent red blob;
centre the number on the circle.
2026-06-18 04:01:19 +02:00
peter ed4194a8d3 feat(scheduler): admin run-now/run-all triggers + live progress + completion notices
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.
2026-06-18 04:01:10 +02:00
peter 3a0789ebe7 chore(release): v0.9.0 — notification inbox + automatic video maintenance
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.
2026-06-18 03:22:22 +02:00
peter 46fc56371f Merge feature/maintenance-and-inbox: validation job + notification inbox (P1) 2026-06-18 03:20:35 +02:00
peter c4aecf9f77 feat(maintenance): scheduled job to retire unplayable videos
Add a daily maintenance/validation job that detects videos which can't be played
anywhere and retires them safely. Two phases: re-check already-flagged videos
(recover if available again, else hard-delete once the grace period elapses,
cascading to states/playlist items), and a rolling re-validation of the
least-recently-checked currently-available videos that flags newly-unplayable
ones (hidden from the feed immediately via unavailable_since).

Detection is ~free: a video missing from the videos.list response is
deleted-or-private; an `upcoming` premiere >2 days past its scheduled start that
never went live is abandoned. A still-live broadcast is kept (legit 24/7 stream).
Enrichment now also fetches part=status to populate the status columns. Grace is
7 days for removed videos, none for abandoned. Before deleting, affected users
get one batched notification (never per-video). Interval is admin-tunable via the
Scheduler dashboard; batch size and grace are config. Quota-attributed to the
system and bounded by the same backfill reserve as the other jobs.
2026-06-18 03:20:28 +02:00
peter b9a3a9012d feat(notifications): durable per-user inbox (P1) + maintenance schema
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.
2026-06-18 03:20:17 +02:00
peter a11a8db278 chore(release): v0.8.0 — channel columns, tag workflow, video reset, error dialog 2026-06-18 01:18:41 +02:00
peter e85d30a3f3 Merge improvement/tags-ux: tag UX overhaul, user-tags feed widget, video reset, global error dialog 2026-06-18 01:17:38 +02:00
peter 1e530d23a6 feat(tags): tag UX overhaul + per-card video reset
- 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.
2026-06-18 01:17:31 +02:00
peter b55a944e7c fix(ux): modal error dialog for server-refused actions + ESC closes only the topmost
- 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.
2026-06-18 01:17:31 +02:00
peter edd8c85132 Merge improvement/channel-aggregates: aggregate columns, attached-only tags, auto-width DataTable 2026-06-17 23:24:31 +02:00
peter 0aa16543d9 feat(channels): aggregate columns + smarter tag display
- 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.
2026-06-17 23:24:25 +02:00