The feed query was always scoped to the user's own non-hidden subscriptions
via an INNER JOIN on subscriptions. Add a scope param: scope=my (default)
keeps that behaviour; scope=all LEFT-joins the subscription instead, so every
video in the shared catalog shows while per-channel priority still resolves
for channels the user is subscribed to. Per-user watch state stays private via
the VideoState outer join in both modes. The priority sort is made null-safe
(coalesce to 0) since unsubscribed channels have no subscription row in all-mode.
The backend shuffle sort already accepts a seed param. Add a circular
reshuffle button next to the sort control (shown only when shuffle is
active) that re-rolls the seed and re-queries the feed; selecting shuffle
also seeds a fresh order instead of the deterministic seed-0 one. The seed
lives in FeedFilters but is intentionally kept out of the URL state.
The per-tag channel_count was already returned by GET /api/tags but only
surfaced in the chip tooltip. Render it as a small count badge on the chip
face so the relative weight of each topic/language is visible at a glance.
Make APP_VERSION come from the VERSION file (Dockerfile reads it for the SPA build
and ships it for the backend to read at runtime) instead of depending solely on a
deploy-time build-arg. This keeps the version correct even for a plain
`docker compose build` and removes the first-deploy bootstrap gap. git_sha/build_date
stay best-effort build-args.
api: HttpError now carries the server's detail. Channels sync/backfill/unsubscribe
now detect a 403 (no YouTube grant) and show a 'connect your YouTube account'
message with a Connect action that opens the onboarding wizard, instead of a vague
'failed' toast. Translated HU/EN/DE.
Feed, VideoCard, Sidebar, PlayerModal, Channels, Stats, SettingsPanel,
OnboardingWizard, NotificationCenter, Toaster, ErrorBoundary and the relativeTime
helper are now fully translated in Hungarian, English and German, each with its own
locale area file (auto-loaded). Key parity verified across all three languages.
Login screen (with a language picker), header, account menu, sync status, About and
Release Notes dialogs, and the version banner are now fully translated in Hungarian,
English and German.
Set up react-i18next with locale files auto-loaded per area (Vite glob), a compact
LanguageSwitcher, and language as a server-persisted preference (preferences.language)
mirrored to localStorage. On first login the default UI language is guessed from the
Google-reported locale (hu/en/de, else English). vite-env.d.ts types the build-time env.
About (in the account menu) shows frontend/backend/database versions + build.
Release Notes renders per-version highlights with a commit-SHA reference; a
dismissible banner appears once after the running build's version changes and
links into the notes. Adds a reusable Modal shell and the release-notes data
(detailed v0.1.0).
Add a VERSION file (0.1.0) and inject APP_VERSION/GIT_SHA/BUILD_DATE as Docker
build-args (both stages; Vite inlines them into the SPA). New public GET
/api/version returns app_version, git_sha, build_date and the Alembic head as the
database revision. deploy.sh and the localdev build pass the args.
Drop docs/deployment-plan.md (internal planning doc, kept out of the repo) and
replace the personal lab DB IP and host names in the env/compose/deploy docs with
generic placeholders, so the repo is safe to make public later.
A channel whose stored uploads already meet or exceed YouTube's advertised
video_count holds its whole history, but backfill_done could stay false forever
when the deep cursor never reached the end (e.g. a small channel that was never
deep-requested, so the demand-driven deep job never ran). Such channels nagged
as 'needs full history' despite having every video. Add reconcile_full_history()
(idempotent, no quota) and run it at the end of each recent/deep backfill cycle
so backfill_done self-heals.
Show 'N yours / M total' in the header (your subscriptions vs. the whole shared
catalog) with a tooltip, backed by a new total_videos field on /sync/my-status.
The admin pause button now only appears when there's sync work to pause; Resume
still shows whenever sync is paused.
Video cards show a resume progress bar for started-but-unfinished videos and a
hover overlay: Play on every card, Continue + Restart on in-progress ones. The
in-app player now resumes from (and checkpoints to) the server position instead
of localStorage, accepts an explicit startAt (Restart -> 0), and refreshes the
feed on close so the card bar reflects the session. Sidebar gains an
'In progress' show filter.
Add position_seconds (+progress_updated_at) to video_states so watch progress
survives across devices and can drive a feed filter. New POST
/api/videos/{id}/progress checkpoints the player position (clearing trivially
-early and near-finished positions). Feed serialize exposes position_seconds and
a show=in_progress filter lists started-but-unfinished videos. Un-marking
'watched' now keeps a stored position instead of deleting the row.
- header: per-user "N without full history" count (channels_deep_pending),
clickable with a hint -> opens the channel manager filtered to those.
- channel manager: status filter chips (All / Needs full history / Fully synced
/ Hidden); the header link deep-links to "Needs full history".
- fix: priority up/down is now an optimistic in-place cache update (no refetch /
re-sort), so the list no longer jumps to the top and loses your scroll position;
the new order applies on the next page load.
The header status bar read the global /api/sync/status (videos_total +
channels_backfilling), so every user saw the whole catalog's numbers —
confusing and a small cross-user info leak (e.g. "3 syncing" for a user with 2
channels). It now uses /api/sync/my-status: the user's own available video count
and how many of their own channels are still being fetched
(channels_recent_pending). The pause control stays admin-only via an isAdmin prop.
After read access is granted the wizard now imports the user's YouTube
subscriptions automatically (with a "Building your feed…" progress state), so a
new user lands on a populated feed — channels already in the shared catalog show
up instantly, new ones backfill in the background. The empty feed now prompts
users without read access to set up via the wizard instead of a bare message.
- requirements: cryptography >=46.0.7 (was pinned <46, which excluded the fix for
the CVEs pip-audit flagged in our Fernet/crypto library). pip-audit now clean.
- Dockerfile: upgrade pip before installing deps (patches installer-level CVEs).
- auth: /auth/upgrade now defaults to the least-privileged read scope; only an
explicit access=write requests the write scope.
docker-compose.prod.yml targets the public VPS: Postgres is never published, the
app binds to 127.0.0.1 (Caddy proxies it), and every service has a memory/CPU cap
plus no-new-privileges / cap_drop / read-only rootfs. deploy/deploy.sh rolls out
main with a host-side build (migrations run via the entrypoint). CI type-checks
and builds the frontend and byte-compiles the backend on every push to main.
Adds login-free /privacy and /terms pages (rendered outside the authenticated
tree via a pathname switch in main.tsx) carrying the Google API Services Limited
Use disclosure, YouTube ToS / Google Privacy links, and contact + data-deletion
info. The sign-in screen now describes the app and links to both, satisfying
Google's homepage + privacy-policy requirements for the OAuth consent screen.
After the clean name/email sign-in, a wizard walks the user through granting
YouTube read (then optionally write) one step at a time, each with a plain
rationale and an up-front heads-up about Google's "unverified app" screen.
The visible step is derived from the granted scopes (can_read/can_write) so the
flow resumes correctly across the full-page consent redirect; it's dismissible
and reopenable from Settings -> Account, which now lists read and write as
separate, individually-grantable access rows.
Base login now requests only openid/email/profile (non-sensitive), so a new user
gets a clean Google consent with no "unverified app" warning and no 7-day refresh
token expiry. YouTube read (youtube.readonly) and write (youtube) are granted later
by the onboarding wizard via a parameterized /auth/upgrade?access=read|write.
Security fixes folded in from the baseline audit:
- config: refuse to boot in production (https OAUTH_REDIRECT_URL) with the
placeholder/short SECRET_KEY or a missing TOKEN_ENCRYPTION_KEY, closing a
session-forgery / admin-impersonation hole.
- main: mark the session cookie Secure when served over HTTPS.
- me: expose can_read; sync/subscriptions returns a friendly 403 (not a 500)
until YouTube read access is granted.
Three low-risk wins for large filtered feeds:
- content-visibility:auto on cards (.cv-card/.cv-row) so the browser skips
layout/paint for off-screen cards; contain-intrinsic-size keeps the scrollbar
stable and is remembered per card after first render.
- memo(VideoCard) + stable onState/onChannelFilter callbacks (onState reads the
loaded list via a ref) so appending a page only renders the ~60 new cards
instead of reconciling every card already on screen.
- Prefetch the next page earlier (sentinel rootMargin 800px → 1500px) so the
'Loading more…' flash is far less likely during fast scrolling.
Persisted notifications were restored with their saved 'dismissed' state, so any
that were still active when the page closed came back as active toasts — but
their auto-dismiss timers aren't re-armed on load, leaving them stuck on screen
forever (a burst of errors could pile up dozens). Mark restored entries as
dismissed on load: they stay in the bell history but no longer reappear as live
toasts. The toast surface is session-transient; history persists.
Channel/account avatars come from Google's image CDNs (yt3.ggpht.com,
lh3.googleusercontent.com). On a feed page dozens load at once; the CDN
rate-limits the referrer-bearing burst (429), so a random subset rendered the
browser's broken-image icon (the URLs themselves are valid — verified 200).
Add a shared <Avatar> that sets referrerPolicy="no-referrer" (which the CDNs
serve without throttling) and falls back to a neutral initial placeholder on
error instead of the broken-image icon. Use it for video-card, player, channel
manager, header and settings avatars.
In-app modal player (YouTube IFrame API) on card click as an alternative to
opening youtube.com in a new tab: resume position, watched controls + auto-watch,
a lazily-fetched description popover with linkified timestamps/emails/hashtags/
links, and YouTube links that play inline. Also: a 'watched' notification with an
Unwatch action, and a feed override fix so reverting status re-shows the video.
Built on branch experiment/inline-player, reshaped into logical commits on
feat/inline-player.
The feed kept per-video optimistic status overrides until filters changed, so a
status reverted elsewhere (e.g. Unwatch from the notification center) was masked
by the stale override and stayed filtered out of the current view. Also clear
overrides when fresh feed data arrives — the server is then authoritative.
Mirror the hide flow for watched: marking a video watched (card, modal toggle,
or auto-watch) raises a toast and a bell-history entry with an Unwatch action —
no Find-in-feed, just revert. NotifMeta becomes a discriminated union
(video-hidden | video-watched); unhide/unwatch share one revert-to-new helper.
- Narrow the title hover target to the actual text, not the whole row.
- Linkify descriptions: timestamps (mm:ss / hh:mm:ss) seek the player; emails
become mailto:; hashtags link to YouTube's hashtag feed; other URLs open in a
new tab. Blank lines are stripped so the popover isn't mostly whitespace.
- YouTube links play in the inline player: a link to the current video seeks
(honoring t=), a link to another video navigates the player to it, with a Back
button to the original. While on a linked video the title/author come from the
player and its views/date/duration + a clickable channel come from the detail
endpoint, which falls back to the YouTube API (videos.list, attributed to the
user) for videos not in our DB.