// Package uploads is the use case behind "upload an audio recording" (ADR-033): // validate the file by PROBING it, keep the transcript private, never retain the // audio. It depends only on ports (a Prober, a store, a job Submitter) — the // concrete ffprobe adapter, the web handler and the transcription worker are wired // from outside. package uploads import ( "fmt" "time" ) // Limits bound what an upload may be. Both are rejected synchronously, with a // specific message, rather than by a timeout (ADR-033). type Limits struct { MaxBytes int64 MaxDuration time.Duration } // Probe is what inspecting an uploaded file revealed. It is the input to Validate // because a name or MIME type cannot say what a container holds: a Teams recording // is an .mp4 whether or not the camera was on. type Probe struct { Duration time.Duration HasAudio bool HasVideo bool } // Reason is the machine-checkable class of a rejection. type Reason string const ( ReasonNoAudio Reason = "no_audio" ReasonTooLong Reason = "too_long" ReasonTooLarge Reason = "too_large" ReasonUnreadable Reason = "unreadable" ) // RejectError is a synchronous, user-presentable refusal. Message is written for // the person who uploaded the file; Reason is for code and tests. type RejectError struct { Reason Reason Message string } func (e *RejectError) Error() string { return string(e.Reason) + ": " + e.Message } // Validate decides whether a probed file may be transcribed. A video track is NOT // a reason to refuse: a Teams meeting with the camera off is still an .mp4 with a // video track, so rejecting video would reject the primary use case. Only the audio // is used (Probe.HasVideo is recorded so the user can be told the video was // ignored). What IS refused: no audio at all, an unreadable length, too long. func Validate(p Probe, l Limits) error { switch { case !p.HasAudio: return &RejectError{Reason: ReasonNoAudio, Message: "This file has no audio track to transcribe."} case p.Duration <= 0: return &RejectError{Reason: ReasonUnreadable, Message: "Could not read a length from this file; it may be damaged."} case p.Duration > l.MaxDuration: return &RejectError{Reason: ReasonTooLong, Message: fmt.Sprintf( "This recording is %s long; the limit is %s.", FormatDuration(p.Duration), FormatDuration(l.MaxDuration))} } return nil } // FormatDuration renders a length for people: "1 h 42 min", "45 min", "30 s". func FormatDuration(d time.Duration) string { d = d.Round(time.Second) h := int(d / time.Hour) m := int(d % time.Hour / time.Minute) switch { case h > 0 && m > 0: return fmt.Sprintf("%d h %d min", h, m) case h > 0: return fmt.Sprintf("%d h", h) case m > 0: return fmt.Sprintf("%d min", m) default: return fmt.Sprintf("%d s", int(d/time.Second)) } }