add full source: transcribe pipeline, HTTP API, OpenAI endpoint, web UI, Docker
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.misaka.ren/m1saka/record-asr/internal/audio"
|
||||
"git.misaka.ren/m1saka/record-asr/internal/store"
|
||||
)
|
||||
|
||||
// writeJSON marshals v as JSON with the given status.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// handleHealthz reports liveness and model readiness (public).
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"models": s.manager != nil && s.manager.Ready(),
|
||||
"version": s.modelVersion(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleRoot returns a tiny service descriptor (public).
|
||||
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"name": "record-asr",
|
||||
"engine": "SenseVoiceSmall (int8) + silero VAD",
|
||||
"version": s.modelVersion(),
|
||||
"endpoints": []string{
|
||||
"POST /api/tasks (multipart: file) -> enqueue",
|
||||
"GET /api/tasks -> list",
|
||||
"GET /api/tasks/{id} -> detail",
|
||||
"DELETE /api/tasks/{id} -> delete",
|
||||
"POST /api/tasks/{id}/retry -> re-run failed task",
|
||||
"POST /api/tasks/{id}/skip -> abandon failed task",
|
||||
"GET /api/tasks/{id}/transcript -> transcript",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreateTask accepts a multipart upload and enqueues a transcription task.
|
||||
func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes)
|
||||
if err := r.ParseMultipartForm(s.cfg.MaxUploadBytes); err != nil {
|
||||
renderError(w, http.StatusRequestEntityTooLarge,
|
||||
"upload rejected: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, hdr, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
renderError(w, http.StatusBadRequest, "missing 'file' multipart field")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
name := hdr.Filename
|
||||
if name == "" {
|
||||
name = "upload.wav"
|
||||
}
|
||||
if !audio.IsSupported(name) {
|
||||
renderError(w, http.StatusUnsupportedMediaType,
|
||||
"unsupported extension; supported: "+audio.SupportedExtensions())
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.store.Create(name, file, s.cfg.MaxUploadBytes, s.cfg.MaxRetries)
|
||||
if err != nil {
|
||||
renderError(w, http.StatusInternalServerError, "store: "+err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/api/tasks/"+t.ID)
|
||||
writeJSON(w, http.StatusAccepted, t)
|
||||
}
|
||||
|
||||
// handleListTasks returns all tasks newest-first.
|
||||
func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": s.store.List()})
|
||||
}
|
||||
|
||||
// handleGetTask returns one task.
|
||||
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
t := s.store.Get(id)
|
||||
if t == nil {
|
||||
renderError(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
// handleDeleteTask removes a task and its audio.
|
||||
func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := s.store.Delete(id); err != nil {
|
||||
if err == store.ErrNotFound {
|
||||
renderError(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
renderError(w, http.StatusInternalServerError, "delete: "+err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRetryTask requeues a failed/skipped task for another attempt.
|
||||
func (s *Server) handleRetryTask(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := s.store.Retry(id); err != nil {
|
||||
if err == store.ErrNotFound {
|
||||
renderError(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
renderError(w, http.StatusInternalServerError, "retry: "+err.Error())
|
||||
return
|
||||
}
|
||||
t := s.store.Get(id)
|
||||
writeJSON(w, http.StatusAccepted, t)
|
||||
}
|
||||
|
||||
// handleSkipTask abandons a task so the queue moves on (the "continue" action).
|
||||
func (s *Server) handleSkipTask(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := s.store.Skip(id); err != nil {
|
||||
if err == store.ErrNotFound {
|
||||
renderError(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
renderError(w, http.StatusInternalServerError, "skip: "+err.Error())
|
||||
return
|
||||
}
|
||||
t := s.store.Get(id)
|
||||
writeJSON(w, http.StatusAccepted, t)
|
||||
}
|
||||
|
||||
// handleTranscript returns just the transcript of a completed task.
|
||||
func (s *Server) handleTranscript(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
t := s.store.Get(id)
|
||||
if t == nil {
|
||||
renderError(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
if t.Status != store.StatusCompleted || t.Result == nil {
|
||||
renderError(w, http.StatusConflict, "transcript not ready (status: "+t.Status+")")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t.Result)
|
||||
}
|
||||
|
||||
// --- auth ---
|
||||
|
||||
// authMiddleware wraps a handler with the password check. Auth is enforced
|
||||
// for paths under /api/ and /v1/ (the OpenAI-compatible endpoint); /healthz
|
||||
// and / stay public. When ASR_PASSWORD is unset, auth is disabled (a warning
|
||||
// was logged at startup).
|
||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if (!strings.HasPrefix(r.URL.Path, "/api/") && !strings.HasPrefix(r.URL.Path, "/v1/")) || !s.cfg.AuthEnabled() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !constantTimeEqual(extractToken(r), s.cfg.Password) {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="record-asr"`)
|
||||
renderError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// extractToken pulls the shared secret from, in order: X-Api-Key header,
|
||||
// Authorization: Bearer <token>, or ?token= query parameter.
|
||||
func extractToken(r *http.Request) string {
|
||||
if v := strings.TrimSpace(r.Header.Get("X-Api-Key")); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := r.Header.Get("Authorization"); v != "" {
|
||||
if strings.HasPrefix(strings.ToLower(v), "bearer ") {
|
||||
return strings.TrimSpace(v[7:])
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(v, "Bearer "))
|
||||
}
|
||||
return strings.TrimSpace(r.URL.Query().Get("token"))
|
||||
}
|
||||
|
||||
// constantTimeEqual compares two strings in constant time.
|
||||
func constantTimeEqual(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
var v byte
|
||||
for i := 0; i < len(a); i++ {
|
||||
v |= a[i] ^ b[i]
|
||||
}
|
||||
return v == 0
|
||||
}
|
||||
Reference in New Issue
Block a user