Compare commits

...

6 Commits

Author SHA1 Message Date
Admin
0ae71c62f9 fix: switch CI source map upload from glitchtip-cli to sentry-cli
All checks were successful
Release / Test backend (push) Successful in 1m9s
Release / Check ui (push) Successful in 51s
Release / Docker / caddy (push) Successful in 46s
Release / Docker / backend (push) Successful in 4m30s
Release / Docker / runner (push) Successful in 3m54s
Release / Upload source maps (push) Successful in 53s
Release / Docker / ui (push) Successful in 2m57s
Release / Gitea Release (push) Successful in 57s
glitchtip-cli v0.1.0 uploads chunks but never calls the assemble endpoint,
leaving releases with 0 files. sentry-cli correctly calls assemble after
chunk upload — confirmed working in manual test (2.5.84-test3 = 211 files).
2026-04-05 17:52:42 +05:00
Admin
d0c95889ca fix: persist GlitchTip GzipChunk patch and prune old releases in CI
All checks were successful
Release / Test backend (push) Successful in 45s
Release / Check ui (push) Successful in 50s
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 2m43s
Release / Upload source maps (push) Successful in 4m36s
Release / Docker / ui (push) Successful in 4m38s
Release / Gitea Release (push) Successful in 4m46s
- Add homelab/glitchtip/files_api.py with GzipChunk fallback for sentry-cli 3.x
  raw zip uploads; bind-mount into both glitchtip-web and glitchtip-worker
- Add release.yaml prune step to delete all but the 10 newest GlitchTip releases
- Reader page: remove dead code and simplify layout
2026-04-05 17:30:39 +05:00
Admin
a3ad54db70 feat: add Listening Mode overlay with voice picker, speed, sleep timer, and chapter list
Some checks failed
Release / Test backend (push) Has been cancelled
Release / Check ui (push) Has been cancelled
Release / Docker / backend (push) Has been cancelled
Release / Docker / runner (push) Has been cancelled
Release / Upload source maps (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Docker / caddy (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Adds a full-screen listening mode accessible via the headphones button in the
mini-player bar. Moves voice selector, speed, auto-next, and sleep timer out of
the reader settings panel into the new overlay. Voices are stored in AudioStore
so ListeningMode can read them without prop drilling.
2026-04-05 17:06:56 +05:00
Admin
48bc206c4e Fix GlitchTip source map assembly: shared uploads volume + MEDIA_ROOT
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 49s
Release / Docker / caddy (push) Successful in 51s
Release / Docker / backend (push) Successful in 2m47s
Release / Docker / runner (push) Successful in 2m38s
Release / Upload source maps (push) Successful in 3m20s
Release / Docker / ui (push) Successful in 2m59s
Release / Gitea Release (push) Successful in 1m45s
- Mount glitchtip_uploads named volume to /code/uploads on both
  glitchtip-web and glitchtip-worker so chunk blobs written by the
  web container are readable by the worker during assembly
- Set MEDIA_ROOT=/code/uploads explicitly on both services so the
  Django storage path matches the volume mount and DB blob records
2026-04-05 16:58:56 +05:00
Admin
4c1ad84fa9 Fix source maps + reader UI redesign
All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 53s
Release / Docker / backend (push) Successful in 3m13s
Release / Docker / runner (push) Successful in 2m54s
Release / Upload source maps (push) Successful in 3m46s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Successful in 1m26s
- Strip v prefix from GlitchTip release name in upload-sourcemaps job
  so it matches PUBLIC_BUILD_VERSION reported by the deployed app
- Focus mode: hide bottom nav/comments, show floating prev/next/exit pill
- Listening mode: full-screen overlay with transport, speed pills, voice selector
- Settings panel: dedup speed/auto-next/sleep controls (single source of truth)
- Mini-bar: unified speed steps, headphones button opens ListeningMode
2026-04-05 16:14:28 +05:00
Admin
9c79fd5deb feat: AI job tracking, range support, auto-prompt, and resume
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 55s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / backend (push) Successful in 3m21s
Release / Docker / runner (push) Successful in 2m41s
Release / Upload source maps (push) Successful in 3m44s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Successful in 1m22s
- New `ai_jobs` PocketBase collection tracks all long-running AI tasks
  (batch-covers, chapter-names) with status, progress, and cancellation
- `handlers_aijobs.go`: GET/cancel endpoints for ai_jobs; centralised
  cancel registry (moved from handlers_catalogue)
- Batch-covers and chapter-names SSE handlers now create/resume ai_job
  records, support from_item/to_item ranges, and resume from items_done
  on restart via job_id
- New `POST /api/admin/image-gen/auto-prompt`: generates an image prompt
  from book description (cover) or chapter title (chapter) via LLM
- image-gen page: "Auto-prompt" button calls auto-prompt API when a slug
  is selected; falls back gracefully if TextGen not configured
- text-gen chapter-names: from/to chapter range inputs + job ID display
- catalogue-tools batch-covers: from/to item range + resume job ID input
- pb-init-v3.sh: adds ai_jobs collection (idempotent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 15:40:52 +05:00
20 changed files with 1412 additions and 229 deletions

View File

@@ -155,17 +155,20 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Compute release version (strip leading v)
id: ver
run: |
V="${{ gitea.ref_name }}"
echo "version=${V#v}" >> "$GITHUB_OUTPUT"
- name: Build with source maps
run: npm run build
- name: Download glitchtip-cli
run: |
curl -L "https://gitlab.com/glitchtip/glitchtip-cli/-/jobs/artifacts/v0.1.0/raw/artifacts/glitchtip-cli-linux-x86_64?job=build-linux-x86_64" \
-o /usr/local/bin/glitchtip-cli
chmod +x /usr/local/bin/glitchtip-cli
- name: Install sentry-cli
run: npm install -g @sentry/cli
- name: Inject debug IDs into build artifacts
run: glitchtip-cli sourcemaps inject ./build
run: sentry-cli sourcemaps inject ./build
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
@@ -173,7 +176,7 @@ jobs:
SENTRY_PROJECT: ui
- name: Create GlitchTip release
run: glitchtip-cli releases new ${{ gitea.ref_name }}
run: sentry-cli releases new ${{ steps.ver.outputs.version }}
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
@@ -181,7 +184,7 @@ jobs:
SENTRY_PROJECT: ui
- name: Upload source maps to GlitchTip
run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
run: sentry-cli sourcemaps upload ./build --release ${{ steps.ver.outputs.version }}
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
@@ -189,13 +192,36 @@ jobs:
SENTRY_PROJECT: ui
- name: Finalize GlitchTip release
run: glitchtip-cli releases finalize ${{ gitea.ref_name }}
run: sentry-cli releases finalize ${{ steps.ver.outputs.version }}
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
- name: Prune old GlitchTip releases (keep latest 10)
run: |
set -euo pipefail
KEEP=10
OLD=$(curl -sf \
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
"$SENTRY_URL/api/0/organizations/$SENTRY_ORG/releases/?project=$SENTRY_PROJECT&per_page=100" \
| python3 -c "
import sys, json
releases = json.load(sys.stdin)
for r in releases[$KEEP:]:
print(r['version'])
" KEEP=$KEEP)
for ver in $OLD; do
echo "Deleting old release: $ver"
sentry-cli releases delete "$ver" || true
done
env:
SENTRY_URL: https://errors.libnovel.cc
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
# ── docker: ui ────────────────────────────────────────────────────────────────
docker-ui:
name: Docker / ui

View File

@@ -195,6 +195,7 @@ func run() error {
ImageGen: imageGenClient,
TextGen: textGenClient,
BookWriter: store,
AIJobStore: store,
Log: log,
},
)

View File

@@ -0,0 +1,233 @@
package backend
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
)
// ── Cancel registry ────────────────────────────────────────────────────────────
// cancelJobsMu guards cancelJobs.
var cancelJobsMu sync.Mutex
// cancelJobs maps a job ID to its CancelFunc. Entries are added when a batch
// job starts and removed when it finishes or is cancelled.
var cancelJobs = map[string]context.CancelFunc{}
func registerCancelJob(id string, cancel context.CancelFunc) {
cancelJobsMu.Lock()
cancelJobs[id] = cancel
cancelJobsMu.Unlock()
}
func deregisterCancelJob(id string) {
cancelJobsMu.Lock()
delete(cancelJobs, id)
cancelJobsMu.Unlock()
}
// ── AI Job list / get / cancel ─────────────────────────────────────────────────
// handleAdminListAIJobs handles GET /api/admin/ai-jobs.
// Returns all ai_job records sorted by started descending.
func (s *Server) handleAdminListAIJobs(w http.ResponseWriter, r *http.Request) {
if s.deps.AIJobStore == nil {
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
return
}
jobs, err := s.deps.AIJobStore.ListAIJobs(r.Context())
if err != nil {
s.deps.Log.Error("admin: list ai jobs failed", "err", err)
jsonError(w, http.StatusInternalServerError, "list ai jobs: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"jobs": jobs})
}
// handleAdminGetAIJob handles GET /api/admin/ai-jobs/{id}.
func (s *Server) handleAdminGetAIJob(w http.ResponseWriter, r *http.Request) {
if s.deps.AIJobStore == nil {
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
return
}
id := r.PathValue("id")
job, ok, err := s.deps.AIJobStore.GetAIJob(r.Context(), id)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("job %q not found", id))
return
}
writeJSON(w, 0, job)
}
// handleAdminCancelAIJob handles POST /api/admin/ai-jobs/{id}/cancel.
// Marks the job as cancelled in PB and cancels the in-memory context if present.
func (s *Server) handleAdminCancelAIJob(w http.ResponseWriter, r *http.Request) {
if s.deps.AIJobStore == nil {
jsonError(w, http.StatusServiceUnavailable, "ai job store not configured")
return
}
id := r.PathValue("id")
// Cancel in-memory context if the job is still running in this process.
cancelJobsMu.Lock()
if cancel, ok := cancelJobs[id]; ok {
cancel()
}
cancelJobsMu.Unlock()
// Mark as cancelled in PB.
if err := s.deps.AIJobStore.UpdateAIJob(r.Context(), id, map[string]any{
"status": string(domain.TaskStatusCancelled),
"finished": time.Now().Format(time.RFC3339),
}); err != nil {
s.deps.Log.Error("admin: cancel ai job failed", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, "cancel ai job: "+err.Error())
return
}
s.deps.Log.Info("admin: ai job cancelled", "id", id)
writeJSON(w, 0, map[string]any{"cancelled": true})
}
// ── Auto-prompt ────────────────────────────────────────────────────────────────
// autoPromptRequest is the JSON body for POST /api/admin/image-gen/auto-prompt.
type autoPromptRequest struct {
// Slug is the book slug.
Slug string `json:"slug"`
// Type is "cover" or "chapter".
Type string `json:"type"`
// Chapter number (required when type == "chapter").
Chapter int `json:"chapter"`
// Model is the text-gen model to use. Defaults to DefaultTextModel.
Model string `json:"model"`
}
// autoPromptResponse is returned by POST /api/admin/image-gen/auto-prompt.
type autoPromptResponse struct {
Prompt string `json:"prompt"`
Model string `json:"model"`
}
// handleAdminImageGenAutoPrompt handles POST /api/admin/image-gen/auto-prompt.
//
// Uses the text generation model to create a vivid image generation prompt
// based on the book's description (for covers) or chapter title/content (for chapters).
func (s *Server) handleAdminImageGenAutoPrompt(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
return
}
var req autoPromptRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if req.Type != "cover" && req.Type != "chapter" {
jsonError(w, http.StatusBadRequest, `type must be "cover" or "chapter"`)
return
}
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
return
}
model := req.Model
if model == "" {
model = string(cfai.DefaultTextModel)
}
var userPrompt string
if req.Type == "cover" {
userPrompt = fmt.Sprintf(
"Book: \"%s\"\nAuthor: %s\nGenres: %s\n\nDescription:\n%s",
meta.Title,
meta.Author,
strings.Join(meta.Genres, ", "),
meta.Summary,
)
} else {
// For chapter images, use chapter title if available.
chapterTitle := fmt.Sprintf("Chapter %d", req.Chapter)
if req.Chapter > 0 {
chapters, listErr := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
if listErr == nil {
for _, ch := range chapters {
if ch.Number == req.Chapter {
chapterTitle = ch.Title
break
}
}
}
}
userPrompt = fmt.Sprintf(
"Book: \"%s\"\nGenres: %s\nChapter: %s\n\nBook description:\n%s",
meta.Title,
strings.Join(meta.Genres, ", "),
chapterTitle,
meta.Summary,
)
}
systemPrompt := buildAutoPromptSystem(req.Type)
s.deps.Log.Info("admin: image auto-prompt requested",
"slug", req.Slug, "type", req.Type, "chapter", req.Chapter, "model", model)
result, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: cfai.TextModel(model),
Messages: []cfai.TextMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
MaxTokens: 256,
})
if genErr != nil {
s.deps.Log.Error("admin: auto-prompt failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
return
}
writeJSON(w, 0, autoPromptResponse{
Prompt: strings.TrimSpace(result),
Model: model,
})
}
func buildAutoPromptSystem(imageType string) string {
if imageType == "cover" {
return `You are a professional prompt engineer for AI image generation (Stable Diffusion / FLUX models). ` +
`Given a book's title, genres, and description, write a single vivid image generation prompt ` +
`for a book cover. The prompt should describe the visual composition, art style, lighting, ` +
`and mood without mentioning text or typography. ` +
`Format: comma-separated visual descriptors, 3060 words. ` +
`Output ONLY the prompt — no explanation, no quotes, no labels.`
}
return `You are a professional prompt engineer for AI image generation (Stable Diffusion / FLUX models). ` +
`Given a book's title, genres, and a specific chapter title, write a single vivid scene illustration prompt. ` +
`Describe the scene, characters, setting, lighting, and art style. ` +
`Format: comma-separated visual descriptors, 3060 words. ` +
`Output ONLY the prompt — no explanation, no quotes, no labels.`
}

View File

@@ -16,32 +16,12 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
)
// ── Cancel registry ────────────────────────────────────────────────────────
// cancelJobsMu guards cancelJobs.
var cancelJobsMu sync.Mutex
// cancelJobs maps a job ID to its CancelFunc. Entries are added when a batch
// job starts and removed when it finishes or is cancelled.
var cancelJobs = map[string]context.CancelFunc{}
func registerCancelJob(id string, cancel context.CancelFunc) {
cancelJobsMu.Lock()
cancelJobs[id] = cancel
cancelJobsMu.Unlock()
}
func deregisterCancelJob(id string) {
cancelJobsMu.Lock()
delete(cancelJobs, id)
cancelJobsMu.Unlock()
}
// ── Tagline ───────────────────────────────────────────────────────────────
@@ -452,8 +432,9 @@ type batchCoverEvent struct {
// Streams SSE events as it generates covers for every book that has no cover
// stored in MinIO. Each event carries progress info. The final event has Finish=true.
//
// The job can be cancelled by calling POST /api/admin/catalogue/batch-covers/cancel
// with body {"job_id":"..."}.
// Supports from_item/to_item to process a sub-range of the catalogue (0-based indices).
// Supports job_id to resume a previously interrupted job.
// The job can be cancelled by calling POST /api/admin/ai-jobs/{id}/cancel.
func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil || s.deps.ImageGen == nil {
jsonError(w, http.StatusServiceUnavailable, "image/text generation not configured")
@@ -469,22 +450,34 @@ func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request)
NumSteps int `json:"num_steps"`
Width int `json:"width"`
Height int `json:"height"`
FromItem int `json:"from_item"`
ToItem int `json:"to_item"`
JobID string `json:"job_id"`
}
// Body is optional — defaults used if absent.
json.NewDecoder(r.Body).Decode(&reqBody) //nolint:errcheck
books, err := s.deps.BookReader.ListBooks(r.Context())
allBooks, err := s.deps.BookReader.ListBooks(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "list books: "+err.Error())
return
}
// Generate a unique job ID.
jobID := randomHex(8)
ctx, cancel := context.WithCancel(r.Context())
registerCancelJob(jobID, cancel)
defer deregisterCancelJob(jobID)
defer cancel()
// Apply range filter.
books := allBooks
if reqBody.FromItem > 0 || reqBody.ToItem > 0 {
from := reqBody.FromItem
to := reqBody.ToItem
if to == 0 || to >= len(allBooks) {
to = len(allBooks) - 1
}
if from < 0 {
from = 0
}
if from <= to && from < len(allBooks) {
books = allBooks[from : to+1]
}
}
// SSE headers.
w.Header().Set("Content-Type", "text/event-stream")
@@ -503,19 +496,75 @@ func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request)
total := len(books)
done := 0
// Send initial event with jobID so frontend can store it for cancellation.
sseWrite(batchCoverEvent{JobID: jobID, Done: 0, Total: total})
// Create or resume PB ai_job and register cancel context.
var pbJobID string
resumeFrom := 0
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
for _, book := range books {
if s.deps.AIJobStore != nil {
if reqBody.JobID != "" {
if existing, ok, _ := s.deps.AIJobStore.GetAIJob(r.Context(), reqBody.JobID); ok {
pbJobID = reqBody.JobID
resumeFrom = existing.ItemsDone
done = resumeFrom
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{
"status": string(domain.TaskStatusRunning),
"items_total": total,
})
}
}
if pbJobID == "" {
id, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
Kind: "batch-covers",
Status: domain.TaskStatusRunning,
FromItem: reqBody.FromItem,
ToItem: reqBody.ToItem,
ItemsTotal: total,
Started: time.Now(),
})
if createErr == nil {
pbJobID = id
}
}
if pbJobID != "" {
registerCancelJob(pbJobID, cancel)
defer deregisterCancelJob(pbJobID)
}
}
// Use pbJobID as the SSE job_id when available, else a random hex fallback.
sseJobID := pbJobID
if sseJobID == "" {
sseJobID = randomHex(8)
ctx2, cancel2 := context.WithCancel(r.Context())
registerCancelJob(sseJobID, cancel2)
defer deregisterCancelJob(sseJobID)
defer cancel2()
cancel() // replace ctx with ctx2
ctx = ctx2
}
// Send initial event with jobID so frontend can store it for cancellation.
sseWrite(batchCoverEvent{JobID: sseJobID, Done: done, Total: total})
for i, book := range books {
if ctx.Err() != nil {
break
}
// Skip already-processed items when resuming.
if i < resumeFrom {
continue
}
// Check if cover already exists.
hasCover := s.deps.CoverStore.CoverExists(ctx, book.Slug)
if hasCover {
done++
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Skipped: true})
if pbJobID != "" && s.deps.AIJobStore != nil {
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{"items_done": done})
}
continue
}
@@ -547,6 +596,21 @@ func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request)
done++
s.deps.Log.Info("batch-covers: cover generated", "slug", book.Slug)
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug})
if pbJobID != "" && s.deps.AIJobStore != nil {
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{"items_done": done})
}
}
if pbJobID != "" && s.deps.AIJobStore != nil {
status := domain.TaskStatusDone
if ctx.Err() != nil {
status = domain.TaskStatusCancelled
}
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), pbJobID, map[string]any{
"status": string(status),
"items_done": done,
"finished": time.Now().Format(time.RFC3339),
})
}
sseWrite(batchCoverEvent{Done: done, Total: total, Finish: true})

View File

@@ -1,10 +1,12 @@
package backend
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
@@ -38,6 +40,13 @@ type textGenChapterNamesRequest struct {
Model string `json:"model"`
// MaxTokens limits response length (0 = model default).
MaxTokens int `json:"max_tokens"`
// FromChapter is the first chapter to process (1-based). 0 = start from chapter 1.
FromChapter int `json:"from_chapter"`
// ToChapter is the last chapter to process (inclusive). 0 = process all.
ToChapter int `json:"to_chapter"`
// JobID is an optional existing ai_job ID for resuming a previous run.
// If set, the handler resumes from items_done instead of starting from scratch.
JobID string `json:"job_id"`
}
// proposedChapterTitle is a single chapter with its AI-proposed title.
@@ -51,6 +60,8 @@ type proposedChapterTitle struct {
// chapterNamesBatchEvent is one SSE event emitted per processed batch.
type chapterNamesBatchEvent struct {
// JobID is the PB ai_job ID for this run (emitted on the first event only).
JobID string `json:"job_id,omitempty"`
// Batch is the 1-based batch index.
Batch int `json:"batch"`
// TotalBatches is the total number of batches.
@@ -99,16 +110,36 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
}
// Load existing chapter list.
chapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
allChapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "list chapters: "+err.Error())
return
}
if len(chapters) == 0 {
if len(allChapters) == 0 {
jsonError(w, http.StatusNotFound, fmt.Sprintf("no chapters found for slug %q", req.Slug))
return
}
// Apply chapter range filter.
chapters := allChapters
if req.FromChapter > 0 || req.ToChapter > 0 {
filtered := chapters[:0]
for _, ch := range allChapters {
if req.FromChapter > 0 && ch.Number < req.FromChapter {
continue
}
if req.ToChapter > 0 && ch.Number > req.ToChapter {
break
}
filtered = append(filtered, ch)
}
chapters = filtered
}
if len(chapters) == 0 {
jsonError(w, http.StatusBadRequest, "no chapters in the specified range")
return
}
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
@@ -160,10 +191,58 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
}
}
chaptersDone := 0
// Create or resume an ai_job record for tracking.
var jobID string
resumeFrom := 0
jobCtx := r.Context()
var jobCancel context.CancelFunc
if s.deps.AIJobStore != nil {
if req.JobID != "" {
if existingJob, ok, _ := s.deps.AIJobStore.GetAIJob(r.Context(), req.JobID); ok {
jobID = req.JobID
resumeFrom = existingJob.ItemsDone
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
"status": string(domain.TaskStatusRunning),
"items_total": len(chapters),
})
}
}
if jobID == "" {
jobPayload := fmt.Sprintf(`{"pattern":%q}`, req.Pattern)
id, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{
Kind: "chapter-names",
Slug: req.Slug,
Status: domain.TaskStatusRunning,
FromItem: req.FromChapter,
ToItem: req.ToChapter,
ItemsTotal: len(chapters),
Model: string(model),
Payload: jobPayload,
Started: time.Now(),
})
if createErr == nil {
jobID = id
}
}
if jobID != "" {
jobCtx, jobCancel = context.WithCancel(r.Context())
registerCancelJob(jobID, jobCancel)
defer deregisterCancelJob(jobID)
defer jobCancel()
}
}
chaptersDone := resumeFrom
firstEvent := true
for i, batch := range batches {
if r.Context().Err() != nil {
return // client disconnected
if jobCtx.Err() != nil {
return // client disconnected or cancelled
}
// Skip batches already processed in a previous run.
batchEnd := (i + 1) * chapterNamesBatchSize
if batchEnd <= resumeFrom {
continue
}
var chapterListSB strings.Builder
@@ -172,7 +251,7 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
}
userPrompt := fmt.Sprintf("Naming pattern: %s\n\nChapters:\n%s", req.Pattern, chapterListSB.String())
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
raw, genErr := s.deps.TextGen.Generate(jobCtx, cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{
{Role: "system", Content: systemPrompt},
@@ -183,14 +262,19 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
if genErr != nil {
s.deps.Log.Error("admin: text-gen chapter-names batch failed",
"batch", i+1, "err", genErr)
sseWrite(chapterNamesBatchEvent{
evt := chapterNamesBatchEvent{
Batch: i + 1,
TotalBatches: totalBatches,
ChaptersDone: chaptersDone,
TotalChapters: len(chapters),
Model: string(model),
Error: genErr.Error(),
})
}
if firstEvent {
evt.JobID = jobID
firstEvent = false
}
sseWrite(evt)
continue
}
@@ -205,13 +289,37 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
}
chaptersDone += len(batch)
sseWrite(chapterNamesBatchEvent{
if jobID != "" && s.deps.AIJobStore != nil {
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
"items_done": chaptersDone,
})
}
evt := chapterNamesBatchEvent{
Batch: i + 1,
TotalBatches: totalBatches,
ChaptersDone: chaptersDone,
TotalChapters: len(chapters),
Model: string(model),
Chapters: result,
}
if firstEvent {
evt.JobID = jobID
firstEvent = false
}
sseWrite(evt)
}
// Mark job as done in PB.
if jobID != "" && s.deps.AIJobStore != nil {
status := domain.TaskStatusDone
if jobCtx.Err() != nil {
status = domain.TaskStatusCancelled
}
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
"status": string(status),
"items_done": chaptersDone,
"finished": time.Now().Format(time.RFC3339),
})
}

View File

@@ -82,6 +82,9 @@ type Dependencies struct {
// BookWriter writes book metadata and chapter refs to PocketBase.
// Used by admin text-gen apply endpoints.
BookWriter bookstore.BookWriter
// AIJobStore tracks long-running AI generation jobs in PocketBase.
// If nil, job persistence is disabled (jobs still run but are not recorded).
AIJobStore bookstore.AIJobStore
// Log is the structured logger.
Log *slog.Logger
}
@@ -214,6 +217,14 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
mux.HandleFunc("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
// Admin AI job tracking endpoints
mux.HandleFunc("GET /api/admin/ai-jobs", s.handleAdminListAIJobs)
mux.HandleFunc("GET /api/admin/ai-jobs/{id}", s.handleAdminGetAIJob)
mux.HandleFunc("POST /api/admin/ai-jobs/{id}/cancel", s.handleAdminCancelAIJob)
// Auto-prompt generation from book/chapter content
mux.HandleFunc("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
// Admin data repair endpoints
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)

View File

@@ -158,6 +158,19 @@ type CoverStore interface {
CoverExists(ctx context.Context, slug string) bool
}
// AIJobStore manages AI generation jobs tracked in PocketBase.
type AIJobStore interface {
// CreateAIJob inserts a new ai_job record with status=running and returns its ID.
CreateAIJob(ctx context.Context, job domain.AIJob) (string, error)
// GetAIJob retrieves a single ai_job by ID.
// Returns (zero, false, nil) when not found.
GetAIJob(ctx context.Context, id string) (domain.AIJob, bool, error)
// UpdateAIJob patches an existing ai_job record with the given fields.
UpdateAIJob(ctx context.Context, id string, fields map[string]any) error
// ListAIJobs returns all ai_job records sorted by started descending.
ListAIJobs(ctx context.Context) ([]domain.AIJob, error)
}
// TranslationStore covers machine-translated chapter storage in MinIO.
// The runner writes translations; the backend reads them.
type TranslationStore interface {

View File

@@ -169,3 +169,30 @@ type TranslationResult struct {
ObjectKey string `json:"object_key,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
// AIJob represents an AI generation task tracked in PocketBase (ai_jobs collection).
type AIJob struct {
ID string `json:"id"`
// Kind is one of: "chapter-names", "batch-covers", "chapter-covers", "refresh-metadata".
Kind string `json:"kind"`
// Slug is the book slug for per-book jobs; empty for catalogue-wide jobs.
Slug string `json:"slug"`
Status TaskStatus `json:"status"`
// FromItem is the first item to process (chapter number, or 0-based book index).
// 0 = start from the beginning.
FromItem int `json:"from_item"`
// ToItem is the last item to process (inclusive). 0 = process all.
ToItem int `json:"to_item"`
// ItemsDone is the cumulative count of successfully processed items.
ItemsDone int `json:"items_done"`
// ItemsTotal is the total number of items in this job.
ItemsTotal int `json:"items_total"`
Model string `json:"model"`
// Payload is a JSON-encoded string with job-specific parameters
// (e.g. naming pattern for chapter-names, num_steps for batch-covers).
Payload string `json:"payload"`
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started,omitempty"`
Finished time.Time `json:"finished,omitempty"`
HeartbeatAt time.Time `json:"heartbeat_at,omitempty"`
}

View File

@@ -53,6 +53,7 @@ var _ bookstore.PresignStore = (*Store)(nil)
var _ bookstore.ProgressStore = (*Store)(nil)
var _ bookstore.CoverStore = (*Store)(nil)
var _ bookstore.TranslationStore = (*Store)(nil)
var _ bookstore.AIJobStore = (*Store)(nil)
var _ taskqueue.Producer = (*Store)(nil)
var _ taskqueue.Consumer = (*Store)(nil)
var _ taskqueue.Reader = (*Store)(nil)
@@ -1063,3 +1064,107 @@ func (s *Store) GetTranslation(ctx context.Context, key string) (string, error)
}
return string(data), nil
}
// ── AIJobStore ────────────────────────────────────────────────────────────────
func (s *Store) CreateAIJob(ctx context.Context, job domain.AIJob) (string, error) {
payload := map[string]any{
"kind": job.Kind,
"slug": job.Slug,
"status": string(job.Status),
"from_item": job.FromItem,
"to_item": job.ToItem,
"items_done": job.ItemsDone,
"items_total": job.ItemsTotal,
"model": job.Model,
"payload": job.Payload,
"started": job.Started.Format(time.RFC3339),
}
var out struct {
ID string `json:"id"`
}
if err := s.pb.post(ctx, "/api/collections/ai_jobs/records", payload, &out); err != nil {
return "", fmt.Errorf("CreateAIJob: %w", err)
}
return out.ID, nil
}
func (s *Store) GetAIJob(ctx context.Context, id string) (domain.AIJob, bool, error) {
var raw json.RawMessage
if err := s.pb.get(ctx, fmt.Sprintf("/api/collections/ai_jobs/records/%s", id), &raw); err != nil {
if strings.Contains(err.Error(), "404") {
return domain.AIJob{}, false, nil
}
return domain.AIJob{}, false, fmt.Errorf("GetAIJob: %w", err)
}
job, err := parseAIJob(raw)
if err != nil {
return domain.AIJob{}, false, err
}
return job, true, nil
}
func (s *Store) UpdateAIJob(ctx context.Context, id string, fields map[string]any) error {
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/ai_jobs/records/%s", id), fields)
}
func (s *Store) ListAIJobs(ctx context.Context) ([]domain.AIJob, error) {
items, err := s.pb.listAll(ctx, "ai_jobs", "", "-started")
if err != nil {
return nil, fmt.Errorf("ListAIJobs: %w", err)
}
out := make([]domain.AIJob, 0, len(items))
for _, raw := range items {
j, err := parseAIJob(raw)
if err != nil {
continue
}
out = append(out, j)
}
return out, nil
}
func parseAIJob(raw json.RawMessage) (domain.AIJob, error) {
var r struct {
ID string `json:"id"`
Kind string `json:"kind"`
Slug string `json:"slug"`
Status string `json:"status"`
FromItem int `json:"from_item"`
ToItem int `json:"to_item"`
ItemsDone int `json:"items_done"`
ItemsTotal int `json:"items_total"`
Model string `json:"model"`
Payload string `json:"payload"`
ErrorMessage string `json:"error_message"`
Started string `json:"started"`
Finished string `json:"finished"`
HeartbeatAt string `json:"heartbeat_at"`
}
if err := json.Unmarshal(raw, &r); err != nil {
return domain.AIJob{}, fmt.Errorf("parseAIJob: %w", err)
}
parseT := func(s string) time.Time {
if s == "" {
return time.Time{}
}
t, _ := time.Parse(time.RFC3339, s)
return t
}
return domain.AIJob{
ID: r.ID,
Kind: r.Kind,
Slug: r.Slug,
Status: domain.TaskStatus(r.Status),
FromItem: r.FromItem,
ToItem: r.ToItem,
ItemsDone: r.ItemsDone,
ItemsTotal: r.ItemsTotal,
Model: r.Model,
Payload: r.Payload,
ErrorMessage: r.ErrorMessage,
Started: parseT(r.Started),
Finished: parseT(r.Finished),
HeartbeatAt: parseT(r.HeartbeatAt),
}, nil
}

View File

@@ -169,6 +169,11 @@ services:
VALKEY_URL: "redis://valkey:6379/1"
PORT: "8000"
ENABLE_USER_REGISTRATION: "false"
MEDIA_ROOT: "/code/uploads"
volumes:
- glitchtip_uploads:/code/uploads
# Patch: GzipChunk fallback for sentry-cli 3.x raw zip uploads (GlitchTip bug)
- ./glitchtip/files_api.py:/code/apps/files/api.py:ro
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/0/')"]
interval: 15s
@@ -190,6 +195,11 @@ services:
DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
VALKEY_URL: "redis://valkey:6379/1"
SERVER_ROLE: "worker"
MEDIA_ROOT: "/code/uploads"
volumes:
- glitchtip_uploads:/code/uploads
# Patch: GzipChunk fallback for sentry-cli 3.x raw zip uploads (GlitchTip bug)
- ./glitchtip/files_api.py:/code/apps/files/api.py:ro
# ── Umami ───────────────────────────────────────────────────────────────────
umami:
@@ -540,3 +550,4 @@ volumes:
grafana_data:
pocket_tts_cache:
hf_cache:
glitchtip_uploads:

View File

@@ -0,0 +1,127 @@
"""Port of sentry.api.endpoints.chunk.ChunkUploadEndpoint"""
import logging
from gzip import GzipFile
from io import BytesIO
from django.conf import settings
from django.shortcuts import aget_object_or_404
from django.urls import reverse
from ninja import File, Router
from ninja.errors import HttpError
from ninja.files import UploadedFile
from apps.organizations_ext.models import Organization
from glitchtip.api.authentication import AuthHttpRequest
from glitchtip.api.decorators import optional_slash
from glitchtip.api.permissions import has_permission
from .models import FileBlob
# Force just one blob
CHUNK_UPLOAD_BLOB_SIZE = 32 * 1024 * 1024 # 32MB
MAX_CHUNKS_PER_REQUEST = 1
MAX_REQUEST_SIZE = CHUNK_UPLOAD_BLOB_SIZE
MAX_CONCURRENCY = 1
HASH_ALGORITHM = "sha1"
CHUNK_UPLOAD_ACCEPT = (
"debug_files", # DIF assemble
"release_files", # Release files assemble
"pdbs", # PDB upload and debug id override
"sources", # Source artifact bundle upload
"artifact_bundles", # Artifact bundles contain debug ids to link source to sourcemaps
"proguard",
)
class GzipChunk(BytesIO):
def __init__(self, file):
raw = file.read()
try:
data = GzipFile(fileobj=BytesIO(raw), mode="rb").read()
except Exception:
# sentry-cli 3.x sends raw (uncompressed) zip data despite gzip being
# advertised by the server — fall back to using the raw bytes as-is.
data = raw
self.size = len(data)
self.name = file.name
super().__init__(data)
router = Router()
@optional_slash(router, "get", "organizations/{slug:organization_slug}/chunk-upload/")
async def get_chunk_upload_info(request: AuthHttpRequest, organization_slug: str):
"""Get server settings for chunk file upload"""
path = reverse("api:get_chunk_upload_info", args=[organization_slug])
url = (
path
if settings.GLITCHTIP_CHUNK_UPLOAD_USE_RELATIVE_URL
else settings.GLITCHTIP_URL.geturl() + path
)
return {
"url": url,
"chunkSize": CHUNK_UPLOAD_BLOB_SIZE,
"chunksPerRequest": MAX_CHUNKS_PER_REQUEST,
"maxFileSize": 2147483648,
"maxRequestSize": MAX_REQUEST_SIZE,
"concurrency": MAX_CONCURRENCY,
"hashAlgorithm": HASH_ALGORITHM,
"compression": ["gzip"],
"accept": CHUNK_UPLOAD_ACCEPT,
}
@optional_slash(router, "post", "organizations/{slug:organization_slug}/chunk-upload/")
@has_permission(["project:write", "project:admin", "project:releases"])
async def chunk_upload(
request: AuthHttpRequest,
organization_slug: str,
file_gzip: list[UploadedFile] = File(...),
):
"""Upload one more more gzipped files to save"""
logger = logging.getLogger("glitchtip.files")
logger.info("chunkupload.start")
organization = await aget_object_or_404(
Organization, slug=organization_slug.lower(), users=request.auth.user_id
)
files = [GzipChunk(chunk) for chunk in file_gzip]
if len(files) == 0:
# No files uploaded is ok
logger.info("chunkupload.end", extra={"status": 200})
return
logger.info("chunkupload.post.files", extra={"len": len(files)})
# Validate file size
checksums = []
size = 0
for chunk in files:
size += chunk.size
if chunk.size > CHUNK_UPLOAD_BLOB_SIZE:
logger.info("chunkupload.end", extra={"status": 400})
raise HttpError(400, "Chunk size too large")
checksums.append(chunk.name)
if size > MAX_REQUEST_SIZE:
logger.info("chunkupload.end", extra={"status": 400})
raise HttpError(400, "Request too large")
if len(files) > MAX_CHUNKS_PER_REQUEST:
logger.info("chunkupload.end", extra={"status": 400})
raise HttpError(400, "Too many chunks")
try:
await FileBlob.from_files(
zip(files, checksums), organization=organization, logger=logger
)
except IOError as err:
logger.info("chunkupload.end", extra={"status": 400})
raise HttpError(400, str(err)) from err
logger.info("chunkupload.end", extra={"status": 200})

View File

@@ -294,6 +294,23 @@ create "translation_jobs" '{
{"name":"heartbeat_at", "type":"date"}
]}'
create "ai_jobs" '{
"name":"ai_jobs","type":"base","fields":[
{"name":"kind", "type":"text", "required":true},
{"name":"slug", "type":"text"},
{"name":"status", "type":"text", "required":true},
{"name":"from_item", "type":"number"},
{"name":"to_item", "type":"number"},
{"name":"items_done", "type":"number"},
{"name":"items_total", "type":"number"},
{"name":"model", "type":"text"},
{"name":"payload", "type":"text"},
{"name":"error_message", "type":"text"},
{"name":"started", "type":"date"},
{"name":"finished", "type":"date"},
{"name":"heartbeat_at", "type":"date"}
]}'
create "discovery_votes" '{
"name":"discovery_votes","type":"base","fields":[
{"name":"session_id","type":"text","required":true},

View File

@@ -32,6 +32,8 @@
* It only runs once per chapter (guarded by nextStatus !== 'none').
*/
import type { Voice } from '$lib/types';
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
@@ -50,6 +52,9 @@ class AudioStore {
/** Full chapter list for the currently loaded book (number + title). */
chapters = $state<{ number: number; title: string }[]>([]);
/** Available voices (populated by the chapter AudioPlayer on mount). */
voices = $state<Voice[]>([]);
// ── Loading/generation state ────────────────────────────────────────────
status = $state<AudioStatus>('idle');
audioUrl = $state('');

View File

@@ -226,6 +226,11 @@
audioStore.nextChapter = nextChapter ?? null;
});
// Keep voices in store up to date whenever prop changes.
$effect(() => {
if (voices.length > 0) audioStore.voices = voices;
});
// Auto-start: if the layout navigated here via auto-next, kick off playback.
// We match against the chapter prop so the outgoing chapter's AudioPlayer
// (still mounted during the brief navigation window) never reacts to this.
@@ -530,6 +535,7 @@
audioStore.bookTitle = bookTitle;
audioStore.cover = cover;
audioStore.chapters = chapters;
if (voices.length > 0) audioStore.voices = voices;
// Update OS media session (lock screen / notification center).
setMediaSession();
@@ -1102,72 +1108,7 @@
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
</span>
</div>
<!-- Auto-next toggle (keep here as useful context) -->
{#if nextChapter !== null && nextChapter !== undefined}
<Button
variant="ghost"
size="sm"
class={cn('gap-1.5 text-xs flex-shrink-0', audioStore.autoNext ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
title={audioStore.autoNext ? m.player_auto_next_on() : m.player_auto_next_off()}
aria-pressed={audioStore.autoNext}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
{m.reader_auto_next()}
</Button>
{/if}
<!-- Sleep timer -->
<Button
variant="ghost"
size="sm"
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
onclick={cycleSleepTimer}
title={audioStore.sleepAfterChapter
? 'Stop after this chapter'
: audioStore.sleepUntil
? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining`
: 'Sleep timer off'}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
{#if audioStore.sleepAfterChapter}
End Ch.
{:else if audioStore.sleepUntil}
{formatSleepRemaining(sleepRemainingSec)}
{:else}
Sleep
{/if}
</Button>
</div>
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
{#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined}
<div class="mt-2">
{#if audioStore.nextStatus === 'prefetching'}
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
<svg class="w-3 h-3 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>{m.reader_ch_preparing({ n: String(nextChapter), percent: String(Math.round(audioStore.nextProgress)) })}</span>
</div>
{:else if audioStore.nextStatus === 'prefetched'}
<p class="text-xs text-(--color-muted) flex items-center gap-1">
<svg class="w-3 h-3 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
{m.reader_ch_ready({ n: String(nextChapter) })}
</p>
{:else if audioStore.nextStatus === 'failed'}
<p class="text-xs text-(--color-muted) opacity-60">{m.reader_ch_generate_on_nav({ n: String(nextChapter) })}</p>
{/if}
</div>
{/if}
{/if}
{:else if audioStore.active}

View File

@@ -0,0 +1,451 @@
<script lang="ts">
import { audioStore } from '$lib/audio.svelte';
import { cn } from '$lib/utils';
import type { Voice } from '$lib/types';
interface Props {
/** Called when the user closes the overlay. */
onclose: () => void;
}
let { onclose }: Props = $props();
// Voices come from the store (populated by AudioPlayer on mount/play)
const voices = $derived(audioStore.voices);
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
let showVoicePanel = $state(false);
let samplePlayingVoice = $state<string | null>(null);
let sampleAudio: HTMLAudioElement | null = null;
function voiceLabel(v: Voice | string): string {
if (typeof v === 'string') {
const found = voices.find((x) => x.id === v);
if (found) return voiceLabel(found);
const id = v as string;
return id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
const base = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return v.lang !== 'en-us' ? `${base} (${v.lang})` : base;
}
function stopSample() {
if (sampleAudio) {
sampleAudio.pause();
sampleAudio.src = '';
sampleAudio = null;
}
samplePlayingVoice = null;
}
async function playSample(voiceId: string) {
if (samplePlayingVoice === voiceId) { stopSample(); return; }
stopSample();
samplePlayingVoice = voiceId;
try {
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voiceId)}`);
if (!res.ok) { samplePlayingVoice = null; return; }
const { url } = (await res.json()) as { url: string };
sampleAudio = new Audio(url);
sampleAudio.onended = () => stopSample();
sampleAudio.onerror = () => stopSample();
sampleAudio.play().catch(() => stopSample());
} catch {
samplePlayingVoice = null;
}
}
function selectVoice(voiceId: string) {
stopSample();
audioStore.voice = voiceId;
showVoicePanel = false;
}
// ── Speed ────────────────────────────────────────────────────────────────
const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const;
// ── Sleep timer ──────────────────────────────────────────────────────────
const SLEEP_OPTIONS = [15, 30, 45, 60]; // minutes
let sleepRemainingSec = $derived.by(() => {
void audioStore.currentTime; // re-run every second while playing
if (!audioStore.sleepUntil) return 0;
return Math.max(0, Math.floor((audioStore.sleepUntil - Date.now()) / 1000));
});
function cycleSleepTimer() {
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = true;
} else if (audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = false;
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000;
} else {
const remaining = audioStore.sleepUntil - Date.now();
const currentMin = Math.round(remaining / 60000);
const idx = SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
if (idx === -1 || idx === SLEEP_OPTIONS.length - 1) {
audioStore.sleepUntil = 0;
} else {
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[idx + 1] * 60 * 1000;
}
}
}
function formatSleepRemaining(secs: number): string {
if (secs <= 0) return 'Off';
const m = Math.floor(secs / 60);
const s = secs % 60;
return m > 0 ? `${m}m${s > 0 ? ` ${s}s` : ''}` : `${s}s`;
}
const sleepLabel = $derived(
audioStore.sleepAfterChapter
? 'End Ch.'
: audioStore.sleepUntil > Date.now()
? formatSleepRemaining(sleepRemainingSec)
: 'Sleep'
);
// ── Format time ──────────────────────────────────────────────────────────
function formatTime(s: number): string {
if (!isFinite(s) || s < 0) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
// ── Playback controls ────────────────────────────────────────────────────
function seek(e: Event) {
audioStore.seekRequest = Number((e.currentTarget as HTMLInputElement).value);
}
function skipBack() {
audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15);
}
function skipForward() {
audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30);
}
function togglePlay() {
audioStore.toggleRequest++;
}
// Close on Escape
$effect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (showVoicePanel) { showVoicePanel = false; }
else { onclose(); }
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
</script>
<!-- Full-screen listening mode overlay -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-60 flex flex-col overflow-hidden"
style="background: var(--color-surface);"
>
<!-- Blurred cover background -->
{#if audioStore.cover}
<div
class="absolute inset-0 bg-cover bg-center opacity-10 blur-2xl scale-110"
style="background-image: url('{audioStore.cover}');"
aria-hidden="true"
></div>
{/if}
<!-- Header bar -->
<div class="relative flex items-center justify-between px-4 py-3 shrink-0">
<button
type="button"
onclick={onclose}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close listening mode"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Now Playing</span>
<!-- Voice selector button -->
<button
type="button"
onclick={() => (showVoicePanel = !showVoicePanel)}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
showVoicePanel
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
</svg>
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
</button>
</div>
<!-- Voice panel (inline dropdown below header) -->
{#if showVoicePanel && voices.length > 0}
<div class="relative mx-4 mb-2 bg-(--color-surface-2) border border-(--color-border) rounded-xl p-3 z-10 overflow-y-auto max-h-56 shrink-0">
<div class="flex items-center justify-between mb-2">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider">Select Voice</p>
<button type="button" onclick={() => { stopSample(); showVoicePanel = false; }} class="text-(--color-muted) hover:text-(--color-text) transition-colors" aria-label="Close voice panel">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{#each ([['Kokoro', kokoroVoices], ['Pocket TTS', pocketVoices], ['CF AI', cfaiVoices]] as [string, Voice[]][]) as [label, group]}
{#if group.length > 0}
<p class="text-[10px] text-(--color-muted) opacity-60 mb-1 mt-2 first:mt-0">{label}</p>
<div class="flex flex-wrap gap-1.5">
{#each group as v (v.id)}
<div class="flex items-center rounded-lg border overflow-hidden text-xs
{audioStore.voice === v.id
? 'border-(--color-brand) bg-(--color-brand)/10'
: 'border-(--color-border) bg-(--color-surface-3)'}">
<button
type="button"
onclick={() => selectVoice(v.id)}
class="px-2 py-1 font-medium transition-colors
{audioStore.voice === v.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>{voiceLabel(v)}</button>
<button
type="button"
onclick={(e) => { e.stopPropagation(); playSample(v.id); }}
class="px-1.5 py-1 border-l border-(--color-border) transition-colors
{samplePlayingVoice === v.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
title={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
>
{#if samplePlayingVoice === v.id}
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
{:else}
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{/if}
</button>
</div>
{/each}
</div>
{/if}
{/each}
</div>
{/if}
<!-- Scrollable body -->
<div class="relative flex-1 overflow-y-auto flex flex-col">
<!-- Cover art + track info -->
<div class="flex flex-col items-center px-8 pt-4 pb-6 shrink-0">
{#if audioStore.cover}
<img
src={audioStore.cover}
alt=""
class="w-40 h-56 object-cover rounded-xl shadow-2xl mb-5"
/>
{:else}
<div class="w-40 h-56 flex items-center justify-center bg-(--color-surface-2) rounded-xl shadow-2xl mb-5 border border-(--color-border)">
<svg class="w-16 h-16 text-(--color-muted)/40" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
</svg>
</div>
{/if}
<p class="text-base font-bold text-(--color-text) text-center leading-snug">
{audioStore.chapterTitle || (audioStore.chapter > 0 ? `Chapter ${audioStore.chapter}` : '')}
</p>
<p class="text-sm text-(--color-muted) text-center mt-0.5 truncate max-w-full">{audioStore.bookTitle}</p>
</div>
<!-- Seek bar -->
<div class="px-6 shrink-0">
<input
type="range"
aria-label="Seek"
min="0"
max={audioStore.duration || 0}
value={audioStore.currentTime}
oninput={seek}
class="w-full h-1.5 accent-[--color-brand] cursor-pointer block"
style="accent-color: var(--color-brand);"
/>
<div class="flex justify-between text-xs text-(--color-muted) tabular-nums mt-1">
<span>{formatTime(audioStore.currentTime)}</span>
<span>{formatTime(audioStore.duration)}</span>
</div>
</div>
<!-- Transport controls -->
<div class="flex items-center justify-center gap-4 px-6 pt-5 pb-2 shrink-0">
<!-- Prev chapter -->
{#if audioStore.chapter > 1 && audioStore.slug}
<a
href="/books/{audioStore.slug}/chapters/{audioStore.chapter - 1}"
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
title="Previous chapter"
aria-label="Previous chapter"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
</a>
{:else}
<div class="w-9 h-9"></div>
{/if}
<!-- Skip back 15s -->
<button
type="button"
onclick={skipBack}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Skip back 15 seconds"
title="Back 15s"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
</svg>
</button>
<!-- Play / Pause -->
<button
type="button"
onclick={togglePlay}
class="w-16 h-16 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors shadow-lg"
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
>
{#if audioStore.isPlaying}
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
{:else}
<svg class="w-7 h-7 ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
<!-- Skip forward 30s -->
<button
type="button"
onclick={skipForward}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Skip forward 30 seconds"
title="Forward 30s"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">30</text>
</svg>
</button>
<!-- Next chapter -->
{#if audioStore.nextChapter !== null && audioStore.slug}
<a
href="/books/{audioStore.slug}/chapters/{audioStore.nextChapter}"
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
title="Next chapter"
aria-label="Next chapter"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
</a>
{:else}
<div class="w-9 h-9"></div>
{/if}
</div>
<!-- Secondary controls: Speed · Auto-next · Sleep -->
<div class="flex items-center justify-center gap-3 px-6 py-3 shrink-0">
<!-- Speed -->
<div class="flex items-center gap-1 bg-(--color-surface-2) rounded-full px-2 py-1 border border-(--color-border)">
{#each SPEED_OPTIONS as s}
<button
type="button"
onclick={() => (audioStore.speed = s)}
class={cn(
'px-2 py-0.5 rounded-full text-xs font-semibold transition-colors',
audioStore.speed === s
? 'bg-(--color-brand) text-(--color-surface)'
: 'text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed={audioStore.speed === s}
>{s}×</button>
{/each}
</div>
<!-- Auto-next -->
<button
type="button"
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.autoNext
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
aria-pressed={audioStore.autoNext}
title={audioStore.autoNext ? 'Auto-next on' : 'Auto-next off'}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
Auto
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
{/if}
</button>
<!-- Sleep timer -->
<button
type="button"
onclick={cycleSleepTimer}
class={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
audioStore.sleepUntil || audioStore.sleepAfterChapter
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
)}
title="Sleep timer"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
{sleepLabel}
</button>
</div>
<!-- Chapter list -->
{#if audioStore.chapters.length > 0}
<div class="mx-4 mb-6 bg-(--color-surface-2) rounded-xl border border-(--color-border) overflow-hidden shrink-0">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider px-4 py-2.5 border-b border-(--color-border)">Chapters</p>
<div class="overflow-y-auto max-h-64">
{#each audioStore.chapters as ch (ch.number)}
<a
href="/books/{audioStore.slug}/chapters/{ch.number}"
onclick={onclose}
class="flex items-center gap-3 px-4 py-2.5 text-xs transition-colors hover:bg-(--color-surface-3)
{ch.number === audioStore.chapter ? 'text-(--color-brand) font-semibold bg-(--color-brand)/5' : 'text-(--color-muted)'}"
>
<span class="tabular-nums w-7 shrink-0 text-right opacity-50">{ch.number}</span>
<span class="flex-1 truncate">{ch.title || `Chapter ${ch.number}`}</span>
{#if ch.number === audioStore.chapter}
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</a>
{/each}
</div>
</div>
{/if}
</div>
</div>

View File

@@ -11,6 +11,7 @@
import { cn } from '$lib/utils';
import * as m from '$lib/paraglide/messages.js';
import { locales, getLocale } from '$lib/paraglide/runtime.js';
import ListeningMode from '$lib/components/ListeningMode.svelte';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
@@ -34,6 +35,7 @@
// Chapter list drawer state for the mini-player
let chapterDrawerOpen = $state(false);
let activeChapterEl = $state<HTMLElement | null>(null);
let listeningModeOpen = $state(false);
function setIfActive(node: HTMLElement, isActive: boolean) {
if (isActive) activeChapterEl = node;
@@ -287,7 +289,7 @@
audioEl.currentTime = Math.min(audioEl.duration || 0, audioEl.currentTime + 30);
}
const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0];
const speedSteps = [0.75, 1.0, 1.25, 1.5, 2.0];
function cycleSpeed() {
const idx = speedSteps.indexOf(audioStore.speed);
@@ -993,6 +995,21 @@
</a>
{/if}
<!-- Headphones: open listening mode -->
<Button
variant="ghost"
size="icon"
onclick={() => (listeningModeOpen = true)}
title="Listening mode"
aria-label="Open listening mode"
class="text-(--color-muted) hover:text-(--color-text) flex-shrink-0"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 18v-6a9 9 0 0118 0v6"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 19a2 2 0 01-2 2h-1a2 2 0 01-2-2v-3a2 2 0 012-2h3zM3 19a2 2 0 002 2h1a2 2 0 002-2v-3a2 2 0 00-2-2H3z"/>
</svg>
</Button>
<!-- Dismiss -->
<Button
variant="ghost"
@@ -1008,4 +1025,9 @@
</Button>
</div>
</div>
<!-- Listening mode full-screen overlay -->
{#if listeningModeOpen}
<ListeningMode onclose={() => (listeningModeOpen = false)} />
{/if}
{/if}

View File

@@ -39,6 +39,9 @@
$effect(() => { void imgModel; void numSteps; void width; void height; saveConfig(); });
// ── Batch covers ──────────────────────────────────────────────────────────────
let fromItem = $state(0);
let toItem = $state(0);
let resumeJobID = $state('');
let running = $state(false);
let jobID = $state('');
let done = $state(0);
@@ -62,6 +65,9 @@
num_steps: numSteps || undefined,
width: width || undefined,
height: height || undefined,
from_item: fromItem || undefined,
to_item: toItem || undefined,
job_id: resumeJobID.trim() || undefined,
})
});
if (!res.ok) {
@@ -172,6 +178,21 @@
<input id="height" type="number" bind:value={height} min="0" step="64"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="from-item">From <span class="font-normal">(0=start)</span></label>
<input id="from-item" type="number" bind:value={fromItem} min="0"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="to-item">To <span class="font-normal">(0=end)</span></label>
<input id="to-item" type="number" bind:value={toItem} min="0"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
<div class="col-span-2 sm:col-span-4 space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="resume-job">Resume job ID <span class="font-normal">(leave blank to start fresh)</span></label>
<input id="resume-job" type="text" bind:value={resumeJobID} placeholder="optional PB job ID"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
</div>
<!-- Controls -->

View File

@@ -193,6 +193,33 @@
prompt = prompt ? `${prompt}\n\nBook description: ${snippet}` : `Book description: ${snippet}`;
}
// ── Auto-prompt ──────────────────────────────────────────────────────────────
let autoPrompting = $state(false);
let autoPromptError = $state('');
async function autoGeneratePrompt() {
if (!slug.trim() || autoPrompting) return;
autoPrompting = true;
autoPromptError = '';
try {
const res = await fetch('/api/admin/image-gen/auto-prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: slug.trim(), type: imageType, chapter: imageType === 'chapter' ? chapter : 0 })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
autoPromptError = body.error ?? `Error ${res.status}`;
return;
}
prompt = body.prompt ?? '';
} catch {
autoPromptError = 'Network error.';
} finally {
autoPrompting = false;
}
}
// ── Style presets ────────────────────────────────────────────────────────────
const PRESETS_KEY = 'admin_image_gen_presets_v1';
@@ -583,8 +610,14 @@
Prompt
</label>
<div class="flex flex-wrap items-center gap-3">
{#if slug.trim()}
<button onclick={autoGeneratePrompt} disabled={autoPrompting}
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors disabled:opacity-50">
{autoPrompting ? 'Generating…' : 'Auto-prompt'}
</button>
{/if}
{#if selectedBook?.summary}
<button onclick={injectDescription} class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">
<button onclick={injectDescription} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">
Inject description
</button>
{/if}
@@ -617,6 +650,9 @@
placeholder="Describe the image to generate…"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand) resize-y"
></textarea>
{#if autoPromptError}
<p class="text-xs text-(--color-danger)">{autoPromptError}</p>
{/if}
<div class="flex gap-2">
<input type="text" bind:value={newPresetName} placeholder="Preset name…"

View File

@@ -80,6 +80,9 @@
let chAC = makeBookAC();
let chSlug = $state('');
let chPattern = $state(saved.chPattern ?? 'Chapter {n}: {scene}');
let chFromChapter = $state(0);
let chToChapter = $state(0);
let chJobID = $state('');
let chGenerating = $state(false);
let chError = $state('');
@@ -122,6 +125,7 @@
chBatchWarnings = [];
chApplySuccess = false;
chApplyError = '';
chJobID = '';
try {
const res = await fetch('/api/admin/text-gen/chapter-names', {
@@ -130,7 +134,9 @@
body: JSON.stringify({
slug: chSlug.trim(),
pattern: chPattern.trim(),
model: selectedModel
model: selectedModel,
from_chapter: chFromChapter || undefined,
to_chapter: chToChapter || undefined
})
});
@@ -161,6 +167,7 @@
if (!payload) continue;
let evt: {
job_id?: string;
batch?: number;
total_batches?: number;
chapters_done?: number;
@@ -176,6 +183,8 @@
continue;
}
if (evt.job_id) chJobID = evt.job_id;
if (evt.done) {
chBatchProgress = `Done — ${evt.total_chapters ?? chProposals.length} chapters`;
chGenerating = false;
@@ -579,6 +588,27 @@
</p>
</div>
<!-- Range (optional) -->
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-from">
From chapter <span class="font-normal">(0=all)</span>
</label>
<input id="ch-from" type="number" bind:value={chFromChapter} min="0"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-to">
To chapter <span class="font-normal">(0=all)</span>
</label>
<input id="ch-to" type="number" bind:value={chToChapter} min="0"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)" />
</div>
</div>
{#if chJobID}
<p class="text-xs text-(--color-muted)">Job: <span class="font-mono">{chJobID}</span></p>
{/if}
<button
onclick={generateChapterNames}
disabled={!chCanGenerate}

View File

@@ -22,14 +22,6 @@
let settingsPanelOpen = $state(false);
let settingsTab = $state<'reading' | 'listening'>('reading');
const READER_THEMES = [
{ id: 'amber', label: 'Amber', swatch: '#f59e0b' },
{ id: 'slate', label: 'Slate', swatch: '#818cf8' },
{ id: 'rose', label: 'Rose', swatch: '#fb7185' },
{ id: 'light', label: 'Light', swatch: '#d97706', light: true },
{ id: 'light-slate', label: 'L·Slate',swatch: '#4f46e5', light: true },
{ id: 'light-rose', label: 'L·Rose', swatch: '#e11d48', light: true },
] as const;
const READER_FONTS = [
{ id: 'system', label: 'System' },
{ id: 'serif', label: 'Serif' },
@@ -43,14 +35,9 @@
] as const;
// Mirror context values into local reactive state so the panel shows current values
let panelTheme = $state(settingsCtx?.current ?? 'amber');
let panelFont = $state(settingsCtx?.fontFamily ?? 'system');
let panelSize = $state(settingsCtx?.fontSize ?? 1.0);
function applyTheme(id: string) {
panelTheme = id;
if (settingsCtx) settingsCtx.current = id;
}
function applyFont(id: string) {
panelFont = id;
if (settingsCtx) settingsCtx.fontFamily = id;
@@ -97,34 +84,6 @@
if (browser) localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout));
}
// ── Listening settings helpers ───────────────────────────────────────────────
const SETTINGS_SLEEP_OPTIONS = [15, 30, 45, 60];
const sleepSettingsLabel = $derived(
audioStore.sleepAfterChapter
? 'End Ch.'
: audioStore.sleepUntil > Date.now()
? `${Math.ceil((audioStore.sleepUntil - Date.now()) / 60000)}m`
: 'Off'
);
function toggleSleepFromSettings() {
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = true;
} else if (audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = false;
audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[0] * 60 * 1000;
} else {
const remaining = audioStore.sleepUntil - Date.now();
const currentMin = Math.round(remaining / 60000);
const idx = SETTINGS_SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
if (idx === -1 || idx === SETTINGS_SLEEP_OPTIONS.length - 1) {
audioStore.sleepUntil = 0;
} else {
audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[idx + 1] * 60 * 1000;
}
}
}
// Apply reading CSS vars whenever layout changes
$effect(() => {
if (!browser) return;
@@ -338,7 +297,8 @@
<div class="reading-progress" style="width: {scrollProgress * 100}%"></div>
{/if}
<!-- Top nav -->
<!-- Top nav (hidden in focus mode) -->
{#if !layout.focusMode}
<div class="flex items-center justify-between mb-6 gap-4">
<a
href="/books/{data.book.slug}/chapters"
@@ -383,6 +343,7 @@
</p>
{/if}
</div>
{/if}
<!-- Language switcher (not shown for preview chapters or focus mode) -->
{#if !data.isPreview && !layout.focusMode}
@@ -562,8 +523,8 @@
</div>
{/if}
<!-- Bottom nav + comments (hidden in paginated focus mode) -->
{#if !(layout.focusMode && layout.readMode === 'paginated')}
<!-- Bottom nav + comments (hidden in focus mode) -->
{#if !layout.focusMode}
<div class="flex justify-between mt-12 pt-6 border-t border-(--color-border) gap-4">
{#if data.prev}
<a
@@ -595,6 +556,45 @@
</div>
{/if}
<!-- Focus mode floating nav (shown only in focus mode) -->
{#if layout.focusMode}
<div class="fixed bottom-[4.5rem] left-1/2 -translate-x-1/2 z-50 flex items-center gap-2">
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-xs transition-colors shadow-md"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
{m.reader_chapter_n({ n: String(data.prev) })}
</a>
{/if}
<button
type="button"
onclick={() => setLayout('focusMode', false)}
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-brand) text-xs transition-colors shadow-md"
aria-label="Exit focus mode"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
Exit focus
</button>
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-xs transition-colors shadow-md"
>
{m.reader_chapter_n({ n: String(data.next) })}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
{/if}
</div>
{/if}
<!-- ── Reader settings bottom sheet ──────────────────────────────────────── -->
{#if settingsCtx}
@@ -653,27 +653,6 @@
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Typography</p>
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
<!-- Theme -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-10 shrink-0">Theme</span>
<div class="flex flex-wrap gap-1.5 flex-1">
{#each READER_THEMES as t}
<button
onclick={() => applyTheme(t.id)}
title={t.label}
class="flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition-colors
{panelTheme === t.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelTheme === t.id}
>
<span class="w-2 h-2 rounded-full shrink-0 {'light' in t && t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
{t.label}
</button>
{/each}
</div>
</div>
<!-- Font -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-10 shrink-0">Font</span>
@@ -828,51 +807,6 @@
</div>
</div>
{#if page.data.user}
<!-- Speed -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-14 shrink-0">Speed</span>
<div class="flex gap-1 flex-1">
{#each [0.75, 1, 1.25, 1.5, 2] as s}
<button
type="button"
onclick={() => { audioStore.speed = s; }}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{audioStore.speed === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={audioStore.speed === s}
>{s}×</button>
{/each}
</div>
</div>
<!-- Auto-next -->
<button
type="button"
onclick={() => { audioStore.autoNext = !audioStore.autoNext; }}
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
{audioStore.autoNext ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
aria-pressed={audioStore.autoNext}
>
<span>Auto-next chapter</span>
<span class="text-(--color-muted) text-[11px]">{audioStore.autoNext ? 'On' : 'Off'}</span>
</button>
<!-- Sleep timer -->
<button
type="button"
onclick={toggleSleepFromSettings}
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
{audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
>
<span>Sleep timer</span>
<span class="text-(--color-muted) text-[11px]">{sleepSettingsLabel}</span>
</button>
{/if}
</div>
</div>