chore: migrate to v3 and adopt Doppler for secrets management #3
Reference in New Issue
Block a user
Delete Branch "v3-cleanup"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Completes the migration from the multi-directory monorepo layout to a clean v3-only codebase.
What changed
v3/contents to repo root — no more nested directories.envfile approach with Doppler (project=libnovel,config=prd) for all secretsjustfile— all docker compose commands are wrapped withdoppler runso secrets are injected at runtimedocker-compose.ymlREADME.mdHow to run
- GET /api/browse fetches novelfire.net catalogue page and parses it with golang.org/x/net/html; returns JSON {novels, page, hasNext} with per-novel slug/title/cover/rank/rating/chapters/url. Supports page, genre, sort, status query params. - GET /api/scrape/status returns {"running": bool} for polling job state from the UIReplace selected={data.x === opt.value} on individual <option> elements with value={data.x} on the <select> — the idiomatic Svelte approach that ensures correct hydration and form submission of the current filter values.- ui/Dockerfile: copy package-lock.json and run npm ci --omit=dev in the runtime stage so marked (and other runtime deps) are available to adapter-node at startup — fixes ERR_MODULE_NOT_FOUND for 'marked' - storage: add ReindexChapters to Store interface and HybridStore — walks MinIO objects for a slug, reads chapter titles, upserts chapters_idx - server: add POST /api/reindex/{slug} to rebuild chapters_idx from MinIO- Replace BrowsePageKey(genre/sort/status/type/page) with BrowseHTMLKey(domain, page) -> {domain}/html/page-{n}.html - Add BrowseCoverKey(domain, slug) -> {domain}/assets/book-covers/{slug}.jpg - Add SaveBrowseAsset/GetBrowseAsset for binary assets in browse bucket - Rewrite triggerBrowseSnapshot: after storing HTML, parse it, upsert ranking records with MinIO cover keys, fire per-novel cover download goroutines - Add handleGetCover endpoint (GET /api/cover/{domain}/{slug}) to proxy cover images from MinIO - handleGetRanking rewrites MinIO cover keys to /api/cover/... proxy URLs - Update save-browse CLI to use BrowseHTMLKey, populate ranking, and download covers- Add audio.svelte.ts: module singleton AudioStore (Svelte 5 runes) with slug/chapter/title metadata, status, progress, playback state, and toggleRequest/seekRequest signals for layout<->audio element communication - Rewrite AudioPlayer.svelte as a store controller: no <audio> element owned; drives audioStore for presign->generate->play flow; shows inline controls when current chapter is active, 'Now playing / Load this chapter' banner when a different chapter is playing - Update +layout.svelte: single persistent <audio> outside {#key} block so it never unmounts on navigation; effects to load URL, sync speed, handle toggle/seek requests; fixed bottom mini-player bar with seek, skip 15s/30s, speed cycle, go-to-chapter link, dismiss; pb-24 padding when active - Pass chapterTitle and bookTitle from chapter page to AudioPlayerTwo bugs caused the start/stop loop: 1. The <audio> element was wrapped in {#if audioStore.audioUrl}, so whenever any reactive state changed (e.g. currentTime ticking), Svelte could destroy and recreate the element, firing onpause and then the URL effect restarting playback. 2. Comparing audioEl.src !== url is unreliable — browsers normalise the src property to a full absolute URL, causing false mismatches every tick. Fix: make <audio> always present in the DOM (display:none), and track the loaded URL in a plain local variable (loadedUrl) instead of reading audioEl.src.- Fix speed/voice bug: AudioPlayer no longer accepts speed/voice as props; startPlayback() reads audioStore.voice/speed directly instead of overwriting them - Add GET /api/voices endpoint (Go) proxying Kokoro, cached in-memory - Add POST /api/audio/voice-samples endpoint (Go) to pre-generate sample clips for all voices and store them in MinIO under _voice-samples/{voice}.mp3 - Add GET /api/presign/voice-sample/{voice} endpoint (Go) - Add SvelteKit proxy routes: /api/voices, /api/presign/voice-sample, /api/audio/voice-samples - Add presignVoiceSample() helper in minio.ts with proper host rewrite - Pass book.cover through +page.server.ts -> +page.svelte -> AudioPlayer - Set navigator.mediaSession.metadata on playback start so cover art, book title, and chapter title appear on phone lock screen / notificationAdd warmVoiceSamples() goroutine launched from ListenAndServe. It waits up to 30s for Kokoro to become reachable, then generates missing voice sample clips for all available voices and uploads them to MinIO (_voice-samples/{voice}.mp3). Already-existing samples are skipped, so the operation is idempotent on restarts.Adds GET /api/book-preview/{slug} and GET /api/chapter-text-preview/{slug}/{n} Go endpoints that scrape live from novelfire.net without persisting to PocketBase or MinIO. The UI book page falls back to the preview endpoint when a book is not found in PocketBase, showing a 'not in library' badge and the scraped chapter list. Chapter pages handle ?preview=1 to fetch and render chapter text live, skipping MinIO and suppressing the audio player.Replace blocking POST /api/audio with a non-blocking 202 flow: the Go handler immediately enqueues a job in a new `audio_jobs` PocketBase collection and returns {job_id, status}. A background goroutine runs the actual Kokoro TTS work and updates job status (pending → generating → done/failed). A new GET /api/audio/status/{slug}/{n} endpoint lets clients poll progress. The SvelteKit proxy and AudioPlayer.svelte are updated to POST, then poll the status route every 2s until done.- AudioPlayerService: replace loading/generating states with a single .generating state; presign fast path before triggering TTS; fix URL resolution for relative paths; cache cover artwork to avoid re-downloads; fix duration KVO race (durationObserver); use toleranceBefore/After:zero for accurate seeking; prefetch next chapter unconditionally (not just when autoNext is on); handle auto-next internally on playback finish - APIClient: fix URL construction (appendingPathComponent encodes slashes); add verbose debug logging for all requests/responses/decoding errors; fix sessions() to unwrap {sessions:[]} envelope; fix BrowseResponse CodingKey hasNext → camelCase - AudioGenerateResponse: update to synchronous {url, filename} shape - Models: remove redundant AudioStatus enum; remove CodingKeys from UserSettings (server now sends camelCase) - Views: fix alert bindings (.constant → proper two-way Binding); add error+retry UI to BrowseView; add pull-to-refresh to browse list; fix ChapterReaderView to show error state and use dynamic WKWebView height; fix HomeView HStack alignment; only treat audio as current if the player isActive; suppress CancellationError from error UI- Go scraper: add PresignAvatarUploadURL/PresignAvatarURL/DeleteAvatar to Store interface, implement on HybridStore+MinioClient, register GET /api/presign/avatar-upload/{userId} and /api/presign/avatar/{userId} - SvelteKit: replace direct AWS S3 SDK in minio.ts with presign calls to the Go scraper; rewrite avatar +server.ts (POST=presign, PATCH=record key) - iOS: rewrite uploadAvatar() as 3-step presigned PUT flow; refactor chip components into shared ChipButton in CommonViews.swift- Add AvatarCropModal.svelte using cropperjs v1: 1:1 crop, 400×400 output, JPEG/WebP output, dark glassmorphic UI - Rewrite profile page avatar upload to use presigned PUT flow (POST→PUT→PATCH) instead of sending raw FormData directly; crop modal opens on file select - Add GET /health → {status:ok} for Docker healthcheck - Simplify Dockerfile: drop runtime npm ci (adapter-node bundles all deps) - Fix docker-compose UI healthcheck: /health route, 127.0.0.1 to avoid IPv6 localhost resolution failure in alpine busybox wgetmarked({ async: true }) triggers a dynamic internal require inside marked that vite/rollup treats as external, causing ERR_MODULE_NOT_FOUND at runtime in the adapter-node Docker image which ships no node_modules. Switching to the synchronous marked() call makes rollup inline the full library into the server chunk.The /api/comments/[id] delete route was never created; the deleteComment helper in pocketbase.ts existed but was unreachable. Added DELETE /api/comment/[id] route handler alongside the existing vote route. Updated CommentsSection.svelte and iOS APIClient to use /api/comment/{id} for both delete and (already fixed) vote, keeping all comment-mutation endpoints under the singular /api/comment/ prefix to avoid SvelteKit route conflicts with /api/comments/[slug].- docker-compose.yml: add image: kalekber/libnovel-{backend,runner,ui,caddy}:${GIT_TAG} alongside each build: block — prod pulls from Docker Hub, dev builds locally with just build - justfile: add push, build-push, pull-images, pull-infra recipes - ci-v3.yaml: fix v3/ path references, add caddy job, add registry layer cache - release-v3.yaml: fix v3/ path references, add caddy job, simplify tag pattern to semver (v*), add layer cache, caddy added to release gate- Add email/email_verified/verification_token/verification_token_exp fields to app_users PocketBase schema (pb-init-v3.sh) - Add SMTP env vars to UI service in docker-compose.yml - New email.ts: raw TLS SMTP mailer via Node tls module, sendVerificationEmail() - createUser() now takes email param, stores verification token (24h TTL) - loginUser() throws 'Email not verified' when email_verified is false - New /verify-email route: validates token, verifies user, auto-logs in - Login page: email field in register form, check-inbox state after register - /api/auth/register (iOS): returns { pending_verification, email } instead of token - Add pb.libnovel.cc and storage.libnovel.cc Caddy virtual hosts for homelab runner - Add homelab runner docker-compose and libnovel.sh helper scriptIntroduce a Redis-backed Asynq task queue so the runner consumes TTS jobs pushed by the backend instead of polling PocketBase. - backend/internal/asynqqueue: Producer and Consumer wrappers - backend/internal/runner: AsynqRunner mux, per-instance Prometheus registry (fixes duplicate-collector panic in tests), redisConnOpt - backend/internal/config: REDIS_ADDR / REDIS_PASSWORD env vars - backend/cmd/{backend,runner}/main.go: wire Redis when env set; fall back to legacy poll mode when unset - Caddyfile: caddy-l4 TCP proxy for redis.libnovel.cc:6380 → homelab - caddy/Dockerfile: add --with github.com/mholt/caddy-l4 - docker-compose.yml: Caddy exposes 6380, backend/runner get Redis env - homelab/runner/docker-compose.yml: Redis sidecar, runner depends_on - homelab/otel/grafana: Grafana dashboards (backend, catalogue, runner) and alerting rules / contact-points provisioning- docker-compose.yml: MEILI_URL in x-infra-env was hardcoded to the local meilisearch container, ignoring the Doppler MEILI_URL secret entirely. Changed to "${MEILI_URL:-http://meilisearch:7700}" so prod reads from https://search.libnovel.cc while local dev keeps the container default. - ui/+layout.svelte: rename nav + footer label from "Discover" to "Catalogue" (desktop nav, mobile menu, footer — all three occurrences).The bare { } block at the bottom was a second global options block which Caddy's caddyfile adapter rejects on reload. Merged layer4 into the single top-level global block. Changed listener from hostname (redis.libnovel.cc:6380) to :6380 so Caddy binds to the local interface rather than the Cloudflare IP that resolves for the hostname.- LibreTranslate client (chunks on blank lines, ≤4500 chars, 3-goroutine semaphore) - Runner translation task loop (OTel, heartbeat, MinIO storage) - PocketBase translation_jobs collection support (create/claim/finish/list) - Per-chapter language switcher on chapter reader (EN/RU/ID/PT/FR, polls until done) - Admin /admin/translation page: bulk enqueue form + live-polling jobs table - New backend routes: POST /api/translation/{slug}/{n}, GET /api/translation/status, GET /api/translation/{slug}/{n}, GET /api/admin/translation/jobs, POST /api/admin/translation/bulk - ListTranslationTasks added to taskqueue.Reader interface + store impl - All builds and tests pass; svelte-check: 0 errors- Webhook handler verifies HMAC-SHA256 sig and updates user role on subscription.created / subscription.updated / subscription.revoked - Audio endpoint gated: free users limited to 3 chapters/day via Valkey counter; returns 402 {error:'pro_required'} when limit reached - Translation proxy endpoint enforces 402 for non-pro users - AudioPlayer.svelte surfaces 402 via onProRequired callback + upgrade banner - Chapter page shows lock icon + upgrade prompts for gated translation langs - Profile page: subscription section shows Pro badge + manage link (active) or monthly/annual checkout buttons (free); isPro resolved fresh from DB - i18n: 13 new profile_subscription_* keys across all 5 localesReading content is now publicly accessible without login: - /catalogue, /books/[slug], /books/[slug]/chapters, /books/[slug]/chapters/[n] are all public — no login redirect - /books (personal library), /profile, /admin remain protected Unauthenticated UX: - Chapter page: "Audio narration available — Sign in to listen" banner replaces the audio player for logged-out visitors - Book detail: bookmark icon links to /login instead of triggering the save action (both desktop and mobile CTAs) SEO: - robots.txt updated: Allow /books/ and /catalogue, Disallow /books (library) - sitemap.xml now includes /catalogue as primary crawl entry point i18n: added reader_signin_for_audio, reader_signin_audio_desc, book_detail_signin_to_save in all 5 languages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>- Add --color-success variable to all three themes - Replace hard-coded amber/green Tailwind colours with CSS variables: Pro badge + discount badge: bg-amber-400/15 → bg-(--color-brand)/15 Saved confirmation: text-green-400 → text-(--color-success) Catalogue flash messages: emerald/yellow → success/brand tokens Login hover border: border-zinc-600 → border-(--color-brand)/50 - Seek bar in mini-player now has aria-label (player_seek_label) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>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 bufferingWithout 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.ymlThe 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.- 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>- 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)**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>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)- Fix handlers_image.go: cover_url now uses /api/cover/novelfire.net/{slug} (was 'local') - Add POST /api/admin/image-gen/save-cover: persists pre-generated base64 directly to MinIO via PutCover without re-calling Cloudflare AI - Add SvelteKit proxy route api/admin/image-gen/save-cover/+server.ts - Update UI saveAsCover(): send existing image_b64 to save-cover instead of re-generating - Run npm run paraglide to compile admin_nav_image_gen message; force-add gitignored files - svelte-check: 0 errors, 21 warnings (all pre-existing)- Default max_tokens to 4096 for chapter-names so large chapter lists are not cut off mid-JSON by the model's token limit - Rewrite system prompt to clarify placeholder semantics ({n} = number, {scene} = scene hint) and explicitly forbid echoing the number inside the title field — prevents "Chapter 1 - 1: ..." style duplications - UI: surface raw_response in the error area when chapters:[] is returned so the admin can see what the model actually producedWithout explicit setActionHandler('play'/'pause'/...) the browser uses default handling which on iOS Safari stops working after ~1 min of pause in background/locked state (AudioSession gets suspended and the lock screen button can't resume it without a direct user gesture). Registering handlers in the layout (where audioEl lives) ensures: - play/pause call audioEl directly — iOS treats this as a trusted gesture and allows .play() even from the lock screen - seekbackward/seekforward map to ±15s / ±30s - playbackState stays in sync so the lock screen shows the right icon Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Previously all voices waited for full TTS generation before first byte reached the browser (POST → poll → presign flow). For long chapters this meant 2–5 minutes of silence. New flow for Kokoro and PocketTTS: - tryPresign fast path unchanged (audio in MinIO → seekable presigned URL) - On cache miss: set audioEl.src to /api/audio-stream/{slug}/{n} and mark status=ready immediately — audio starts playing within seconds - Backend streams bytes to browser while concurrently uploading to MinIO; subsequent plays use the fast path New SvelteKit route: GET /api/audio-stream/[slug]/[n] - Proxies backend handleAudioStream (already implemented in Go) - Same 3 chapters/day free paywall as POST route - HEAD handler for paywall pre-check (no side effects, no counter increment) so AudioPlayer can surface upgrade CTA before pointing <audio> at URL CF AI voices keep the old POST+poll flow (batch-only API, no real streaming benefit, preserves the generating progress bar UX). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>html and fetchingContent were captured with untrack() at mount time. When SvelteKit re-ran the page load (triggered by the layout's settings PUT), data.html updated but html stayed stale. The {#key} block in the layout then destroyed and recreated the component, and on remount data.html was momentarily empty so html became '' and the live-scrape fallback ran unnecessarily. Fix: - html is now $derived(scrapedHtml || data.html || '') — always tracks load - scrapedHtml is a separate $state only set by the live-scrape fallback - fetchingContent starts false; the fallback sets it true only when actually fetching - translationStatus/translatingLang: dropped untrack() so they also react to re-runs - Removed unused untrack import- Fix upsertChapterIdx race: use conflict-retry pattern (mirrors WriteMetadata) so concurrent goroutines don't double-POST the same chapter number - Add DeduplicateChapters to BookWriter interface and Store implementation; keeps the latest record per (slug, number) and deletes extras - Wire POST /api/admin/dedup-chapters/{slug} handler in server.goLlama 4 Scout returns `result.response` as an array of objects [{"generated_text":"..."}] instead of a plain string. Decode into json.RawMessage and try both shapes; fall back to generated_text[0]. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Backend (handlers_catalogue.go): - POST /api/admin/text-gen/tagline — 1-sentence marketing hook - POST /api/admin/text-gen/genres + /apply — LLM genre suggestions, editable + persist - POST /api/admin/text-gen/content-warnings — mature theme detection - POST /api/admin/text-gen/quality-score — 1–5 description quality rating - POST /api/admin/catalogue/batch-covers (SSE) — generate covers for books missing one - POST /api/admin/catalogue/batch-covers/cancel — cancel via in-memory job registry - POST /api/admin/catalogue/refresh-metadata/{slug} (SSE) — description + cover refresh Frontend: - text-gen: 4 new tabs (Tagline, Genres, Warnings, Quality) with book autocomplete - image-gen: localStorage style presets (save/apply/delete named prompt templates) - catalogue-tools: new admin page with batch cover SSE progress + cancel - admin nav: "Catalogue Tools" link added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>When PREBUILT=1 the pre-built artifact is downloaded into ui/build/ but .dockerignore excludes 'build', so Docker never sees it and /app/build doesn't exist in the builder stage — causing the runtime COPY to fail. Fix: rewrite ui/.dockerignore on the CI runner (grep -v '^build$') so the pre-built directory is included in the Docker context. Also in this commit: - book page: gate EPUB download on isPro (UI upsell + server 403 guard) - book page: chapter names default pattern changed to '{scene}'- Backend: add GET /api/audio-preview/{slug}/{n} — generates first ~1800-char chunk via CF AI so playback starts immediately; full chapter cached in MinIO - Frontend: replace CF AI spinner with preview blob URL + background swap to full presigned URL when runner finishes, preserving currentTime - AudioPlayer: isPreview state + 'preview' badge in mini-bar during swap - pocketbase.ts: fix 403 on stale token — reduce TTL to 50 min + retry once on 401/403 with forced re-auth (was cached 12 h, PB tokens expire in 1 h) - Footer build time now rendered in user's local timezone via toLocaleString()- justify-between on scrollable body so cover art sits top, controls sit bottom — no more half-empty screen on tall phones - Move ListeningMode outside {#if audioStore.active} so pausing never tears down the overlay and loses resume context - Mini-bar time/track click now opens ListeningMode with chapter picker pre-shown (same view as the Chapters button inside ListeningMode) - Remove the old chapterDrawerOpen mini-bar drawer (replaced by above) - Add openChapters prop to ListeningMode for pre-opening chapter modalCycles through all in-progress books every 6s; prev/next arrow buttons overlay the card edges; active dot stretches to a pill; cover fades in on slide change via {#key} + animate-fade-in; shelf excludes the current hero to avoid duplication. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>- New SearchModal.svelte: full-screen modal with blurred backdrop - Live results as you type (300ms debounce, min 2 chars) - Local vs Novelfire badge on each result card (cover + title + author + genres + chapter count) - Local/remote counts shown in result header - 'See all in catalogue' shortcut button + footer repeat link - Recent searches (localStorage, max 8, per-item remove + clear all) - Genre suggestion chips shown when query is empty or no results found - Keyboard navigation: ArrowUp/Down to select, Enter to open, Escape to close - Body scroll lock while open - +layout.svelte: - Imports SearchModal, adds searchOpen state - Search icon button in nav header (hidden on chapter reader pages) - Global keyboard shortcut: '/' or Cmd/Ctrl+K opens modal - Shortcut ignored when focused in input/textarea or on chapter pages - Modal not shown while ListeningMode is open - Auto-closes on route changeRoot cause: the chapter picker overlay (fixed inset-0) was rendered as a descendant of the AudioPlayer's wrapping div, which is itself inside a 'rounded-b-lg overflow-hidden' container on the chapter page. Chrome clips position:fixed descendants when a parent has overflow:hidden + border-radius, so the overlay was constrained to the parent's bounding box instead of covering the full viewport. Fix: moved the {#if showChapterPanel} block from inside the standard player div to a top-level sibling at the end of the component template. Svelte components support multiple root nodes, so the overlay is now a sibling of all player containers — no ancestor overflow-hidden can clip it. Also replaced the subtle chevron-down close button with a clear X (×) button in the top-right of the header, making it obvious how to dismiss the panel.The {#if playerStyle === 'compact'} block was left unclosed after the idle-state refactor, causing a svelte-check parse error.- Wrap {#key} children in fade transition (out 100ms, in 180ms+60ms delay) for a smooth cross-fade between pages with no added dependencies - Halve navigation progress bar animation duration (8s → 4s) for more realistic feedback on typical navigations - Add prefers-reduced-motion media query to collapse all animation/transition durations for users with accessibility needs - Add content-visibility: auto on footer to skip browser paint of off-screen content and improve rendering performance- Remove compact player mode (seekable bar inline was undifferentiated from standard; didn't have voice/chapter/warm-up controls) - Add 'float' player style: a fixed, draggable mini-overlay positioned above the bottom mini-bar (bottom-right corner, z-55) · Seek bar, skip ±15/30s, play/pause, time, speed indicator · Drag handle (pointer capture) to reposition anywhere on screen · Animated playing pulse dot in the title row · Suppresses the standard non-idle inline block when active so there is no duplicate UI - PlayerStyle type: 'standard' | 'float' (was 'compact') - Bump localStorage key to reader_layout_v2 so old 'compact' pref does not pollute the new default - Listening tab Style row now shows Standard / Float- Remove all emojis (flame icon in streak widget, 🔥 in stats footer, ✓ in completed badge) — they cheapened the overall feel - Fix double carousel indicator: drop the animated progress sub-line below the active dot; the expanding pill shape is sufficient signal. Also removes the rAF animation loop and progressStart state. - Remove 'X left' badge from Continue Reading shelf cards - Remove 'X chapters ahead' text from hero card info rowCloudflare Workers AI changed the API for flux-2-dev, flux-2-klein-4b, and flux-2-klein-9b to require multipart/form-data (instead of JSON) and now returns {"image":"<base64>"} instead of raw PNG bytes. - Add requiresMultipart() helper for the three FLUX.2 models - callImageAPI builds multipart body for those models, JSON for others - Parse {"image":"<base64>"} JSON response; fall back to raw bytes for legacy models - Use "steps" field name (not "num_steps") in multipart forms per CF docs - Book page: capture and display actual backend error message instead of blank 'Error'6617828487toa8a7151feea8a7151feetoa371acb28ea371acb28etoe088bc056e- svelte.config.js: paths.relative=false so CSS uses absolute /_app/ paths (fixes blank home page after redirect) - ai-jobs: fix openReview() mutating stale alias r instead of $state review — was causing 'Loading results...' to never resolve for chapter-names/image-gen/description - notifications bell: redesign with All/Unread tabs, per-item dismiss (×), mark-all-read, clear-all, 'View all' footer link - /admin/notifications: new dedicated full-page notifications view - api/notifications proxy: add PATCH (mark-all-read) and DELETE (clear-all, dismiss) handlers - runner: add CreateNotification calls on success/failure in runScrapeTask, runAudioTask, runTranslationTask - storage/import.go: real PDF (dslipak/pdf) and EPUB (archive/zip + x/net/html) parsing replacing stubs - translation admin page: stream jobs Promise instead of blocking navigation - store.go: DeleteNotification, ClearAllNotifications, MarkAllNotificationsRead methods - handlers_notifications.go + server.go: PATCH /api/notifications, DELETE /api/notifications, DELETE /api/notifications/{id}- Add `archived` bool to domain.BookMeta, pbBook, and Meilisearch bookDoc - ArchiveBook / UnarchiveBook patch the PocketBase record; ListBooks filters archived=false so hidden books disappear from all public responses - Meilisearch: add `archived` as a filterable attribute; Search and Catalogue always prepend `archived = false` to exclude archived books from results - DeleteBook permanently removes the PocketBase record, all chapters_idx rows, MinIO chapter objects, cover image, and the Meilisearch document - New BookAdminStore interface with ArchiveBook, UnarchiveBook, DeleteBook - Admin HTTP endpoints: PATCH /api/admin/books/{slug}/archive|unarchive, DELETE /api/admin/books/{slug} - PocketBase schema: archived field added to live pb.libnovel.cc and to pb-init-v3.sh (both create block and add_field migration)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 {#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 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.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>The Gitea runner's docker buildx doesn't support HCL locals{} or function{} blocks (added in buildx 0.12+). Replace with plain variables: VERSION and MAJOR_MINOR are pre-computed in a CI step and passed as env vars to bake. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Content visibility: - Add `visibility` field to books ("public" | "admin_only"); new migration backfills all existing scraped books to admin_only - Meilisearch: add visibility as filterable attribute; catalogue/search endpoints filter to public-only for non-admin requests - Admin users identified by bearer token bypass the filter and see all books - All PocketBase discovery queries (trending, recommended, recently-updated, audio shelf, discover, subscription feed) now filter to visibility=public - New scraped books default to admin_only; WriteMetadata preserves existing visibility on PATCH (never overwrites) Author submission: - POST /api/admin/books/submit — creates a public book with submitted_by - PATCH /api/admin/books/{slug}/publish / unpublish — toggle visibility - SvelteKit proxies: /api/admin/books/[slug]/publish|unpublish - /api/books/[slug] endpoint for admin book lookup Frontend: - backendFetchAdmin() helper sends admin token on any path - Catalogue server load uses admin fetch when user is admin - /submit page: author submission form with genre picker and rights assertion - "Publish" nav link shown to all logged-in users - Admin catalogue-tools: visibility management panel (load book by slug, toggle) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>- Catalogue/cover: rewrite raw scraped cover URLs to /api/cover/{domain}/{slug} in handleCatalogue so all covers route through the backend proxy; fix broken cdn.novelfire.net fallback in handleGetCover to read stored URL from PocketBase - Catalogue/profile: add Svelte 5 onerror handlers on cover <img> tags to show letter-initial placeholder when image fails to load - Library page: read ?status URL param to initialise activeShelf tab on load so /books?status=reading correctly pre-selects the Reading tab - Sessions: filter bot/tool user-agents (curl, python, wget, etc.) and debug-IP sessions from listUserSessions display; also purge them in pruneStaleUserSessions - Profile: show email under username, quick stats chips (streak/chapters/completed) in header, reading count on Library row, dedicated Sign out row, history covers routed through /api/cover proxy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Admins can now generate TTS audiobooks chapter-by-chapter and publish them as standard RSS 2.0 + iTunes podcast feeds that Spotify, Apple Podcasts, and any podcast app can subscribe to. Backend: - POST /api/admin/podcast — creates ai_job (kind=podcast), spawns goroutine that generates TTS for missing chapters and writes to MinIO - GET /podcast/{slug}.xml?voice=<id> — public RSS feed with correct pubDate (from chapters_idx.created) and enclosure length (MinIO stat) - GET /podcast/audio/{slug}/{n}/{voice} — public audio proxy, 302 to presigned MinIO URL (no auth required for podcast clients) - GET /api/admin/podcast/{slug} — list podcast jobs for a book - Add AudioObjectSize to AudioStore interface backed by MinIO StatObject - Populate ChapterInfo.Date from chapters_idx.created in ListChapters UI: - New /admin/podcast page: book + voice selector, chapter range, live progress bars, copy-feed-URL button, cancel button, how-to instructions - /api/admin/podcast SvelteKit proxy (injects admin Bearer token) - Podcast link added to admin sidebar Cleanup: - Delete stray playwright screenshot files from repo root - Add .playwright-mcp/ to .gitignore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.