Mobile action area was cluttered with Continue, Start ch.1, bookmark,
stars, and shelf dropdown all competing in a single unstructured row.
- Split mobile CTAs into two clear rows:
Row 1: primary read button(s) only (Continue / Start from ch.1)
Row 2: bookmark icon + shelf dropdown + star rating inline
- 'Start from ch.1' no longer stretches to flex-1 when Continue is
present — it's a compact secondary button instead
- Stars and shelf dropdown moved out of the CTA row into their own line
Genre tags were plain <span> elements with no interaction. Tapping
'fantasy' or 'action' now navigates to /catalogue?genre=fantasy,
pre-selecting the genre filter on the catalogue page.
The save-settings $effect was firing on the initial data load because
settingsApplied was set to true synchronously in the apply effect, then
currentTheme/fontFamily/fontSize were written in the same tick — causing
the save effect to immediately fire with uninitialized default values
(theme: "", fontFamily: "", fontSize: 0), producing a 400 error.
- Add settingsDirty flag, set via setTimeout(0) after initial apply so
the save effect is blocked for the first load and only runs on real
user-driven changes
- Also accept empty string / 0 as 'not provided' in PUT /api/settings
validation as a defensive backstop
OAuth login was silently failing: upsertUserSession() queried the
device_fingerprint column which didn't exist in the user_sessions
collection, PocketBase returned 400, the fallback authSessionId was
never written to the DB, and isSessionRevoked() immediately revoked
the cookie on first load after the OAuth redirect.
- scripts/pb-init-v3.sh: add device_fingerprint text field to the
user_sessions create block (new installs) and add an idempotent
add_field migration line (existing installs)
Audio jobs were stuck pending because the homelab runner was
connecting to its own local Redis instead of the prod VPS Redis.
- homelab/docker-compose.yml: change hardcoded REDIS_ADDR=redis:6379
to ${REDIS_ADDR} so Doppler injects rediss://redis.libnovel.cc:6380
(the Caddy TLS proxy that bridges the homelab runner to prod Redis)
Cache home stats (10 min) and recently added books (5 min) to avoid
hitting PocketBase on every homepage load. Cache all ratings for
discovery ranking (5 min) with invalidation on setBookRating.
invalidateBooksCache now clears all related keys atomically.
Fix discover card pop-to-full-size bug: new card now transitions from
scale(0.95) to scale(1.0) matching its back-card position, instead of
snapping to full size instantly after each swipe.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
**Profile stats tab**
- New Stats tab on /profile page (Profile / Stats switcher)
- Reading overview: chapters read, completed, reading, plan-to-read counts
- Activity cards: day streak + avg rating given
- Favourite genres (top 3 by frequency across library/progress)
- getUserStats() in pocketbase.ts — computes streak, shelf counts, genre freq
**Discover history tab**
- New History tab on /discover with full voted-book list
- Per-entry: cover thumbnail, title link, author, action label (Liked/Skipped/etc.)
- Undo button: optimistic update + DELETE /api/discover/vote?slug=...
- Clear all history button; tab shows vote count badge
- getVotedBooks(), undoDiscoveryVote() in pocketbase.ts
**Rating-ranked discovery deck**
- getBooksForDiscovery now sorts by community avg rating before returning
- Tier-based shuffle: books within the same ±0.5 star bucket are still randomised
- Higher-rated books surface earlier without making the deck fully deterministic
**End-of-chapter sleep timer**
- New cycle option: Off → End of Chapter → 15m → 30m → 45m → 60m → Off
- sleepAfterChapter flag in AudioStore; layout handles it in onended (skips auto-next)
- Button shows "End Ch." label when active in this mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OAuth callbacks were creating a new session record on every login from
the same device because user-agent/IP were hardcoded as empty strings,
producing a pile-up of 6+ identical 'Unknown browser' sessions.
- Add upsertUserSession(): looks up existing session by user_id +
device_fingerprint (SHA-256 of ua::ip, first 16 hex chars); reuses
and touches it (returning the same authSessionId) if found, creates
a new record otherwise
- Add device_fingerprint field to UserSession interface
- Fix OAuth callback to read real user-agent/IP from request headers
(they are available in RequestHandler via request.headers)
- Switch both OAuth and password login to upsertUserSession so the
returned authSessionId is used for the auth token
- Extend pruneStaleUserSessions to also cap sessions at 10 per user
- Keep createUserSession as a deprecated shim for gradual migration
**Ratings (1–5 stars)**
- New `book_ratings` PB collection (session_id, user_id, slug, rating)
- `getBookRating`, `getBookAvgRating`, `setBookRating` in pocketbase.ts
- GET/POST /api/ratings/[slug] API route
- StarRating.svelte component with hover, animated stars, avg display
- Star rating shown on book detail page (desktop + mobile)
**Plan-to-Read shelf**
- `shelf` field added to `user_library` (reading/plan_to_read/completed/dropped)
- `updateBookShelf`, `getShelfMap` in pocketbase.ts
- PATCH /api/library/[slug] for shelf updates
- Shelf selector dropdown on book detail page (only when saved)
- Shelf tabs on library page to filter by category
**Sleep timer**
- `sleepUntil` state added to AudioStore
- Layout handles timer lifecycle (survives chapter navigation)
- Cycles Off → 15m → 30m → 45m → 60m → Off
- Shows live countdown in AudioPlayer when active
**EPUB export**
- Go backend: GET /api/export/{slug}?from=N&to=N
- Generates valid EPUB2 zip (mimetype uncompressed, OPF, NCX, XHTML chapters)
- Markdown → HTML via goldmark
- SvelteKit proxy at /api/export/[slug]
- Download button on book detail page (only when in library)
**Fix TS errors**
- discover/+page.svelte: currentBook possibly undefined (use {@const book})
- cardEl now $state for reactive binding
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ClaimNextTranslationTask and HeartbeatTask were no-ops in the asynq
Consumer, so translation tasks created in PocketBase were never picked
up by the runner. Translation tasks live in PocketBase (not Redis),
so they must be claimed/heartbeated via the underlying pb consumer.
ReapStaleTasks is also delegated so stale translation tasks get reset.
Also removes the LibreTranslate healthcheck from homelab/runner
docker-compose.yml and relaxes depends_on to service_started — the
healthcheck was blocking runner startup until models loaded (~2 min)
and the models are already pre-downloaded in the volume.
When the mini-player chapter drawer is opened, the current chapter is
now immediately scrolled into the center of the list instead of always
starting from the top. Uses a Svelte action (setIfActive) to track the
active chapter element and a $effect to call scrollIntoView on open.
- storage/pocketbase.go: replace http.DefaultClient (no timeout) with a
dedicated pbHTTPClient{Timeout: 30s} so a slow/hung PocketBase cannot
stall the backend or runner indefinitely
- runner/asynq_runner.go: heartbeat ticker was firing at StaleTaskThreshold
(2 min) == the Docker healthcheck deadline, so a single missed tick would
mark the container unhealthy; halved to StaleTaskThreshold/2 (1 min)
- New /discover page with swipe UI: left=skip, right=like, up=read now, down=nope
- Onboarding modal to collect genre/status preferences (persisted in localStorage)
- 3-card stack with pointer-event drag, CSS fly-out animation, 5 action buttons
- Tap card for preview modal; empty state with deck reset
- Like/read-now auto-saves book to user library
- POST /api/discover/vote + DELETE for deck reset
- Discovery vote persistence via PocketBase discovery_votes collection
- Fix duplicate books: dedup by slug in getBooksBySlugs
- Fix WriteMetadata TOCTOU race: conflict-retry on concurrent insert
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add StreamAudioWAV() to pocket-tts and Kokoro clients; pocket-tts streams
raw WAV directly (no ffmpeg), Kokoro requests response_format:wav with stream:true
- GET /api/audio-stream supports ?format=wav for lower-latency first-byte delivery;
WAV cached separately in MinIO as {slug}/{n}/{voice}.wav
- Add GET /api/admin/audio/jobs with optional ?slug filter
- Add POST /api/admin/audio/bulk {slug, voice, from, to, skip_existing, force}
where skip_existing=true (default) resumes interrupted bulk jobs
- Add POST /api/admin/audio/cancel-bulk {slug} to cancel all pending/running tasks
- Add CancelAudioTasksBySlug to taskqueue.Producer + asynqqueue implementation
- Add AudioObjectKeyExt to bookstore.AudioStore for format-aware MinIO keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace static Polar checkout links with a server-side POST /api/checkout
route that creates a checkout session with customer_external_id = user ID
and customer_email locked (not editable). Adds loading/error states and
a post-checkout success banner on the profile page.
- Fix customer email path: was data.customer_email, is actually
data.customer.email per Polar v1 API schema
- Add resolveUser() helper: tries polar_customer_id → email → external_id
- Add subscription.active and subscription.canceled event handling
- Handle order.created for fast-path pro upgrade on purchase
- Profile page: fetch user email + polarCustomerId from PocketBase
- Profile page: pre-fill ?customer_email= on checkout links
- Profile page: link to polar.sh/purchases for existing customers
- Add +layout.server.ts to enforce admin role check at layout level,
preventing 404 on /admin and protecting all sub-routes centrally
- Add +page.server.ts to redirect /admin → /admin/scrape (was 404)
The fixed top bar was hidden behind the main site navbar (z-50).
Replace with an inline 'Admin menu' button at the top of the content.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Match main site logo style: lowercase 'libnovel' in full brand amber.
Add meta http-equiv refresh fallback for 5xx pages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The admin_nav_* message files generated by paraglide used 0-param inner
functions but called them with inputs, causing 50 svelte-check type errors.
Add _inputs = {} to each inner locale function to match the call signature.
Also adds backend/backend and backend/runner to .gitignore — binaries are
built inside Dockerfile and should never be committed.
- homelab/docker-compose.yml: add redis:7-alpine service (port 6379 bound to host
so Caddy TLS proxy on prod can reach it), add libretranslate service, add
redis_data and libretranslate_data volumes
- asynqqueue/producer.go: Asynq enqueue failures are now logged as warnings instead
of returned as errors — PB record already exists so runner picks it up via poll
- backend/main.go: pass logger to NewProducer
Root cause: Redis was not reachable at 192.168.0.109:6379 because the redis
container had no host port binding. Caddy TLS proxy terminates TLS but could
not TCP-connect to the backend Redis.
Without a connection_policy, Caddy resolved the TLS cert by the Docker
internal IP (172.18.0.5) instead of the hostname, causing TLS handshake
failures on :6380 (rediss:// from prod backend → homelab Redis / Asynq).
Changes:
- Caddyfile: add connection_policy { match { sni redis.libnovel.cc } } to
the layer4 :6380 tls handler so Caddy picks the correct cert
- Caddyfile: add redis.libnovel.cc virtual-host block (respond 404) to
force Caddy to obtain and cache a TLS cert for that hostname
- homelab/docker-compose.yml: add REDIS_ADDR, REDIS_PASSWORD,
LIBRETRANSLATE_URL, LIBRETRANSLATE_API_KEY, and
RUNNER_MAX_CONCURRENT_TRANSLATION to the runner service for parity with
homelab/runner/docker-compose.yml
The paraglideVitePlugin runs at build time (buildStart hook) and fetches
the inlang plugin from cdn.jsdelivr.net to recompile messages. This:
1. Overwrites messages.js with 'export * as m from ...' unconditionally
2. Causes Rollup SSR tree-shaking to replace all m.*() calls with (void 0)
3. Crashes every page server-side with 'TypeError: (void 0) is not a function'
The plugin is no longer needed: compiled paraglide output is committed to
git and updated via 'npm run paraglide' when messages change. Removing the
plugin lets Vite treat messages.js as a plain static module, keeping all
exports intact through the SSR bundle.
- ui/src/error.html: custom SvelteKit last-resort fallback (replaces
the bare '500 | Internal Error' shown when +error.svelte itself fails)
— branded, auto-refreshes in 20s, book+lightning SVG illustration
- ui/src/routes/+error.svelte: improved with context-aware SVG
illustrations (question mark book for 404, lightning bolt for 5xx),
larger status watermark, and a Retry button on non-404 errors
- caddy/errors/500.html: new static error page matching the 502/503/504
design — served by Caddy when a gateway-level 500 occurs
- Caddyfile: add handle_errors 500 block pointing at /srv/errors/500.html
- caddy/Dockerfile: COPY errors/ into image so static pages are baked in
These files are needed at CI check time. paraglide fetches its plugin
from cdn.jsdelivr.net which is unavailable in the CI environment,
causing compile to produce empty output. Committing the generated
output means CI never needs to recompile them.
paraglide-js fetches its plugin from cdn.jsdelivr.net at compile time.
In CI (no outbound internet access), this silently produces an empty
_index.js, causing svelte-check to fail with 'not a module' errors.
Fix:
- Commit all generated src/lib/paraglide/ files to git (removing
the auto-generated .gitignore that was hiding them)
- Simplify prepare script to only run svelte-kit sync
- Add separate 'npm run paraglide' script for developers to
regenerate when messages/*.json source files change
paraglide-js unconditionally emits 'export * as m from ...' in messages.js
which causes Vite/Rollup SSR to tree-shake all named message imports,
replacing every m.*() call with (void 0)() and crashing every page.
Strip the two offending lines via a Node.js one-liner in the prepare script
so the fix survives every npm ci run in CI.
Also stop tracking messages.js in git since it is always regenerated.
Add GET /api/audio-stream/{slug}/{n}?voice= that streams MP3 audio to the
client as TTS generates it, while simultaneously uploading to MinIO. On
subsequent requests the endpoint redirects to the presigned MinIO URL,
skipping generation entirely.
- PocketTTS: StreamAudioMP3 pipes live WAV response body through ffmpeg
(streaming transcode — no full-buffer wait)
- Kokoro: StreamAudioMP3 uses stream:true mode, returning MP3 frames
directly without the two-step download-link flow
- AudioStore: PutAudioStream added for multipart MinIO upload from reader
- WriteTimeout bumped 60s → 15min to accommodate full-chapter streams
- X-Accel-Buffering: no header disables Caddy/nginx response buffering
The paraglide messages.js had an extra 'export * as m from ...' line which
caused Rollup/Vite to tree-shake all actual message function imports in the
SSR bundle. Every m.* call compiled to (void 0)(), crashing every page
server-side with TypeError. Removed the duplicate namespace re-export.
Collections without an 'updated' field (books, user_sessions, user_settings,
user_library) were returning 400 because listOne always sent sort=-updated.
Changed default to empty string since we only fetch 1 record (no ordering needed).
- CommentsSection now accepts a chapter prop and scopes comments to that chapter
- Chapter reader page mounts CommentsSection with current chapter number
- Book detail page shows rolling 7-day unique reader count badge
- API GET/POST pass chapter param; pocketbase listComments filters by chapter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- First continue-reading book becomes a wide hero card with title,
description, genre tags, and a prominent Resume ch.N CTA
- Remaining in-progress books move to a horizontal scroll shelf
- Recently Updated deduplicates by slug; books with multiple new
chapters show a green "+N ch." badge
- Genre discovery strip (horizontal scroll) links to /catalogue?genre=X
- Stats demoted to a subtle two-number footer bar
- All rows use horizontal scroll instead of fixed grids
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three light variants mirroring the existing dark set. All use CSS custom
properties so no component changes are needed. Theme dots in the header
and mobile drawer show a separator between dark/light groups; light-theme
swatches get a subtle ring so they're visible on light backgrounds.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove both "Save settings" buttons; all settings now auto-save with
800ms debounce and show a transient "✓ Saved" indicator
- Apply theme, font family, and font size to context immediately on
change so the preview is live without waiting for the save
- Merge Appearance + Reading settings into a single Preferences card
with dividers — fewer sections, less visual noise
- Pro users see a compact subscription row; free users see upgrade CTAs
- Speed label splits value and units (shows "1.5x" separately in brand
color) for cleaner readout
- Auto-advance toggle gains a subtitle description
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: user_settings table was missing theme, locale, font_family,
font_size columns — PocketBase silently dropped them on every save.
Added the four columns via PocketBase API.
Also:
- listOne now sorts by -updated so the most-recent settings record wins
- PARAGLIDE_LOCALE cookie is now cleared when switching back to English
- pt-BR renamed to pt throughout (messages, inlang settings, validLocales)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sessions not seen in 30+ days are deleted in the background each time
a new session is created. No cron job needed — self-cleaning on login.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add header/footer, LibNovel wordmark, pulsing status indicator, faint
watermark code, and auto-refresh countdown (20s for 502/504, 30s for 503).
Fix two-tone background by setting background on html+body. 404 is static
with no auto-refresh.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>