Resumable /api/uploads flow (init -> append -> complete -> abort) so a large recording uploads as many small slices (<= ASR_CHUNK_SIZE_MB, default 50MB) instead of one request a size-capping proxy (Cloudflare's 100MB) would reject. The web console auto-routes files larger than one slice through the chunked path with cross-slice progress and transient-error retry; smaller files keep the single-shot upload. - store: in-memory upload sessions + .part reassembly (offset writes, out-of-order/idempotent), reuse upload id as task id, orphan sweep on start, idle-session GC - fix limitedReader rejecting bodies exactly at the cap (broke full chunks) - fix drag-drop double-firing when a drop lands on the dropzone (its drop handler now stopPropagation so the window listener doesn't re-upload) - tests: store + HTTP flow, plus a 120MB reassembly test (ASR_TEST_LARGE)
204 lines
6.4 KiB
Go
204 lines
6.4 KiB
Go
// Package server implements the HTTP API: password auth, the task endpoints
|
|
// (upload / list / get / delete / retry / skip), and the background worker
|
|
// that drains the queue with automatic retry-on-error.
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.misaka.ren/m1saka/record-asr/internal/config"
|
|
"git.misaka.ren/m1saka/record-asr/internal/models"
|
|
"git.misaka.ren/m1saka/record-asr/internal/store"
|
|
"git.misaka.ren/m1saka/record-asr/internal/transcribe"
|
|
)
|
|
|
|
// Server wires the store, transcribe pipeline, and model manager behind an
|
|
// authenticated HTTP API.
|
|
type Server struct {
|
|
cfg config.Config
|
|
store *store.Store
|
|
pipe *transcribe.Pipeline
|
|
manager *models.Manager
|
|
// concSem caps simultaneously in-flight synchronous transcriptions on
|
|
// the OpenAI endpoint, preventing onnxruntime thread contention / OOM
|
|
// under bursts. The background workers are NOT gated by this semaphore;
|
|
// they are bounded separately by ASR_WORKERS, so the task queue is
|
|
// never starved by a flood of OpenAI requests.
|
|
concSem chan struct{}
|
|
}
|
|
|
|
// New assembles a Server from its dependencies.
|
|
func New(cfg config.Config, st *store.Store, pipe *transcribe.Pipeline, mgr *models.Manager) *Server {
|
|
return &Server{
|
|
cfg: cfg,
|
|
store: st,
|
|
pipe: pipe,
|
|
manager: mgr,
|
|
concSem: make(chan struct{}, cfg.MaxConcurrent),
|
|
}
|
|
}
|
|
|
|
// Router builds the HTTP mux. All routes are registered with explicit
|
|
// method + path patterns on one mux (Go 1.22+). Auth is then applied as an
|
|
// outer middleware that only enforces the password for /api/* paths, leaving
|
|
// /healthz and / public so monitoring and the landing page work token-free.
|
|
func (s *Server) Router() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
// Public endpoints: the web console (UI calls the authed /api/* routes
|
|
// itself) and the health check.
|
|
mux.HandleFunc("GET /", s.handleUI)
|
|
mux.HandleFunc("GET /healthz", s.handleHealthz)
|
|
|
|
// Task API (guarded by the outer authMiddleware below).
|
|
mux.HandleFunc("POST /api/tasks", s.handleCreateTask)
|
|
mux.HandleFunc("GET /api/tasks", s.handleListTasks)
|
|
mux.HandleFunc("GET /api/tasks/{id}", s.handleGetTask)
|
|
mux.HandleFunc("DELETE /api/tasks/{id}", s.handleDeleteTask)
|
|
mux.HandleFunc("POST /api/tasks/{id}/retry", s.handleRetryTask)
|
|
mux.HandleFunc("POST /api/tasks/{id}/skip", s.handleSkipTask)
|
|
mux.HandleFunc("GET /api/tasks/{id}/transcript", s.handleTranscript)
|
|
|
|
// Resumable chunked uploads (also authed). Lets a large file slip past
|
|
// size-capping proxies (e.g. Cloudflare's 100MB body limit) as many
|
|
// small slices, finalized into a task indistinguishable from /api/tasks.
|
|
mux.HandleFunc("POST /api/uploads", s.handleCreateUpload)
|
|
mux.HandleFunc("POST /api/uploads/{id}", s.handleUploadChunk)
|
|
mux.HandleFunc("POST /api/uploads/{id}/complete", s.handleCompleteUpload)
|
|
mux.HandleFunc("DELETE /api/uploads/{id}", s.handleAbortUpload)
|
|
|
|
// OpenAI-compatible synchronous transcription (also authed).
|
|
mux.HandleFunc("POST /v1/audio/transcriptions", s.handleOpenAITranscription)
|
|
|
|
return s.authMiddleware(mux)
|
|
}
|
|
|
|
// Run starts the background workers and serves HTTP until ctx is cancelled.
|
|
func (s *Server) Run(ctx context.Context) error {
|
|
if s.cfg.AuthEnabled() {
|
|
log.Printf("auth: enabled (password set)")
|
|
} else {
|
|
log.Printf("WARNING: ASR_PASSWORD not set — API is open; set ASR_PASSWORD to require a password")
|
|
}
|
|
log.Printf("concurrency: max %d in-flight sync transcriptions (OpenAI endpoint)", s.cfg.MaxConcurrent)
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < s.cfg.Workers; i++ {
|
|
wg.Add(1)
|
|
go func(n int) {
|
|
defer wg.Done()
|
|
s.runWorker(ctx, n)
|
|
}(i)
|
|
}
|
|
|
|
srv := &http.Server{
|
|
Addr: s.cfg.Addr,
|
|
Handler: s.Router(),
|
|
ReadHeaderTimeout: 15 * time.Second,
|
|
// Reading a large upload can take a while; bound it generously.
|
|
ReadTimeout: 10 * time.Minute,
|
|
WriteTimeout: 15 * time.Minute,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
log.Printf("listening on %s", s.cfg.Addr)
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
errCh <- err
|
|
return
|
|
}
|
|
errCh <- nil
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
case err := <-errCh:
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
log.Printf("http shutdown: %v", err)
|
|
}
|
|
wg.Wait()
|
|
return nil
|
|
}
|
|
|
|
// runWorker is the queue drain loop. It claims one runnable task at a time,
|
|
// transcribes it, and on error requeues it (with backoff, up to MaxRetries)
|
|
// or marks it failed — then immediately looks for the next task. This is the
|
|
// automatic "retry-or-continue" behavior.
|
|
func (s *Server) runWorker(ctx context.Context, n int) {
|
|
log.Printf("worker %d started", n)
|
|
ticker := time.NewTicker(2 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Printf("worker %d stopping", n)
|
|
return
|
|
default:
|
|
}
|
|
t := s.store.Claim(time.Now())
|
|
if t == nil {
|
|
// Nothing ready; wait briefly so we don't spin.
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
continue
|
|
}
|
|
s.processTask(ctx, t)
|
|
}
|
|
}
|
|
|
|
// processTask runs the pipeline for one claimed task and records the outcome.
|
|
func (s *Server) processTask(ctx context.Context, t *store.Task) {
|
|
start := time.Now()
|
|
audioPath := s.store.AudioPath(t)
|
|
log.Printf("[%s] start (attempt %d, %s)", t.ID, t.Attempts, t.OriginalName)
|
|
|
|
res, err := s.pipe.Transcribe(audioPath)
|
|
if err != nil {
|
|
log.Printf("[%s] failed: %v", t.ID, err)
|
|
if ferr := s.store.Fail(t.ID, err.Error()); ferr != nil {
|
|
log.Printf("[%s] persist failure: %v", t.ID, ferr)
|
|
}
|
|
// Whether retried or marked failed, the worker continues to the next.
|
|
return
|
|
}
|
|
if perr := s.store.Complete(t.ID, res); perr != nil {
|
|
log.Printf("[%s] persist complete: %v", t.ID, perr)
|
|
return
|
|
}
|
|
log.Printf("[%s] done in %.1fs (%d segments, lang=%s)", t.ID,
|
|
time.Since(start).Seconds(), len(res.Segments), res.Lang)
|
|
}
|
|
|
|
// manager access for handlers (read-only status).
|
|
func (s *Server) modelVersion() string {
|
|
if s.manager == nil {
|
|
return "n/a"
|
|
}
|
|
return s.manager.VersionInfo()
|
|
}
|
|
|
|
func (s *Server) dataDir() string { return s.cfg.DataDir }
|
|
|
|
// renderError writes a small JSON error object with the given status.
|
|
func renderError(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
fmt.Fprintf(w, `{"error":%q}`, msg)
|
|
}
|