Files
narratio/internal/adapters/whisperx/http.go

323 lines
8.9 KiB
Go

package whisperx
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
const defaultMaxResponseBytes int64 = 10 * 1024 * 1024
// HTTPClientConfig contains parsed, deterministic WhisperX HTTP client settings.
type HTTPClientConfig struct {
TranscribeURL string
Language string
Timeout time.Duration
Retries int
RetryDelay time.Duration
HTTPClient *http.Client
MaxResponseBytes int64
}
// HTTPClient is a real WhisperX HTTP adapter implementation.
type HTTPClient struct {
url *url.URL
language string
timeout time.Duration
retries int
retryDelay time.Duration
httpClient *http.Client
maxResponseBytes int64
}
// NewHTTPClientFromConfigValues builds a client from config values and parses durations once.
func NewHTTPClientFromConfigValues(transcribeURL, language, timeout, retryDelay string, retries int) (*HTTPClient, error) {
if strings.TrimSpace(timeout) == "" {
return nil, fmt.Errorf("whisperx timeout is required")
}
parsedTimeout, err := time.ParseDuration(timeout)
if err != nil {
return nil, fmt.Errorf("parse whisperx timeout %q: %w", timeout, err)
}
if strings.TrimSpace(retryDelay) == "" {
return nil, fmt.Errorf("whisperx retry_delay is required")
}
parsedRetryDelay, err := time.ParseDuration(retryDelay)
if err != nil {
return nil, fmt.Errorf("parse whisperx retry_delay %q: %w", retryDelay, err)
}
return NewHTTPClient(HTTPClientConfig{
TranscribeURL: transcribeURL,
Language: language,
Timeout: parsedTimeout,
Retries: retries,
RetryDelay: parsedRetryDelay,
})
}
// NewHTTPClient constructs an HTTP WhisperX client with validated settings.
func NewHTTPClient(cfg HTTPClientConfig) (*HTTPClient, error) {
if strings.TrimSpace(cfg.TranscribeURL) == "" {
return nil, fmt.Errorf("whisperx transcribe_url is required")
}
u, err := url.Parse(cfg.TranscribeURL)
if err != nil || u.Scheme == "" || u.Host == "" {
if err != nil {
return nil, fmt.Errorf("invalid whisperx transcribe_url %q: %w", cfg.TranscribeURL, err)
}
return nil, fmt.Errorf("invalid whisperx transcribe_url %q", cfg.TranscribeURL)
}
if cfg.Timeout <= 0 {
return nil, fmt.Errorf("whisperx timeout must be > 0")
}
if cfg.Retries < 0 {
return nil, fmt.Errorf("whisperx retries must be >= 0")
}
if cfg.RetryDelay < 0 {
return nil, fmt.Errorf("whisperx retry_delay must be >= 0")
}
if strings.TrimSpace(cfg.Language) == "" {
return nil, fmt.Errorf("whisperx language is required")
}
client := cfg.HTTPClient
if client == nil {
client = &http.Client{}
}
maxBytes := cfg.MaxResponseBytes
if maxBytes <= 0 {
maxBytes = defaultMaxResponseBytes
}
return &HTTPClient{
url: u,
language: cfg.Language,
timeout: cfg.Timeout,
retries: cfg.Retries,
retryDelay: cfg.RetryDelay,
httpClient: client,
maxResponseBytes: maxBytes,
}, nil
}
// Transcribe submits one audio file to WhisperX and writes validated JSON output atomically.
func (c *HTTPClient) Transcribe(ctx context.Context, req TranscribeRequest) (TranscribeResult, error) {
if c == nil {
return TranscribeResult{}, fmt.Errorf("whisperx http client is nil")
}
if strings.TrimSpace(req.AudioPath) == "" {
return TranscribeResult{}, fmt.Errorf("whisperx transcribe request audio path is required")
}
if strings.TrimSpace(req.OutputRawTranscriptPath) == "" {
return TranscribeResult{}, fmt.Errorf("whisperx transcribe request output path is required")
}
start := time.Now()
result := TranscribeResult{
OutputRawTranscriptPath: req.OutputRawTranscriptPath,
}
attempts := c.retries + 1
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
result.Attempts = attempt
attemptCtx, cancel := context.WithTimeout(ctx, c.timeout)
status, body, err := c.doTranscribeAttempt(attemptCtx, req.AudioPath)
cancel()
if status > 0 {
result.HTTPStatus = status
}
if err == nil {
if err := c.validateJSON(body); err != nil {
result.Duration = time.Since(start)
return result, fmt.Errorf("whisperx attempt %d returned invalid json: %w", attempt, err)
}
if err := writeFileAtomic(req.OutputRawTranscriptPath, body, fileops.WorkspaceFileMode); err != nil {
result.Duration = time.Since(start)
return result, fmt.Errorf("whisperx write transcript output %q: %w", req.OutputRawTranscriptPath, err)
}
result.Duration = time.Since(start)
result.Metadata = map[string]any{
"adapter": "whisperx_http",
}
return result, nil
}
lastErr = err
if !c.shouldRetry(ctx, err, status) || attempt == attempts {
break
}
if c.retryDelay > 0 {
timer := time.NewTimer(c.retryDelay)
select {
case <-ctx.Done():
timer.Stop()
result.Duration = time.Since(start)
return result, fmt.Errorf("whisperx attempt %d aborted during retry delay: %w", attempt, ctx.Err())
case <-timer.C:
}
}
}
result.Duration = time.Since(start)
if lastErr == nil {
lastErr = fmt.Errorf("whisperx transcription failed without explicit error")
}
return result, fmt.Errorf("whisperx transcription failed after %d attempt(s): %w", result.Attempts, lastErr)
}
func (c *HTTPClient) doTranscribeAttempt(ctx context.Context, audioPath string) (int, []byte, error) {
bodyBuf := &bytes.Buffer{}
writer := multipart.NewWriter(bodyBuf)
fileWriter, err := writer.CreateFormFile("file", filepath.Base(audioPath))
if err != nil {
return 0, nil, fmt.Errorf("create multipart file field: %w", err)
}
audioFile, err := os.Open(audioPath)
if err != nil {
return 0, nil, fmt.Errorf("open audio file %q: %w", audioPath, err)
}
if _, err := io.Copy(fileWriter, audioFile); err != nil {
_ = audioFile.Close()
return 0, nil, fmt.Errorf("copy audio file %q: %w", audioPath, err)
}
if err := audioFile.Close(); err != nil {
return 0, nil, fmt.Errorf("close audio file %q: %w", audioPath, err)
}
if err := writer.WriteField("language", c.language); err != nil {
return 0, nil, fmt.Errorf("write language form field: %w", err)
}
if err := writer.Close(); err != nil {
return 0, nil, fmt.Errorf("close multipart writer: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url.String(), bodyBuf)
if err != nil {
return 0, nil, fmt.Errorf("build whisperx request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, nil, fmt.Errorf("perform whisperx request: %w", err)
}
defer resp.Body.Close()
data, err := readBounded(resp.Body, c.maxResponseBytes)
if err != nil {
return resp.StatusCode, nil, fmt.Errorf("read whisperx response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return resp.StatusCode, nil, fmt.Errorf("whisperx returned status %d", resp.StatusCode)
}
return resp.StatusCode, data, nil
}
func (c *HTTPClient) validateJSON(data []byte) error {
var v any
if err := json.Unmarshal(data, &v); err != nil {
return err
}
return nil
}
func (c *HTTPClient) shouldRetry(parent context.Context, err error, status int) bool {
if parent.Err() != nil {
return false
}
if status == http.StatusTooManyRequests || status >= 500 {
return true
}
if status >= 400 && status < 500 {
return false
}
if errors.Is(err, context.Canceled) {
return false
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
var nerr net.Error
if errors.As(err, &nerr) {
return true
}
return false
}
func readBounded(r io.Reader, maxBytes int64) ([]byte, error) {
limited := io.LimitReader(r, maxBytes+1)
data, err := io.ReadAll(limited)
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, fmt.Errorf("response exceeds max size %d bytes", maxBytes)
}
return data, nil
}
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("path is required")
}
dir := filepath.Dir(path)
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
return fmt.Errorf("create parent dir %q: %w", dir, err)
}
base := filepath.Base(path)
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmp.Name()
removeTmp := true
defer func() {
if removeTmp {
_ = os.Remove(tmpPath)
}
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("sync temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Chmod(tmpPath, perm); err != nil {
return fmt.Errorf("chmod temp file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("rename temp file: %w", err)
}
removeTmp = false
return nil
}