Files
libnovel/scraper/internal/server/ui.go
Admin 7879a51fe3 feat: add Kokoro TTS, ranking page, direct HTTP strategy, and chapter-number fix
- Add Kokoro-FastAPI TTS integration to the chapter reader UI:
  - Browser-side MSE streaming with paragraph-level click-to-start
  - Voice selector, speed slider, auto-next with prefetch of the next chapter
  - New GET /ui/chapter-text endpoint that strips Markdown and serves plain text

- Add ranking page (novelfire /ranking scraper, WriteRanking/ReadRankingItems
  in writer, GET /ranking + POST /ranking/refresh + GET /ranking/view routes)
  with local-library annotation and one-click scrape buttons

- Add StrategyDirect (plain HTTP client) as a new browser strategy; the
  default strategy is now 'direct' for chapter fetching and 'content'
  for chapter-list URL retrieval (split via BROWSERLESS_URL_STRATEGY)

- Fix chapter numbering bug: numbers are now derived from the URL path
  (/chapter-N) rather than list position, correcting newest-first ordering

- Add 'refresh <slug>' CLI sub-command to re-scrape a book from its saved
  source_url without knowing the original URL

- Extend NovelScraper interface with RankingProvider (ScrapeRanking)

- Tune scraper timeouts: wait-for-selector reduced to 5 s, GotoOptions
  timeout set to 60 s, content/scrape client defaults raised to 90 s

- Add cover extraction fix (figure.cover > img rather than bare img.cover)

- Add AGENTS.md and .aiignore for AI tooling context

- Add integration tests for browser client and novelfire scraper (build
  tag: integration) and unit tests for chapterNumberFromURL and pagination
2026-03-01 12:25:16 +05:00

1332 lines
47 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"bytes"
"context"
"fmt"
"html/template"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/libnovel/scraper/internal/orchestrator"
"github.com/libnovel/scraper/internal/writer"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
goldhtml "github.com/yuin/goldmark/renderer/html"
)
// md is the shared goldmark instance used for all markdown→HTML conversions.
var md = goldmark.New(
goldmark.WithExtensions(extension.Typographer, extension.Table),
goldmark.WithRendererOptions(goldhtml.WithUnsafe()),
)
// kokoroVoices is the full list of voices shipped with Kokoro-FastAPI,
// grouped loosely by language prefix:
//
// af_ / am_ American English female / male
// bf_ / bm_ British English female / male
// ef_ / em_ Spanish female / male
// ff_ French female
// hf_ / hm_ Hindi female / male
// if_ / im_ Italian female / male
// jf_ / jm_ Japanese female / male
// pf_ / pm_ Portuguese female / male
// zf_ / zm_ Chinese female / male
var kokoroVoices = []string{
// American English
"af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia",
"af_jessica", "af_kore", "af_nicole", "af_nova", "af_river",
"af_sarah", "af_sky",
"am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam",
"am_michael", "am_onyx", "am_puck",
// British English
"bf_alice", "bf_emma", "bf_lily",
"bm_daniel", "bm_fable", "bm_george", "bm_lewis",
// Spanish
"ef_dora", "em_alex",
// French
"ff_siwis",
// Hindi
"hf_alpha", "hf_beta", "hm_omega", "hm_psi",
// Italian
"if_sara", "im_nicola",
// Japanese
"jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo",
// Portuguese
"pf_dora", "pm_alex",
// Chinese
"zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi",
"zm_yunjian", "zm_yunxi", "zm_yunxia", "zm_yunyang",
}
// ─── shared layout ────────────────────────────────────────────────────────────
const layoutHead = `<!DOCTYPE html>
<html lang="en" class="bg-zinc-950 text-zinc-100">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} — libnovel</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous"></script>
<style>
.prose p { margin-bottom: 1em; }
.prose h1,.prose h2,
.prose h3,.prose h4 { font-weight: 700; margin: 1.4em 0 .5em; line-height: 1.25; }
.prose h4 { font-size: 1.05rem; }
.prose h3 { font-size: 1.2rem; }
.prose h2 { font-size: 1.4rem; }
.prose h1 { font-size: 1.7rem; }
.prose em { font-style: italic; }
.prose strong { font-weight: 700; }
.prose hr { border-color: #3f3f46; margin: 2em 0; }
.prose blockquote { border-left: 3px solid #52525b; padding-left: 1rem; color: #a1a1aa; }
</style>
</head>
<body class="min-h-screen">`
const layoutFoot = `</body></html>`
func renderPage(w http.ResponseWriter, title, body string) {
t := template.Must(template.New("layout").Parse(layoutHead + body + layoutFoot))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = t.Execute(w, struct{ Title string }{Title: title})
}
func renderFragment(w http.ResponseWriter, body string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, body)
}
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// respond writes either a full page or an HTMX fragment depending on the request.
func (s *Server) respond(w http.ResponseWriter, r *http.Request, title, fragment string) {
if isHTMX(r) {
renderFragment(w, fragment)
return
}
renderPage(w, title,
`<main id="main-content" class="min-h-screen">`+fragment+`</main>`)
}
// ─── GET / — book catalogue ───────────────────────────────────────────────────
const homeTmpl = `
<div class="max-w-4xl mx-auto px-4 py-10">
<div class="flex items-center justify-between mb-2">
<h1 class="text-3xl font-bold text-zinc-100">libnovel</h1>
<a href="/ranking" hx-get="/ranking" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML" class="text-sm px-3 py-1.5 rounded-lg bg-amber-700 hover:bg-amber-600 text-white">Browse Rankings</a>
</div>
<p class="text-zinc-400 mb-8">{{len .Books}} book{{if ne (len .Books) 1}}s{{end}} on disk</p>
<!-- Scrape form -->
<div class="mb-10 rounded-xl border border-zinc-800 bg-zinc-900 p-5">
<h2 class="text-sm font-semibold text-zinc-300 mb-3">Scrape a new book</h2>
<form hx-post="/ui/scrape/book"
hx-target="#scrape-status"
hx-swap="innerHTML"
class="flex gap-2">
<input type="url"
name="url"
required
placeholder="https://novelfire.net/book/some-book"
class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-amber-500 transition-colors" />
<button type="submit"
class="px-4 py-2 rounded-lg bg-amber-600 hover:bg-amber-500 text-white text-sm font-medium transition-colors whitespace-nowrap">
Scrape
</button>
</form>
<div id="scrape-status" class="mt-3"></div>
</div>
<!-- Book grid -->
<div class="grid gap-4 sm:grid-cols-2">
{{range .Books}}
<a href="/books/{{.Slug}}"
hx-get="/books/{{.Slug}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="group block rounded-xl border border-zinc-800 bg-zinc-900 p-5 hover:border-amber-500 transition-colors cursor-pointer">
<div class="flex gap-4">
{{if .Cover}}
<img src="{{.Cover}}" alt="cover" class="w-14 h-20 object-cover rounded flex-shrink-0">
{{end}}
<div class="min-w-0">
<h2 class="font-semibold text-zinc-100 group-hover:text-amber-400 truncate">{{.Title}}</h2>
{{if .Author}}<p class="text-sm text-zinc-400 mt-0.5">{{.Author}}</p>{{end}}
<div class="flex gap-2 mt-2 flex-wrap">
{{if .Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Status}}</span>{{end}}
{{if .TotalChapters}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.TotalChapters}} ch</span>{{end}}
</div>
</div>
</div>
</a>
{{else}}
<p class="text-zinc-500 col-span-2">No books scraped yet.</p>
{{end}}
</div>
</div>`
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
books, err := s.writer.ListBooks()
if err != nil {
http.Error(w, "failed to list books: "+err.Error(), http.StatusInternalServerError)
return
}
t := template.Must(template.New("home").Parse(homeTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct{ Books interface{} }{Books: books})
s.respond(w, r, "Home", buf.String())
}
// ─── GET /ranking — ranking page ───────────────────────────────────────────────
const rankingTmpl = `
<div class="max-w-4xl mx-auto px-4 py-10">
<a href="/"
hx-get="/"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
← All books
</a>
<div class="flex items-start justify-between gap-4 mb-2 flex-wrap">
<div>
<h1 class="text-3xl font-bold text-zinc-100">Novel Rankings</h1>
<p class="text-zinc-400 mt-1">Top novels from novelfire.net</p>
{{if .CachedAt}}<p class="text-xs text-zinc-500 mt-1">Cached {{.CachedAt}}</p>{{end}}
</div>
<div class="flex gap-2 mt-1">
<a href="/ranking/view"
hx-get="/ranking/view"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm px-3 py-1.5 rounded-lg bg-zinc-700 hover:bg-zinc-600 text-white inline-flex items-center gap-1">
View Markdown
</a>
<button
hx-post="/ranking/refresh"
hx-target="#main-content"
hx-swap="innerHTML"
hx-indicator="#refresh-spinner"
hx-push-url="/ranking"
class="text-sm px-3 py-1.5 rounded-lg bg-amber-700 hover:bg-amber-600 text-white inline-flex items-center gap-2">
<span id="refresh-spinner" class="htmx-indicator animate-spin">&#8635;</span>
Refresh Rankings
</button>
</div>
</div>
<!-- Book grid -->
<div class="grid gap-4 sm:grid-cols-2 mt-8">
{{range .Books}}
{{if .Local}}
{{/* Book is in local library — wrap entire card in a clickable link */}}
<a href="/books/{{.Slug}}"
hx-get="/books/{{.Slug}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="group block rounded-xl border border-teal-700 bg-teal-950 p-4 hover:border-teal-400 transition-colors">
<div class="flex gap-4">
{{if .Cover}}
<img src="{{.Cover}}" alt="cover" class="w-14 h-20 object-cover rounded flex-shrink-0">
{{end}}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
{{if .Rank}}<span class="text-xs font-bold text-amber-400">#{{.Rank}}</span>{{end}}
<h2 class="font-semibold text-zinc-100 group-hover:text-teal-300 truncate">{{.Title}}</h2>
<span class="ml-auto text-xs px-1.5 py-0.5 rounded bg-teal-800 text-teal-300 flex-shrink-0">In library</span>
</div>
{{if .Author}}<p class="text-sm text-zinc-400 mt-0.5">{{.Author}}</p>{{end}}
<div class="flex gap-2 mt-2 flex-wrap">
{{if .Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-teal-900 text-teal-300">{{.Status}}</span>{{end}}
</div>
{{if .Genres}}
<div class="flex gap-1 mt-2 flex-wrap">
{{range .Genres}}<span class="text-xs px-1.5 py-0.5 rounded bg-teal-900 text-teal-400">{{.}}</span>{{end}}
</div>
{{end}}
</div>
</div>
</a>
{{else}}
{{/* Book not yet in local library */}}
<div class="group block rounded-xl border border-zinc-800 bg-zinc-900 p-4 hover:border-amber-500 transition-colors">
<div class="flex gap-4">
{{if .Cover}}
<img src="{{.Cover}}" alt="cover" class="w-14 h-20 object-cover rounded flex-shrink-0">
{{end}}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
{{if .Rank}}<span class="text-xs font-bold text-amber-400">#{{.Rank}}</span>{{end}}
<h2 class="font-semibold text-zinc-100 group-hover:text-amber-400 truncate">{{.Title}}</h2>
</div>
{{if .Author}}<p class="text-sm text-zinc-400 mt-0.5">{{.Author}}</p>{{end}}
<div class="flex gap-2 mt-2 flex-wrap">
{{if .Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Status}}</span>{{end}}
</div>
{{if .Genres}}
<div class="flex gap-1 mt-2 flex-wrap">
{{range .Genres}}<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-400">{{.}}</span>{{end}}
</div>
{{end}}
{{if .SourceURL}}
<div class="mt-3">
<form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
<input type="hidden" name="url" value="{{.SourceURL}}">
<button type="submit"
class="text-xs px-2 py-1 rounded bg-amber-700 hover:bg-amber-600 text-white"
title="Scrape full content">
Scrape
</button>
</form>
</div>
{{end}}
</div>
</div>
</div>
{{end}}
{{else}}
<p class="text-zinc-500 col-span-2">No ranking data available. Click "Refresh Rankings" to fetch from novelfire.net.</p>
{{end}}
</div>
</div>`
// rankingViewItem enriches a RankingItem with whether it is present in the
// local book library, so the template can highlight it differently.
type rankingViewItem struct {
writer.RankingItem
Local bool
}
// toRankingViewItems annotates items with Local=true for slugs found in localSlugs.
func toRankingViewItems(items []writer.RankingItem, localSlugs map[string]bool) []rankingViewItem {
out := make([]rankingViewItem, len(items))
for i, it := range items {
out[i] = rankingViewItem{
RankingItem: it,
Local: localSlugs[it.Slug],
}
}
return out
}
// handleRanking serves the ranking page from the cached ranking.md file.
// It does NOT trigger a live scrape; use POST /ranking/refresh for that.
func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
rankingItems, err := s.writer.ReadRankingItems()
if err != nil {
s.log.Error("failed to read cached ranking", "err", err)
}
cachedAt := ""
if info, statErr := s.writer.RankingFileInfo(); statErr == nil {
cachedAt = info.ModTime().Format("Jan 2, 2006 at 15:04")
}
t := template.Must(template.New("ranking").Parse(rankingTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Books interface{}
CachedAt string
}{Books: toRankingViewItems(rankingItems, s.writer.LocalSlugs()), CachedAt: cachedAt})
s.respond(w, r, "Rankings", buf.String())
}
// handleRankingRefresh triggers a live scrape of novelfire.net/ranking,
// persists the result to ranking.md, then re-renders the ranking page.
func (s *Server) handleRankingRefresh(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
rankingCh, errCh := s.novel.ScrapeRanking(ctx)
var rankingItems []writer.RankingItem
for {
select {
case meta, ok := <-rankingCh:
if !ok {
rankingCh = nil
continue
}
rankingItems = append(rankingItems, writer.RankingItem{
Rank: meta.Ranking,
Slug: meta.Slug,
Title: meta.Title,
Author: meta.Author,
Cover: meta.Cover,
Status: meta.Status,
Genres: meta.Genres,
SourceURL: meta.SourceURL,
})
case err, ok := <-errCh:
if !ok {
errCh = nil
continue
}
if err != nil {
s.log.Error("ranking scrape error", "err", err)
}
}
if rankingCh == nil && errCh == nil {
break
}
}
if len(rankingItems) > 0 {
if err := s.writer.WriteRanking(rankingItems); err != nil {
s.log.Error("failed to save ranking", "err", err)
}
}
cachedAt := ""
if info, statErr := s.writer.RankingFileInfo(); statErr == nil {
cachedAt = info.ModTime().Format("Jan 2, 2006 at 15:04")
}
t := template.Must(template.New("ranking").Parse(rankingTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Books interface{}
CachedAt string
}{Books: toRankingViewItems(rankingItems, s.writer.LocalSlugs()), CachedAt: cachedAt})
s.respond(w, r, "Rankings", buf.String())
}
// ─── GET /ranking/view — view ranking markdown ─────────────────────────────────
const rankingViewTmpl = `
<div class="max-w-4xl mx-auto px-4 py-10">
<a href="/ranking"
hx-get="/ranking"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
← Back to Rankings
</a>
<h1 class="text-3xl font-bold text-zinc-100 mb-6">Ranking Data</h1>
<div class="prose prose-invert max-w-none">
{{.HTML}}
</div>
</div>`
func (s *Server) handleRankingView(w http.ResponseWriter, r *http.Request) {
markdown, err := s.writer.ReadRanking()
if err != nil {
http.Error(w, "failed to read ranking: "+err.Error(), http.StatusInternalServerError)
return
}
if markdown == "" {
http.NotFound(w, r)
return
}
var htmlBuf bytes.Buffer
if err := md.Convert([]byte(markdown), &htmlBuf); err != nil {
http.Error(w, "markdown render error: "+err.Error(), http.StatusInternalServerError)
return
}
t := template.Must(template.New("rankingView").Parse(rankingViewTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct{ HTML template.HTML }{HTML: template.HTML(htmlBuf.String())})
s.respond(w, r, "Ranking Data", buf.String())
}
// ─── GET /books/{slug} — chapter list ────────────────────────────────────────
const bookTmpl = `
<div class="max-w-2xl mx-auto px-4 py-10">
<a href="/"
hx-get="/"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm text-zinc-400 hover:text-amber-400 mb-6 inline-flex items-center gap-1">
← All books
</a>
<div class="flex gap-5 mb-8 mt-4">
{{if .Meta.Cover}}
<img src="{{.Meta.Cover}}" alt="cover" class="w-24 h-36 object-cover rounded-lg flex-shrink-0 shadow-lg">
{{end}}
<div>
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-zinc-100">{{.Meta.Title}}</h1>
{{if .Meta.SourceURL}}
<form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
<input type="hidden" name="url" value="{{.Meta.SourceURL}}">
<button type="submit"
class="text-xs px-2 py-1 rounded bg-amber-700 hover:bg-amber-600 text-white"
title="Re-scrape from source">
Refresh
</button>
</form>
{{end}}
</div>
{{if .Meta.Author}}<p class="text-zinc-400 mt-1">{{.Meta.Author}}</p>{{end}}
<div class="flex gap-2 mt-2 flex-wrap">
{{if .Meta.Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Meta.Status}}</span>{{end}}
{{if .Meta.TotalChapters}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Meta.TotalChapters}} ch total</span>{{end}}
<span class="text-xs px-2 py-0.5 rounded-full bg-amber-900 text-amber-300">{{len .Chapters}} downloaded</span>
</div>
{{if .Meta.Summary}}
<p class="text-zinc-400 text-sm mt-3 line-clamp-3">{{.Meta.Summary}}</p>
{{end}}
</div>
</div>
<h2 class="text-lg font-semibold text-zinc-200 mb-3">Chapters</h2>
<ul class="space-y-1">
{{range .Chapters}}
<li>
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors group cursor-pointer">
<span class="text-xs text-zinc-500 w-10 text-right flex-shrink-0">{{.Number}}</span>
<div class="min-w-0">
<span class="text-zinc-300 group-hover:text-amber-400 truncate block">{{.Title}}</span>
{{if .Date}}<span class="text-xs text-zinc-500 block mt-0.5">{{.Date}}</span>{{end}}
</div>
</a>
</li>
{{else}}
<li class="text-zinc-500 px-3 py-2">No chapters downloaded yet.</li>
{{end}}
</ul>
</div>`
func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
meta, ok, err := s.writer.ReadMetadata(slug)
if err != nil {
http.Error(w, "failed to read metadata: "+err.Error(), http.StatusInternalServerError)
return
}
if !ok {
http.NotFound(w, r)
return
}
chapters, err := s.writer.ListChapters(slug)
if err != nil {
http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError)
return
}
t := template.Must(template.New("book").Parse(bookTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Slug string
Meta interface{}
Chapters interface{}
}{Slug: slug, Meta: meta, Chapters: chapters})
s.respond(w, r, meta.Title, buf.String())
}
// ─── GET /books/{slug}/chapters/{n} — chapter reader ─────────────────────────
const chapterTmpl = `
<div class="max-w-2xl mx-auto px-4 py-10">
<div class="flex items-center justify-between mb-8">
<a href="/books/{{.Slug}}"
hx-get="/books/{{.Slug}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm text-zinc-400 hover:text-amber-400 inline-flex items-center gap-1">
← Chapter list
</a>
<div class="flex gap-2">
{{if .PrevN}}
<a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">
← Prev
</a>
{{end}}
{{if .NextN}}
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">
Next →
</a>
{{end}}
</div>
</div>
<!-- TTS player bar — calls Kokoro directly from the browser -->
<div id="tts-bar" class="mb-6 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3">
<div class="flex items-center gap-3 flex-wrap">
<!-- Play/pause button -->
<button id="tts-btn"
onclick="ttsToggle()"
class="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-amber-600 hover:bg-amber-500 text-white font-medium transition-colors">
<span id="tts-icon">&#9654;</span>
<span id="tts-label">Listen</span>
</button>
<!-- Voice selector -->
<select id="tts-voice"
class="rounded-lg bg-zinc-800 border border-zinc-700 px-2 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-amber-500 transition-colors">
{{range .Voices}}
<option value="{{.}}"{{if eq . $.DefaultVoice}} selected{{end}}>{{.}}</option>
{{end}}
</select>
<!-- Speed control -->
<div class="flex items-center gap-1.5">
<span class="text-xs text-zinc-500 whitespace-nowrap">Speed</span>
<input id="tts-speed" type="range"
min="0.5" max="2" step="0.1" value="1"
class="w-20 accent-amber-500 cursor-pointer" />
<span id="tts-speed-label" class="text-xs text-zinc-400 w-7 text-right">1.0×</span>
</div>
<div id="tts-status" class="text-xs text-zinc-500 ml-1"></div>
<!-- Auto-play next chapter toggle -->
<label class="flex items-center gap-1.5 ml-auto cursor-pointer select-none" title="Automatically start the next chapter when this one finishes">
<input id="tts-autoplay" type="checkbox" class="accent-amber-500 cursor-pointer" />
<span class="text-xs text-zinc-400 whitespace-nowrap">Auto-next</span>
</label>
</div>
<audio id="tts-audio" style="display:none"></audio>
</div>
<!-- Paragraphs get data-para-idx injected by JS after render -->
<article id="chapter-article" class="prose text-zinc-300 leading-relaxed text-[1.05rem]">
{{.HTML}}
</article>
<div class="flex justify-between mt-12 pt-6 border-t border-zinc-800">
{{if .PrevN}}
<a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">
← Previous chapter
</a>
{{else}}<span></span>{{end}}
{{if .NextN}}
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">
Next chapter →
</a>
{{else}}<span></span>{{end}}
</div>
</div>
<style>
/* Paragraph click-to-play affordance */
#chapter-article p {
cursor: pointer;
border-radius: 0.375rem;
margin-left: -0.5rem;
padding-left: 0.5rem;
transition: background 0.15s;
}
#chapter-article p:hover {
background: rgba(251,191,36,0.07);
}
#chapter-article p.tts-active {
background: rgba(251,191,36,0.13);
border-left: 2px solid #f59e0b;
padding-left: calc(0.5rem - 2px);
}
</style>
<script>
(function () {
var KOKORO_URL = '{{.KokoroURL}}';
var NEXT_N = {{.NextN}};
var SLUG = '{{.Slug}}';
var audio = document.getElementById('tts-audio');
var btn = document.getElementById('tts-btn');
var icon = document.getElementById('tts-icon');
var label = document.getElementById('tts-label');
var status = document.getElementById('tts-status');
var voiceSel = document.getElementById('tts-voice');
var speedSlider = document.getElementById('tts-speed');
var speedLabel = document.getElementById('tts-speed-label');
var autoplayChk = document.getElementById('tts-autoplay');
var article = document.getElementById('chapter-article');
var abortCtrl = null;
var mse = null;
var srcBuf = null;
var queue = [];
var appending = false;
var fetchDone = false;
var activePara = null; // currently highlighted <p> element
// ── next-chapter prefetch state ───────────────────────────────────────────────
// When auto-play is on we begin fetching the next chapter's audio in the
// background as soon as Kokoro finishes streaming the current chapter.
// The result is held in a second <audio>/MSE pipeline so that when the
// current chapter ends we can play it without any buffering wait.
var prefetchAudio = null; // hidden <audio> element used for prefetch
var prefetchMSE = null;
var prefetchBuf = null;
var prefetchQueue = [];
var prefetchAppending = false;
var prefetchDone = false;
var prefetchAbort = null;
var prefetchReady = false; // true once canplay fired on prefetchAudio
// ── speed slider ─────────────────────────────────────────────────────────────
speedSlider.addEventListener('input', function () {
speedLabel.textContent = parseFloat(speedSlider.value).toFixed(1) + '\u00D7';
});
// ── paragraph indexing ───────────────────────────────────────────────────────
// Collect all <p> elements inside the article, assign data-para-idx,
// and wire click handlers so any paragraph starts TTS from that point.
var paras = Array.prototype.slice.call(article.querySelectorAll('p'));
paras.forEach(function (p, i) {
p.dataset.paraIdx = i;
p.title = 'Click to read from here';
p.addEventListener('click', function () {
startFromPara(i);
});
});
function textFromPara(startIdx) {
return paras
.slice(startIdx)
.map(function (p) { return p.innerText.trim(); })
.filter(function (t) { return t.length > 0; })
.join('\n\n');
}
function highlightPara(idx) {
if (activePara) activePara.classList.remove('tts-active');
activePara = (idx >= 0 && idx < paras.length) ? paras[idx] : null;
if (activePara) {
activePara.classList.add('tts-active');
activePara.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
// ── state helpers ─────────────────────────────────────────────────────────────
function setLoading(paraIdx) {
highlightPara(paraIdx);
icon.textContent = '\u231B';
label.textContent = 'Buffering';
status.textContent = 'Fetching from Kokoro\u2026';
btn.disabled = true;
btn.classList.add('opacity-60', 'cursor-not-allowed');
voiceSel.disabled = true;
speedSlider.disabled = true;
}
function setPlaying() {
icon.innerHTML = '&#9646;&#9646;';
label.textContent = 'Pause';
status.textContent = 'Playing';
btn.disabled = false;
btn.classList.remove('opacity-60', 'cursor-not-allowed');
}
function setPaused() {
icon.innerHTML = '&#9654;';
label.textContent = 'Resume';
status.textContent = 'Paused';
}
function setStopped() {
highlightPara(-1);
icon.innerHTML = '&#9654;';
label.textContent = 'Listen';
status.textContent = '';
btn.disabled = false;
btn.classList.remove('opacity-60', 'cursor-not-allowed');
voiceSel.disabled = false;
speedSlider.disabled = false;
}
function setError(msg) {
highlightPara(-1);
icon.innerHTML = '&#9654;';
label.textContent = 'Listen';
status.textContent = 'Error: ' + msg;
btn.disabled = false;
btn.classList.remove('opacity-60', 'cursor-not-allowed');
voiceSel.disabled = false;
speedSlider.disabled = false;
}
// ── MSE helpers ───────────────────────────────────────────────────────────────
function flushQueue() {
if (appending || !srcBuf || srcBuf.updating || queue.length === 0) return;
appending = true;
var chunk = queue.shift();
try {
srcBuf.appendBuffer(chunk);
} catch (e) {
appending = false;
setError(e.message);
}
}
function endStream() {
if (mse && mse.readyState === 'open' && srcBuf && !srcBuf.updating) {
try { mse.endOfStream(); } catch(_) {}
}
// Current chapter audio is fully received — good time to start prefetching
// the next chapter so it's ready by the time playback ends.
if (autoplayChk.checked && NEXT_N && !prefetchAbort) {
startPrefetch();
}
}
// ── next-chapter prefetch ─────────────────────────────────────────────────────
function stopPrefetch() {
if (prefetchAbort) { prefetchAbort.abort(); prefetchAbort = null; }
if (prefetchAudio) {
if (prefetchAudio.src && prefetchAudio.src.startsWith('blob:')) {
URL.revokeObjectURL(prefetchAudio.src);
}
prefetchAudio.src = '';
prefetchAudio = null;
}
prefetchMSE = null;
prefetchBuf = null;
prefetchQueue = [];
prefetchAppending = false;
prefetchDone = false;
prefetchReady = false;
}
function flushPrefetchQueue() {
if (prefetchAppending || !prefetchBuf || prefetchBuf.updating || prefetchQueue.length === 0) return;
prefetchAppending = true;
var chunk = prefetchQueue.shift();
try {
prefetchBuf.appendBuffer(chunk);
} catch (e) {
prefetchAppending = false;
}
}
function startPrefetch() {
if (!KOKORO_URL || !NEXT_N) return;
// Fetch the next chapter's plain text from the server's chapter-text endpoint.
fetch('/ui/chapter-text/' + SLUG + '/' + NEXT_N)
.then(function (res) { return res.ok ? res.text() : Promise.reject(res.status); })
.then(function (text) {
text = text.trim();
if (!text) return;
prefetchAbort = new AbortController();
prefetchMSE = new MediaSource();
prefetchQueue = [];
prefetchAppending = false;
prefetchDone = false;
prefetchReady = false;
prefetchAudio = document.createElement('audio');
prefetchAudio.style.display = 'none';
prefetchAudio.src = URL.createObjectURL(prefetchMSE);
prefetchAudio.addEventListener('canplay', function () {
prefetchReady = true;
});
prefetchMSE.addEventListener('sourceopen', function () {
try {
prefetchBuf = prefetchMSE.addSourceBuffer('audio/mpeg');
} catch (e) { return; }
prefetchBuf.addEventListener('updateend', function () {
prefetchAppending = false;
if (prefetchDone && prefetchQueue.length === 0) {
if (prefetchMSE.readyState === 'open' && !prefetchBuf.updating) {
try { prefetchMSE.endOfStream(); } catch(_) {}
}
} else {
flushPrefetchQueue();
}
});
fetch(KOKORO_URL + '/v1/audio/speech', {
method: 'POST',
signal: prefetchAbort.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'kokoro',
input: text,
voice: voiceSel.value,
response_format: 'mp3',
speed: parseFloat(speedSlider.value),
stream: true
})
})
.then(function (res) {
if (!res.ok) return;
var reader = res.body.getReader();
function pump() {
reader.read().then(function (ref) {
if (ref.done) {
prefetchDone = true;
if (prefetchBuf && !prefetchBuf.updating && prefetchQueue.length === 0) {
if (prefetchMSE && prefetchMSE.readyState === 'open') {
try { prefetchMSE.endOfStream(); } catch(_) {}
}
}
return;
}
prefetchQueue.push(ref.value);
flushPrefetchQueue();
pump();
}).catch(function () {});
}
pump();
})
.catch(function () {});
});
prefetchAudio.load();
})
.catch(function () {});
}
// ── core stream logic ─────────────────────────────────────────────────────────
function stop() {
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
if (audio.src && audio.src.startsWith('blob:')) {
URL.revokeObjectURL(audio.src);
}
audio.src = '';
mse = null;
srcBuf = null;
queue = [];
appending = false;
fetchDone = false;
stopPrefetch();
setStopped();
}
function startStream(text, paraIdx) {
abortCtrl = new AbortController();
mse = new MediaSource();
queue = [];
appending = false;
fetchDone = false;
audio.src = URL.createObjectURL(mse);
mse.addEventListener('sourceopen', function () {
try {
srcBuf = mse.addSourceBuffer('audio/mpeg');
} catch (e) {
setError('MSE: ' + e.message);
return;
}
srcBuf.addEventListener('updateend', function () {
appending = false;
if (fetchDone && queue.length === 0) {
endStream();
} else {
flushQueue();
}
});
fetch(KOKORO_URL + '/v1/audio/speech', {
method: 'POST',
signal: abortCtrl.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'kokoro',
input: text,
voice: voiceSel.value,
response_format: 'mp3',
speed: parseFloat(speedSlider.value),
stream: true
})
})
.then(function (res) {
if (!res.ok) {
return res.text().then(function (t) { throw new Error(res.status + ': ' + t); });
}
var reader = res.body.getReader();
function pump() {
reader.read().then(function (ref) {
if (ref.done) {
fetchDone = true;
if (!srcBuf.updating && queue.length === 0) { endStream(); }
return;
}
queue.push(ref.value);
flushQueue();
pump();
}).catch(function (e) {
if (e.name !== 'AbortError') setError(e.message);
});
}
pump();
})
.catch(function (e) {
if (e.name !== 'AbortError') setError(e.message);
});
});
audio.load();
}
// startFromPara stops any current stream and begins a new one from paraIdx.
function startFromPara(paraIdx) {
if (!KOKORO_URL) { setError('KOKORO_URL not configured on server'); return; }
stop();
var text = textFromPara(paraIdx);
if (!text) { setError('no text found'); return; }
setLoading(paraIdx);
startStream(text, paraIdx);
}
// ── auto-play next chapter ────────────────────────────────────────────────────
// When auto-play is on we try to hand off directly from the prefetched audio
// pipeline. The page navigation still happens (so the URL and chapter title
// update correctly) but audio starts immediately from the prefetch buffer
// instead of waiting for a cold Kokoro request.
function goNextChapter() {
if (!NEXT_N) return;
var nextURL = '/books/' + SLUG + '/chapters/' + NEXT_N;
if (prefetchAudio && prefetchAbort) {
// We have a prefetch in progress (or complete). Swap the prefetch audio
// element into the main player so it plays without delay.
var pa = prefetchAudio;
// Detach from prefetch state so stopPrefetch() called by htmx:beforeSwap
// doesn't destroy it before we hand it off.
prefetchAudio = null;
prefetchAbort = null;
// Navigate the page (URL + content swap) without the ?autoplay=1 flag
// because we are starting audio ourselves right here.
htmx.ajax('GET', nextURL, {
target: '#main-content',
swap: 'innerHTML',
pushURL: nextURL
});
// Play prefetched audio immediately (or as soon as it can).
// The new page's script will take over its own audio element, so we
// drive this orphaned element directly until the user interacts.
// Use the main audio element to carry the blob URL across the swap.
if (pa.src && pa.src.startsWith('blob:')) {
audio.src = pa.src;
pa.src = ''; // transfer ownership so revokeObjectURL isn't called twice
if (prefetchReady || pa.readyState >= 3 /* HAVE_FUTURE_DATA */) {
audio.play().catch(function () {});
} else {
audio.addEventListener('canplay', function onCp() {
audio.removeEventListener('canplay', onCp);
audio.play().catch(function () {});
});
}
}
// Clean up the rest of the prefetch state (don't abort — the fetch
// may still be pumping chunks into the buffer we are now playing).
prefetchMSE = null; prefetchBuf = null;
prefetchQueue = []; prefetchAppending = false;
prefetchDone = false; prefetchReady = false;
} else {
// No prefetch available — fall back to cold navigation with ?autoplay=1.
htmx.ajax('GET', nextURL + '?autoplay=1', {
target: '#main-content',
swap: 'innerHTML',
pushURL: nextURL
});
}
}
// ── audio events ──────────────────────────────────────────────────────────────
audio.addEventListener('canplay', function () {
if (audio.paused) {
audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
}
});
audio.addEventListener('waiting', function () { status.textContent = 'Buffering\u2026'; });
audio.addEventListener('playing', setPlaying);
audio.addEventListener('ended', function () {
// Cancel the main stream state but keep prefetch alive — goNextChapter needs it.
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
if (audio.src && audio.src.startsWith('blob:')) URL.revokeObjectURL(audio.src);
audio.src = ''; mse = null; srcBuf = null; queue = []; appending = false; fetchDone = false;
if (autoplayChk.checked && NEXT_N) {
goNextChapter();
} else {
stopPrefetch();
setStopped();
}
});
audio.addEventListener('error', function () { setError('audio decode error'); });
audio.addEventListener('pause', function () { if (!audio.ended) setPaused(); });
audio.addEventListener('play', setPlaying);
// ── toolbar toggle (plays from paragraph 0) ───────────────────────────────────
window.ttsToggle = function () {
if (audio.src && !audio.ended) {
if (audio.paused) {
audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
} else {
audio.pause();
}
return;
}
startFromPara(0);
};
// Stop when HTMX navigates away.
document.body.addEventListener('htmx:beforeSwap', stop);
// ── auto-start TTS when arriving via auto-play navigation ────────────────────
// The previous chapter's goNextChapter() appends ?autoplay=1. Detect it here
// and kick off playback from paragraph 0 once the page is ready.
if (new URLSearchParams(window.location.search).get('autoplay') === '1') {
autoplayChk.checked = true; // keep the toggle on for the chain to continue
// Small delay to let the DOM settle before starting the stream.
setTimeout(function () { startFromPara(0); }, 150);
}
}());
</script>`
func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
http.NotFound(w, r)
return
}
raw, err := s.writer.ReadChapter(slug, n)
if err != nil {
http.NotFound(w, r)
return
}
var htmlBuf bytes.Buffer
if err := md.Convert([]byte(raw), &htmlBuf); err != nil {
http.Error(w, "markdown render error: "+err.Error(), http.StatusInternalServerError)
return
}
chapters, _ := s.writer.ListChapters(slug)
prevN, nextN := adjacentChapters(chapters, n)
title := firstHeading(raw, fmt.Sprintf("Chapter %d", n))
t := template.Must(template.New("chapter").Parse(chapterTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Slug string
HTML template.HTML
PrevN int
NextN int
ChapterN int
KokoroURL string
Voices []string
DefaultVoice string
}{
Slug: slug,
HTML: template.HTML(htmlBuf.String()),
PrevN: prevN,
NextN: nextN,
ChapterN: n,
KokoroURL: s.kokoroURL,
Voices: kokoroVoices,
DefaultVoice: s.kokoroVoice,
})
s.respond(w, r, title, buf.String())
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// stripMarkdown removes Markdown syntax and returns clean plain text.
func stripMarkdown(src string) string {
src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "")
src = regexp.MustCompile(`\*{1,3}|_{1,3}`).ReplaceAllString(src, "")
src = regexp.MustCompile("(?s)```.*?```").ReplaceAllString(src, "")
src = regexp.MustCompile("`[^`]*`").ReplaceAllString(src, "")
src = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(src, "$1")
src = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`).ReplaceAllString(src, "")
src = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(src, "")
src = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`).ReplaceAllString(src, "")
src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n")
return strings.TrimSpace(src)
}
// adjacentChapters returns the chapter numbers immediately before and after n
// in the sorted chapters list. 0 means "does not exist".
func adjacentChapters(chapters []writer.ChapterInfo, n int) (prev, next int) {
for i, ch := range chapters {
if ch.Number == n {
if i > 0 {
prev = chapters[i-1].Number
}
if i < len(chapters)-1 {
next = chapters[i+1].Number
}
return
}
}
return
}
// firstHeading returns the text of the first non-empty line, stripping a
// leading "# " markdown heading marker. Falls back to fallback.
func firstHeading(md, fallback string) string {
for _, line := range strings.SplitN(md, "\n", 20) {
line = strings.TrimSpace(line)
if line == "" {
continue
}
return strings.TrimPrefix(line, "# ")
}
return fallback
}
// ─── POST /ui/scrape/book — form submission ───────────────────────────────────
func (s *Server) handleUIScrapeBook(w http.ResponseWriter, r *http.Request) {
bookURL := strings.TrimSpace(r.FormValue("url"))
if bookURL == "" {
renderFragment(w, scrapeStatusHTML("error", "Please enter a book URL."))
return
}
s.mu.Lock()
already := s.running
if !already {
s.running = true
}
s.mu.Unlock()
if already {
renderFragment(w, scrapeStatusHTML("busy", "A scrape job is already running. Please wait."))
return
}
cfg := s.oCfg
cfg.SingleBookURL = bookURL
go func() {
defer func() {
s.mu.Lock()
s.running = false
s.mu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour)
defer cancel()
o := orchestrator.New(cfg, s.novel, s.log)
if err := o.Run(ctx); err != nil {
s.log.Error("UI scrape job failed", "url", bookURL, "err", err)
}
}()
// Return a status badge that polls until the job finishes.
renderFragment(w, scrapeStatusHTML("running", "Scraping "+bookURL+"…"))
}
// ─── GET /ui/scrape/status — polling endpoint ─────────────────────────────────
func (s *Server) handleUIScrapeStatus(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
running := s.running
s.mu.Unlock()
if running {
// Keep polling every 3 s while the job is in progress.
renderFragment(w, scrapeStatusHTML("running", "Scraping in progress…"))
return
}
// Job finished — show a done badge and stop polling.
renderFragment(w, scrapeStatusHTML("done", "Done! Refresh the page to see new books."))
}
// scrapeStatusHTML returns a self-contained status badge fragment.
// state is one of: "running" | "done" | "busy" | "error".
func scrapeStatusHTML(state, msg string) string {
var colour, dot, poll string
switch state {
case "running":
colour = "text-amber-300 bg-amber-950 border-amber-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse mr-2"></span>`
poll = `hx-get="/ui/scrape/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"`
case "done":
colour = "text-green-300 bg-green-950 border-green-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-green-400 mr-2"></span>`
case "busy":
colour = "text-yellow-300 bg-yellow-950 border-yellow-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-yellow-400 mr-2"></span>`
default: // error
colour = "text-red-300 bg-red-950 border-red-800"
dot = `<span class="inline-block w-2 h-2 rounded-full bg-red-400 mr-2"></span>`
}
return fmt.Sprintf(
`<div class="flex items-center text-sm px-3 py-2 rounded-lg border %s" %s>%s%s</div>`,
colour, poll, dot, template.HTMLEscapeString(msg),
)
}