Files
tapir/internal/uploads/intake.go
T
mathiasandClaude Sonnet 5.5 0338b18a17 feat(uploads): choose the language of a recording; record it, show it, carry it to the engines (#61)
Form field (Swedish preselected, Other = auto) read before the file part and refused
before anything is spooled if outside the closed set; stored on the row (migration 021,
CHECK constraint, default sv for history); carried on the Job and the worker's context;
shown on the status page. Feature scenarios and the coverage map are updated, and
ADR-033/034 amended.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 22:29:58 +02:00

218 lines
7.4 KiB
Go

package uploads
import (
"context"
"errors"
"fmt"
"io"
"os"
"sync"
"time"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// ErrUnreadable is what a Prober returns when the FILE is the problem (not media,
// truncated, damaged), as opposed to the prober itself failing. Accept turns it
// into a user-facing rejection; any other prober error is our fault and surfaces
// as an internal error — a missing ffprobe must not read as "your file is bad".
var ErrUnreadable = errors.New("uploads: file is not readable media")
// Prober inspects a file on disk. The ffprobe adapter implements it.
type Prober interface {
Probe(ctx context.Context, path string) (Probe, error)
}
// Store is the slice of the private transcript store the intake needs.
type Store interface {
CreateUpload(ctx context.Context, userID, title string, lang domain.Language, hadVideo bool) (string, error)
FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error
}
// Job is one accepted upload awaiting transcription. Path is a temp file the
// receiver owns: it must delete it when finished (the audio is never retained).
type Job struct {
UserID string
UploadID string
Path string
// Language is what the user chose for this recording; empty means Swedish (a job
// queued before the choice existed).
Language domain.Language
// Duration is what the file CLAIMED when probed. The worker compares it with what
// was really converted; zero means unknown (ADR-035 review).
Duration time.Duration
}
// Submitter hands accepted jobs to the in-process worker (ADR-033).
type Submitter interface {
Submit(Job) error
}
// Intake validates an uploaded recording and, if it passes, queues it.
type Intake struct {
store Store
prober Prober
sub Submitter
limits Limits
tmpDir string
// MaxReceives and MaxReceivesPerUser bound how many request bodies Accept reads at
// once; zero means the defaults. Without them N parallel POSTs each spool up to
// MaxBytes while the queue still looks empty, and the spool volume fills (#47).
MaxReceives int
MaxReceivesPerUser int
mu sync.Mutex
receives int
byUser map[string]int
}
const (
defaultMaxReceives = 4
defaultMaxReceivesPerUser = 2
)
// acquireReceive reserves a receive slot for userID, or reports there is none. Every
// successful call must be paired with releaseReceive.
func (in *Intake) acquireReceive(userID string) bool {
maxAll, maxUser := in.MaxReceives, in.MaxReceivesPerUser
if maxAll <= 0 {
maxAll = defaultMaxReceives
}
if maxUser <= 0 {
maxUser = defaultMaxReceivesPerUser
}
in.mu.Lock()
defer in.mu.Unlock()
if in.receives >= maxAll || in.byUser[userID] >= maxUser {
return false
}
if in.byUser == nil {
in.byUser = map[string]int{}
}
in.receives++
in.byUser[userID]++
return true
}
func (in *Intake) releaseReceive(userID string) {
in.mu.Lock()
defer in.mu.Unlock()
in.receives--
if in.byUser[userID]--; in.byUser[userID] <= 0 {
delete(in.byUser, userID)
}
}
// NewIntake wires an Intake. tmpDir is where uploads are spooled; it must exist.
func NewIntake(s Store, p Prober, sub Submitter, l Limits, tmpDir string) *Intake {
return &Intake{store: s, prober: p, sub: sub, limits: l, tmpDir: tmpDir}
}
// ErrClientRead marks an error that came from READING the client's body: a connection that
// dropped, a body cut short. It is the sender's to retry, unlike a spool, probe or database
// fault, which is ours. It is a marker on the error, not a sentinel that others may wrap:
// io.ErrUnexpectedEOF alone is also what a database drop looks like.
var ErrClientRead = errors.New("uploads: client body read failed")
// clientReader remembers the last non-EOF error its source returned, so Accept can tell
// a failure reading the client from a failure writing the spool file.
type clientReader struct {
r io.Reader
err error
}
func (c *clientReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
if err != nil && !errors.Is(err, io.EOF) {
c.err = err
}
return n, err
}
// busyMessage is what a user is told when the queue is full.
const busyMessage = "Too many recordings are waiting to be transcribed. Please try again in a few minutes."
// Accept spools src to disk (bounded by MaxBytes), probes it, validates it, records
// a queued upload and submits the job. It returns the upload id. A file that fails
// validation yields a *RejectError and creates NO row — rejection is synchronous
// and leaves nothing behind. On success the temp file is handed to the job and the
// worker deletes it; on every failure path Accept deletes it itself.
func (in *Intake) Accept(ctx context.Context, userID, title string, lang domain.Language, src io.Reader) (string, error) {
// Refuse before spooling when the queue has no room, if the submitter can say so: a full
// queue otherwise cost up to the upload cap on disk, an ffprobe run and a failed row for
// every refused attempt.
if rc, ok := in.sub.(interface{ HasRoom(userID string) bool }); ok && !rc.HasRoom(userID) {
return "", &RejectError{Reason: ReasonBusy, Message: busyMessage}
}
// HasRoom cannot see bodies still being received; bound those separately.
if !in.acquireReceive(userID) {
return "", &RejectError{Reason: ReasonBusy, Message: busyMessage}
}
defer in.releaseReceive(userID)
f, err := os.CreateTemp(in.tmpDir, "upload-*")
if err != nil {
return "", fmt.Errorf("uploads: spool: %w", err)
}
path := f.Name()
handedOver := false
defer func() {
if !handedOver {
_ = os.Remove(path)
}
}()
// Read one byte past the cap so "exactly at the limit" and "over" are told apart.
cr := &clientReader{r: io.LimitReader(src, in.limits.MaxBytes+1)}
n, err := io.Copy(f, cr)
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
if cr.err != nil && errors.Is(err, cr.err) {
return "", fmt.Errorf("%w: %w", ErrClientRead, err)
}
return "", fmt.Errorf("uploads: receive: %w", err)
}
if n > in.limits.MaxBytes {
return "", &RejectError{Reason: ReasonTooLarge, Message: fmt.Sprintf(
"This file is larger than the %d MB limit.", in.limits.MaxBytes>>20)}
}
probe, err := in.prober.Probe(ctx, path)
if errors.Is(err, ErrUnreadable) {
return "", &RejectError{
Reason: ReasonUnreadable,
Message: "This file could not be read as audio; it may be damaged or not a recording.",
}
}
if err != nil {
return "", fmt.Errorf("uploads: probe: %w", err)
}
if err := Validate(probe, in.limits); err != nil {
return "", err
}
id, err := in.store.CreateUpload(ctx, userID, title, lang, probe.HasVideo)
if err != nil {
return "", fmt.Errorf("uploads: create: %w", err)
}
if err := in.sub.Submit(Job{UserID: userID, UploadID: id, Path: path, Language: lang, Duration: probe.Duration}); err != nil {
if errors.Is(err, ErrQueueFull) {
// The user's to retry, not a server fault: refuse it as such, and do not leave
// the row queued -- that is the stuck spinner.
if ferr := in.store.FailUpload(ctx, userID, id, domain.UploadErrBusy); ferr != nil {
return "", fmt.Errorf("uploads: submit: %w (and failing the row: %v)", err, ferr)
}
return "", &RejectError{Reason: ReasonBusy, Message: busyMessage}
}
// A row that will never run must not sit queued: that is the stuck spinner.
if ferr := in.store.FailUpload(ctx, userID, id, domain.UploadErrInterrupted); ferr != nil {
return "", fmt.Errorf("uploads: submit: %w (and failing the row: %v)", err, ferr)
}
return "", fmt.Errorf("uploads: submit: %w", err)
}
handedOver = true
return id, nil
}