Settings are loaded on page init and saved on every change so they carry over across chapter navigation and page reloads.
1366 lines
48 KiB
Go
1366 lines
48 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
|
||
|
||
// ── localStorage persistence ─────────────────────────────────────────────────
|
||
// Settings saved: tts_speed, tts_voice, tts_autonext
|
||
// Loaded once on page init; written on every change.
|
||
|
||
var LS_SPEED = 'tts_speed';
|
||
var LS_VOICE = 'tts_voice';
|
||
var LS_AUTONEXT = 'tts_autonext';
|
||
|
||
(function loadSettings() {
|
||
var savedSpeed = localStorage.getItem(LS_SPEED);
|
||
if (savedSpeed !== null) {
|
||
speedSlider.value = savedSpeed;
|
||
speedLabel.textContent = parseFloat(savedSpeed).toFixed(1) + '\u00D7';
|
||
}
|
||
var savedVoice = localStorage.getItem(LS_VOICE);
|
||
if (savedVoice !== null) {
|
||
var opt = voiceSel.querySelector('option[value="' + savedVoice + '"]');
|
||
if (opt) voiceSel.value = savedVoice;
|
||
}
|
||
var savedAutoNext = localStorage.getItem(LS_AUTONEXT);
|
||
if (savedAutoNext !== null) {
|
||
autoplayChk.checked = savedAutoNext === 'true';
|
||
}
|
||
})();
|
||
|
||
// ── speed slider ─────────────────────────────────────────────────────────────
|
||
|
||
speedSlider.addEventListener('input', function () {
|
||
speedLabel.textContent = parseFloat(speedSlider.value).toFixed(1) + '\u00D7';
|
||
localStorage.setItem(LS_SPEED, speedSlider.value);
|
||
});
|
||
|
||
voiceSel.addEventListener('change', function () {
|
||
localStorage.setItem(LS_VOICE, voiceSel.value);
|
||
});
|
||
|
||
autoplayChk.addEventListener('change', function () {
|
||
localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false');
|
||
});
|
||
|
||
// ── 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),
|
||
)
|
||
}
|