From 9b3175db0979816f4c9f11dfe2f9ed9ce880d766 Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 1 Oct 2026 16:56:21 +0200 Subject: [PATCH] feat(cmd): buildUploads -- spool, sweep, fail interrupted rows, wire the chain (ADR-033) Returns nils when disabled (never a typed-nil Intake). When enabled: private 0700 spool dir, delete audio left by a crash, fail rows a restart left queued or transcribing (the audio is not retained, so they cannot resume), then build the iguana->berget chain, worker and intake. An empty TAPIR_TRANSCRIBE_FALLBACK_MODEL keeps private audio on the homelab. Enabling with no gateway fails at start-up, not at the first upload. Co-Authored-By: Claude Sonnet 5.5 --- cmd/tapir/uploads.go | 85 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 cmd/tapir/uploads.go diff --git a/cmd/tapir/uploads.go b/cmd/tapir/uploads.go new file mode 100644 index 0000000..ebe5339 --- /dev/null +++ b/cmd/tapir/uploads.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + + "git.d-ma.be/mathias/tapir/internal/adapters/audio" + "git.d-ma.be/mathias/tapir/internal/adapters/transcribe" + "git.d-ma.be/mathias/tapir/internal/config" + "git.d-ma.be/mathias/tapir/internal/uploads" +) + +// uploadsStore is the store surface the upload wiring needs. *store.Store +// satisfies it. +type uploadsStore interface { + uploads.Store + uploads.WorkerStore + FailInterruptedUploads(ctx context.Context) (int, error) +} + +// buildUploads wires audio upload and private transcription (ADR-033). It returns +// nils when uploads are disabled — never a typed-nil Intake, which would mount the +// /uploads routes over nothing. When enabled it, in order: prepares a private spool +// directory, deletes any audio a crashed run left in it, fails the rows such a run +// left queued or transcribing (the audio is not retained, so they can never +// resume), and only then builds the intake and the worker. The caller runs the +// worker. The fallback endpoint is a cloud service; an empty +// TAPIR_TRANSCRIBE_FALLBACK_MODEL keeps private audio on the homelab. +func buildUploads(ctx context.Context, cfg config.Config, st uploadsStore, log *slog.Logger) (*uploads.Intake, *uploads.Worker, error) { + if !cfg.UploadsEnabled { + return nil, nil, nil + } + if cfg.GatewayURL == "" { + return nil, nil, fmt.Errorf("uploads: TAPIR_UPLOADS_ENABLED is set but there is no TAPIR_GATEWAY_URL to transcribe through") + } + + if err := os.MkdirAll(cfg.UploadTmpDir, 0o700); err != nil { + return nil, nil, fmt.Errorf("uploads: spool dir: %w", err) + } + removeSpooledAudio(cfg.UploadTmpDir, log) + + n, err := st.FailInterruptedUploads(ctx) + if err != nil { + return nil, nil, fmt.Errorf("uploads: fail interrupted rows: %w", err) + } + if n > 0 { + log.Warn("uploads: marked interrupted by a restart (audio is not retained; they must be re-uploaded)", "count", n) + } + + // No overall client timeout: a 90 minute recording takes minutes. The deadline + // is the worker's per-job context. + client := &http.Client{} + primary := transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.TranscribeModel, client) + var fallback transcribe.Backend + if cfg.TranscribeFallbackModel != "" && cfg.TranscribeFallbackModel != cfg.TranscribeModel { + fallback = transcribe.NewGateway(cfg.GatewayURL, cfg.GatewayKey, cfg.TranscribeFallbackModel, client) + } + + worker := uploads.NewWorker(st, &audio.FFmpeg{Bin: cfg.FFmpegBin}, transcribe.NewChain(primary, fallback), + cfg.UploadTmpDir, cfg.TranscribeTimeout) + intake := uploads.NewIntake(st, &audio.FFprobe{Bin: cfg.FFprobeBin}, worker, + uploadLimits(cfg), cfg.UploadTmpDir) + return intake, worker, nil +} + +func uploadLimits(cfg config.Config) uploads.Limits { + return uploads.Limits{MaxBytes: cfg.UploadMaxBytes, MaxDuration: cfg.UploadMaxDuration} +} + +// removeSpooledAudio deletes the spool files this process family creates +// (upload-*, wav-*) and nothing else. +func removeSpooledAudio(dir string, log *slog.Logger) { + for _, pat := range []string{"upload-*", "wav-*"} { + matches, _ := filepath.Glob(filepath.Join(dir, pat)) + for _, m := range matches { + if err := os.Remove(m); err != nil { + log.Warn("uploads: remove leftover audio", "path", m, "err", err) + } + } + } +}