Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a5bee7c09 | ||
|
|
09e90d2ab0 | ||
|
|
f28de2195d | ||
|
|
9388a355e5 | ||
|
|
1c547519e8 | ||
|
|
1536c7dad1 |
@@ -33,9 +33,7 @@ jobs:
|
||||
run: go vet ./...
|
||||
|
||||
- name: staticcheck
|
||||
run: |
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
staticcheck ./...
|
||||
run: go tool staticcheck ./...
|
||||
|
||||
# ── tests ────────────────────────────────────────────────────────────────────
|
||||
test:
|
||||
|
||||
@@ -3,8 +3,16 @@ module github.com/libnovel/scraper
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
honnef.co/go/tools v0.7.0 // indirect
|
||||
)
|
||||
|
||||
tool honnef.co/go/tools/cmd/staticcheck
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
|
||||
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
|
||||
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U=
|
||||
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
|
||||
@@ -134,6 +134,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
`{"error":"audio generation timed out"}`,
|
||||
)
|
||||
mux.Handle("POST /ui/audio/{slug}/{n}", audioGenHandler)
|
||||
// Status route: returns the proxy URL if audio was already generated, 404 otherwise.
|
||||
mux.HandleFunc("GET /ui/audio/{slug}/{n}", s.handleAudioStatus)
|
||||
// Proxy route: fetches the generated file from Kokoro /v1/download/{filename}.
|
||||
mux.HandleFunc("GET /ui/audio-proxy/{slug}/{n}", s.handleAudioProxy)
|
||||
|
||||
@@ -355,6 +357,39 @@ func (s *Server) writeAudioResponse(w http.ResponseWriter, slug string, n int, v
|
||||
})
|
||||
}
|
||||
|
||||
// handleAudioStatus handles GET /ui/audio/{slug}/{n}.
|
||||
// Returns the proxy URL if audio was already generated this session, 404 otherwise.
|
||||
func (s *Server) handleAudioStatus(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 {
|
||||
http.Error(w, `{"error":"invalid chapter"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
voice := r.URL.Query().Get("voice")
|
||||
if voice == "" {
|
||||
voice = s.kokoroVoice
|
||||
}
|
||||
speedStr := r.URL.Query().Get("speed")
|
||||
speed := 1.0
|
||||
if speedStr != "" {
|
||||
if v, err := strconv.ParseFloat(speedStr, 64); err == nil && v > 0 {
|
||||
speed = v
|
||||
}
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s/%.2f", slug, n, voice, speed)
|
||||
s.audioMu.Lock()
|
||||
filename, ok := s.audioCache[cacheKey]
|
||||
s.audioMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"not generated"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.writeAudioResponse(w, slug, n, voice, speed, filename)
|
||||
}
|
||||
|
||||
// handleAudioProxy handles GET /ui/audio-proxy/{slug}/{n}.
|
||||
// It looks up the Kokoro download filename for this chapter (voice/speed) and
|
||||
// proxies GET /v1/download/{filename} from the Kokoro server back to the browser.
|
||||
|
||||
@@ -2704,6 +2704,7 @@ const chapterTmpl = `
|
||||
b.classList.toggle('text-zinc-300', !active);
|
||||
});
|
||||
try { localStorage.setItem(LS_SPEED, btn.dataset.speed); } catch(_) {}
|
||||
if (audio.src) audio.playbackRate = parseFloat(btn.dataset.speed);
|
||||
}
|
||||
window.selectSpeed = selectSpeed;
|
||||
(function loadSettings() {
|
||||
@@ -2841,7 +2842,7 @@ const chapterTmpl = `
|
||||
fetch('/ui/audio/' + SLUG + '/' + chapterN, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ voice: voiceSel.value, speed: getSpeed() }),
|
||||
body: JSON.stringify({ voice: voiceSel.value, speed: 1 }),
|
||||
signal: ctrl.signal
|
||||
})
|
||||
.then(function (res) {
|
||||
@@ -2871,7 +2872,7 @@ const chapterTmpl = `
|
||||
if (!activePara || activePara !== paras[idx]) highlightPara(idx);
|
||||
}
|
||||
|
||||
function onCanPlay() { if (stale()) { audio.removeEventListener('canplay', onCanPlay); return; } if (audio.paused) audio.play().then(setPlaying).catch(function(e){ setError(e.message); }); }
|
||||
function onCanPlay() { if (stale()) { audio.removeEventListener('canplay', onCanPlay); return; } audio.autoplay = false; if (audio.paused) audio.play().then(setPlaying).catch(function(e){ if (e.name === 'NotAllowedError') setPaused(); else setError(e.message); }); }
|
||||
function onWaiting() { if (!stale()) setStatus('Buffering\u2026'); }
|
||||
function onPlaying() { if (!stale()) setPlaying(); }
|
||||
function onPause() { if (!stale() && !audio.ended) setPaused(); }
|
||||
@@ -2921,13 +2922,42 @@ const chapterTmpl = `
|
||||
navigator.mediaSession.setActionHandler('seekto', function(d) { if (isFinite(d.seekTime)) { audio.currentTime = d.seekTime; updateSeek(); } });
|
||||
}
|
||||
|
||||
function startAudio() {
|
||||
function loadAndPlay(url) {
|
||||
// Always set autoplay=true before load(): the browser needs this hint set
|
||||
// synchronously before load() regardless of whether we got here via a user
|
||||
// gesture or a programmatic trigger (e.g. chapter transition). The canplay
|
||||
// handler will call play() as well, but autoplay on the element is the only
|
||||
// reliable path after an async fetch where the gesture stack is gone.
|
||||
audio.autoplay = true;
|
||||
audio.src = url; audio.load();
|
||||
audio.playbackRate = getSpeed();
|
||||
registerMediaSession();
|
||||
}
|
||||
|
||||
function startAudio(autoplay) {
|
||||
setGenerating();
|
||||
highlightPara(0);
|
||||
generateAudio(CHAPTER_N, function (url) {
|
||||
// Check if audio was already generated for this chapter this session.
|
||||
// If so, play it directly without re-generating.
|
||||
fetch('/ui/audio/' + SLUG + '/' + CHAPTER_N + '?voice=' + encodeURIComponent(voiceSel.value) + '&speed=1')
|
||||
.then(function(res) {
|
||||
if (stale()) return;
|
||||
audio.src = url; audio.load();
|
||||
registerMediaSession();
|
||||
if (res.ok) {
|
||||
return res.json().then(function(data) {
|
||||
if (stale()) return;
|
||||
if (data && data.url) { loadAndPlay(data.url); return; }
|
||||
throw new Error('no url in response');
|
||||
});
|
||||
}
|
||||
// Not cached yet — fall through to generation.
|
||||
generateAudio(CHAPTER_N, function(url) {
|
||||
if (stale()) return;
|
||||
loadAndPlay(url);
|
||||
});
|
||||
})
|
||||
.catch(function(e) {
|
||||
if (stale()) return;
|
||||
setError(e.message);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2940,7 +2970,15 @@ const chapterTmpl = `
|
||||
startAudio();
|
||||
};
|
||||
|
||||
window.__ttsBeforeSwap = function () { window.__ttsGen++; stop(); };
|
||||
window.__ttsBeforeSwap = function () {
|
||||
// If TTS is active (generating or has audio loaded), persist the autostart
|
||||
// flag so the incoming chapter's init code will restart TTS automatically.
|
||||
var wasActive = currentAudioCtrl !== null || (audio.src && !audio.ended);
|
||||
if (wasActive) {
|
||||
try { localStorage.setItem('tts_autostart', '1'); } catch(_) {}
|
||||
}
|
||||
window.__ttsGen++; stop();
|
||||
};
|
||||
document.body.addEventListener('htmx:beforeSwap', window.__ttsBeforeSwap);
|
||||
|
||||
var _autostart = false;
|
||||
@@ -2948,10 +2986,10 @@ const chapterTmpl = `
|
||||
if (_autostart) {
|
||||
try { localStorage.removeItem('tts_autostart'); } catch(_) {}
|
||||
autoplayChk.checked = true;
|
||||
setTimeout(startAudio, 100);
|
||||
setTimeout(function() { startAudio(true); }, 100);
|
||||
} else if (new URLSearchParams(window.location.search).get('autoplay') === '1') {
|
||||
autoplayChk.checked = true;
|
||||
setTimeout(startAudio, 100);
|
||||
setTimeout(function() { startAudio(true); }, 100);
|
||||
}
|
||||
|
||||
// ── double-tap left/right to navigate ────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user