- 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
1332 lines
47 KiB
Go
1332 lines
47 KiB
Go
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">↻</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">▶</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 = '▮▮';
|
||
label.textContent = 'Pause';
|
||
status.textContent = 'Playing';
|
||
btn.disabled = false;
|
||
btn.classList.remove('opacity-60', 'cursor-not-allowed');
|
||
}
|
||
|
||
function setPaused() {
|
||
icon.innerHTML = '▶';
|
||
label.textContent = 'Resume';
|
||
status.textContent = 'Paused';
|
||
}
|
||
|
||
function setStopped() {
|
||
highlightPara(-1);
|
||
icon.innerHTML = '▶';
|
||
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 = '▶';
|
||
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),
|
||
)
|
||
}
|