docker compose pull without arguments pulls ALL services including
getmeili/meilisearch:latest — a recent Meilisearch version bump broke
compatibility with existing data on the server, causing meilisearch to
crash immediately on restart.
CI deploy now only pulls the 5 custom app images (backend, runner, ui,
caddy, pocketbase). Infrastructure images (meilisearch, valkey, minio,
redis, crowdsec) are updated deliberately via `just pull-infra`.
Also add pocketbase to `just pull-images` now that it's a custom image.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace 5 sequential build-push-action steps with a single docker/bake-action
call backed by docker-bake.hcl. BuildKit now builds all images in parallel:
backend/runner/pocketbase share one Go builder stage (compiled once), while
caddy and ui build concurrently alongside the Go targets.
Workflow YAML goes from ~130 lines of build steps to ~35. Adding a new image
now only requires a new target block in docker-bake.hcl.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove non-root user from pocketbase Docker image; the existing pb_data
volume was created by the previous root-running image so files are owned
by root — running as a non-root appuser caused an immediate permission
error and container exit
- Increase healthcheck retries to 10 and add start_period=30s so migrations
have time to run on first boot before liveness checks begin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add wget to pocketbase Alpine image so the docker-compose healthcheck
(wget http://localhost:8090/api/health) can actually run
- Fix saveIfAbsent calling itself instead of app.Save(c) — was an
infinite recursion that would stack-overflow on a fresh install
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
release.yaml:
- Build and push kalekber/libnovel-pocketbase image on every release tag
- Add deploy job (runs after docker): copies docker-compose.yml from the
tagged commit to /opt/libnovel on prod, pulls new images, restarts
changed services with --remove-orphans (cleans up removed pb-init)
ci.yaml:
- Validate cmd/pocketbase builds on every branch push
Required new Gitea secrets: PROD_HOST, PROD_USER, PROD_SSH_KEY,
PROD_SSH_KNOWN_HOSTS (see deploy job comments for instructions).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each collection creator now skips creation if the collection already exists,
so deploying to an existing prod install no longer requires running
`migrate history-sync` manually. Migration 2 (missing fields) still applies
as normal since those fields genuinely don't exist yet.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a custom PocketBase binary (cmd/pocketbase) that embeds PocketBase as a
Go framework with version-controlled migrations, replacing the fragile 419-line
shell script. Migrations apply automatically on every `serve` startup.
Migration 1 (20260414000001): full baseline schema — all 21 collections, both
indexes on chapters_idx, and initial superuser creation from env vars.
Migration 2 (20260414000002): adds three fields found in code but missing from
the old script — books.rating, app_users.notify_new_chapters_push,
book_comments.chapter.
docker-compose: pocketbase service now uses the custom kalekber/libnovel-pocketbase
image; pb-init one-shot container removed (migrations replace it entirely).
Existing installs: run `migrate history-sync` once to mark migration 1 as done,
then restart — migration 2 will apply the three previously missing fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds BACKEND_ADMIN_TOKEN env var (set in Doppler) as a required Bearer
token for every admin route. Also fixes PocketBase filter injection in
notification queries and wires BACKEND_ADMIN_TOKEN through docker-compose
to both backend and ui services. Includes CLAUDE.md for AI assistant guidance.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Translation content is immutable once generated — add Cache-Control: public,
max-age=3600, stale-while-revalidate=86400 to handleTranslationRead so the
browser and any CDN reuse the response without hitting MinIO on repeat views.
Forward the header through the SvelteKit /api/translation/[slug]/[n] proxy
which previously stripped it by constructing a bare Content-Type-only Response.
Three bugs:
1. +page.server.ts returned an unawaited Promise — SvelteKit awaits it on
the server anyway so data.jobs arrived as a plain AIJob[] on the client,
not a Promise. The $effect calling .then() on an array silently failed,
leaving jobs=[] and the table empty. Fixed by awaiting in the load fn.
2. +page.svelte $effect updated to assign data.jobs directly (plain array)
instead of calling .then() on it.
3. handlers_textgen.go: final UpdateAIJob (payload+status write) used
r.Context() which is cancelled when the SSE client disconnects. If the
browser navigated away mid-job, results were silently dropped and the
payload stayed as the initial {pattern} stub with no results array.
Fixed by using context.Background() for the final write, matching the
pattern already used in handlers_image.go.
- Add bookstore.NotificationStore interface and wire it directly to *storage.Store
in Dependencies, bypassing the Asynq wrapper that caused Producer.(*storage.Store)
to fail with a 500 on every notification endpoint when Redis is configured
- Replace s.deps.Producer.(*storage.Store) type assertion in all 5 notification
handlers with s.deps.NotificationStore (nil-safe, always works)
- Fix PocketBase filter single-quote bug in ListNotifications, ClearAllNotifications,
and MarkAllNotificationsRead (PocketBase requires double quotes for string values)
The {#await ... then} + {@const} IIFE pattern for assigning to $state
variables stopped working reliably in Svelte 5.53+. Replaced with a
proper $effect that awaits both streamed promises and assigns to state,
which correctly triggers reactivity.
Also: switch library page selection mode entry from long-press to a
'Select' button in the page header.
Three compounding issues caused the 4+ second load:
1. getAllRatings() ran sequentially after the first Promise.all group,
adding it unnecessarily to the critical path. Now runs in parallel
with listBooks/getVotedSlugs/getSavedSlugs (all 4 concurrent).
2. discovery_votes was fetched twice on every page load — once inside
getBooksForDiscovery (via getVotedSlugs) and again by getVotedBooks.
Fixed by caching getVotedSlugs results with a 30s TTL so the second
call hits cache instead of PocketBase.
3. getVotedSlugs and getSavedSlugs were always uncached, hitting
PocketBase on every navigation. Added short-TTL per-user Valkey
cache entries (voted: 30s, saved: 60s). Cache is invalidated
immediately after each write (upsertDiscoveryVote, clearDiscoveryVotes,
undoDiscoveryVote, saveBook) so stale data is never served.
Removed the custom clear button (shown when query is non-empty) and
suppressed the browser-native webkit search cancel button via CSS.
Only the single Cancel button remains, avoiding the double/triple X
clutter on wider screens.
The SSE (non-async) chapter-names handler streamed results to the client
but never wrote them into the PocketBase job payload — only the initial
{pattern} stub was stored. The Review button then fetched the job and
found no results, showing 'No results found in this job's payload.'
Fix: accumulate allResults across batches (same as the async handler) and
write the full {pattern, slug, results:[...]} payload when marking done.
The wrapper div in +layout.svelte had pointer-events:none which blocked
all taps inside ListeningMode (chapter rows, buttons, scrolling). Removed
the wrapper div and moved the fly transition onto ListeningMode's own root
element so the slide-in animation works without stealing pointer events.
- Add notify_new_chapters_push field to AppUser, PATCH /api/profile, and profile loader
- Fix bell panel to reload notifications on every open (not just once on mount)
- Replace flat in-app + push toggles with structured category table (Category | In-app | Push)
- Add browser push master subscribe/unsubscribe row above the table
- Push column toggle disabled until browser is subscribed; shows — when unsupported/denied
- Update Notifications row hint to summarise active channels (In-app · Push / Off)
- getReadyChaptersForSlug(slug, preferredVoice) in pocketbase.ts: per-slug done jobs map, cached 60s, prefers user's voice
- GET /api/audio/chapters?slug=&voice= endpoint
- Chapter list (/books/[slug]/chapters): amber headphones icon on ready rows, play button for instant listen, banner showing ready count
- Chapter reader: audioReady + availableVoice from server load; 'Audio ready — Listen now' banner shown when audio exists and player is not yet active; sets voice preference before expanding player
- getBooksWithAudioCount() in pocketbase.ts aggregates done audio_jobs, deduplicates by chapter per slug, caches 5 min
- GET /api/audio/books endpoint
- home page: readyToListen shelf with headphones badge, chapter count, Listen button, hideable
- /listen page: full grid with search, sort (most narrated / A-Z / recent), empty state
Float mode was broken because AudioPlayer was unmounted the moment
audioStore.active became true — exactly when the float overlay needs
to render. Fix: keep AudioPlayer mounted in float and minimal modes
regardless of audioStore.active; only standard mode shows the
'Controls below' message.
Adds a third 'minimal' style: a compact single-row bar (skip ±,
play/pause, seek, time) with no voice picker or chapter browser.
Voice picker and chapter button are hidden in the idle pill too.
Settings UI updated to show all three options with a live
description of what each style does.
Up/down chevron buttons fixed to the bottom-right of the viewport.
At the top of the chapter the up button becomes a Prev chapter link;
at the bottom the down button becomes an amber Next chapter link.
Hidden in focus mode (uses its own pill). Lifts above the audio
mini-player when it is active.
- Replace bell dropdown with full-screen NotificationsModal (mirrors SearchModal pattern)
- Notifications visible to all logged-in users (not just admin)
- Admin users excluded from new-chapter fan-out (dedup vs Scrape Complete notification)
- Users with notify_new_chapters=false opted out of new-chapter in-app notifications
- Toggle in profile page to enable/disable in-app new-chapter notifications
- PATCH /api/profile endpoint to save notification preferences
- User-facing /notifications page (admin redirects to /admin/notifications)
- Service worker (src/service-worker.ts) handles push events and
notification clicks, navigating to the book page on tap
- Web app manifest (manifest.webmanifest) linked in app.html
- Profile page: push notification toggle (subscribe/unsubscribe)
using the browser Notification + PushManager API with VAPID
- API route POST/DELETE /api/push-subscription proxies to backend
- Go backend: push_subscriptions PocketBase collection storage
methods (SavePushSubscription, DeletePushSubscription,
ListPushSubscriptionsByBook) in storage/store.go
- handlers_push.go: GET vapid-public-key, POST/DELETE subscription
- webpush package: VAPID-signed sends via webpush-go, SendToBook
fans out to all users who have the book in their library
- Runner fires push to subscribers whenever ChaptersScraped > 0
after a successful book scrape
- Config: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT env vars
- domain.ScrapeResult gets a Slug field; orchestrator populates it
Demote per-book and per-chapter-list-page Info logs to Debug — these
fire hundreds of times per catalogue run and drown out meaningful signals:
- orchestrator: RunBook starting (per book)
- metadata saved (per book)
- chapter list fetched (per book)
- scraping chapter list page N (per pagination page per book)
The 'book scrape finished' summary log (with scraped/skipped/errors
counters) remains at Info — it is the useful signal per book.
Two bugs fixed in runScrapeTask / runCatalogueTask:
1. FinishScrapeTask was called with the task's own context, which is
already cancelled when the task is stopped. The PATCH to PocketBase
failed silently, leaving all counters at their initial zero values.
Fix: use a fresh context.WithTimeout(Background, 15s) for the write.
2. BooksFound was double-counted: RunBook already sets BooksFound=1 on
success, but the accumulation loop added an extra +1 unconditionally,
reporting 2 books per successful scrape.
Fix: result.BooksFound += bookResult.BooksFound (drop the + 1).
Remove the TTS voice section from the profile page — it fetched
/api/voices on every mount, blocking paint for the full round-trip.
Voice selection lives on the chapter page where voices are already loaded.
Rewrite the server load to run avatar, sessions+stats, and reading history
all concurrently via Promise.allSettled instead of sequentially, cutting
SSR latency by ~2-3x on the profile route.