Add chunked TTS audio generation and ranking search autocomplete on home page
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

- Split chapter TTS into up to 10 paragraph-aligned parts; part 0 is
  generated synchronously so playback starts immediately, parts 1-9 and
  the final merge happen in a background goroutine
- New routes: GET /ui/audio/{slug}/{n}/status (merge poll) and
  GET /ui/audio-file/{slug}/{n}/part/{p} (serve individual part)
- JS polls status every 3 s and seamlessly swaps audio.src to the merged
  file once ready, preserving playback position proportionally
- Home page scrape form replaced with a ranking-search autocomplete:
  type a title/author to see matching ranking items (cover + metadata),
  click or keyboard-select to inject the source URL, or paste a raw URL
  directly; ranking data is embedded as JSON at page render time
This commit is contained in:
Admin
2026-03-01 21:03:18 +05:00
parent 81e5d015b4
commit 26a46a4d31
3 changed files with 531 additions and 72 deletions

View File

@@ -417,15 +417,29 @@ func (w *Writer) AudioDir(slug string) string {
// The filename is keyed by chapter number, voice, and speed so that different
// settings never collide. Speed is formatted to one decimal place (e.g. "1.0").
func (w *Writer) AudioPath(slug string, n int, voice string, speed float64) string {
// Sanitise voice so it is safe as a filename component.
safeVoice := strings.Map(func(r rune) rune {
safeVoice := sanitiseVoice(voice)
filename := fmt.Sprintf("ch%d-%s-%.1f.mp3", n, safeVoice, speed)
return filepath.Join(w.AudioDir(slug), filename)
}
// AudioPartPath returns the path for an individual audio chunk generated during
// chunked TTS. Part files are named ch{n}-{voice}-{speed}.part{p}.mp3 and are
// deleted after they have been merged into the final AudioPath file.
func (w *Writer) AudioPartPath(slug string, n int, voice string, speed float64, part int) string {
safeVoice := sanitiseVoice(voice)
filename := fmt.Sprintf("ch%d-%s-%.1f.part%d.mp3", n, safeVoice, speed, part)
return filepath.Join(w.AudioDir(slug), filename)
}
// sanitiseVoice converts a voice name into a string that is safe to embed in a
// filename (only a-z, A-Z, 0-9, '_', '-' are kept; everything else becomes '_').
func sanitiseVoice(voice string) string {
return strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
return r
}
return '_'
}, voice)
filename := fmt.Sprintf("ch%d-%s-%.1f.mp3", n, safeVoice, speed)
return filepath.Join(w.AudioDir(slug), filename)
}
// chapterPath computes the full file path for a chapter.