Upload a recording (audio, or video with the camera off), get a private Swedish transcript, and chat about it. - ADR-033: RLS-scoped private transcript store; ffprobe/ffmpeg intake; gateway transcription (iguana kb-whisper, berget fallback); in-process single-replica worker; chat over the transcript (local models only). - ADR-034: optional two-engine mode (pianissimo + kb-whisper) reconciled by a LOCAL model; off unless TAPIR_RECONCILE_BASE_MODEL is set. - ADR-035: real progress (phases, running/quiet/lost), phase logging, every judged spot stored, merged segments. - Hardening from four adversarial reviews: errors logged and stored as kinds never text; per-engine deadlines; converted-length check; bounded queue; client hang-ups are not errors; strict local-alias check. - Dockerfile: static ffmpeg/ffprobe 7.0.2 pinned by sha256 (#34). Built and inspected by hand; CI does not build the image on a PR, so its first CI build is this merge. Uploads stay OFF until TAPIR_UPLOADS_ENABLED is set in the deployment. Closes #33, #34, #42, #43, #44. Co-authored-by: Mathias <mthbqv@gmail.com>
162 lines
5.8 KiB
Go
162 lines
5.8 KiB
Go
package uploads
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"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, 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
|
|
// 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
|
|
}
|
|
|
|
// 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, 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}
|
|
}
|
|
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, probe.HasVideo)
|
|
if err != nil {
|
|
return "", fmt.Errorf("uploads: create: %w", err)
|
|
}
|
|
if err := in.sub.Submit(Job{UserID: userID, UploadID: id, Path: path, 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
|
|
}
|