Compare commits
54
Commits
+27
-8
@@ -1735,17 +1735,36 @@ wording while only `content` is merged.
|
|||||||
`network`, `error`), never its text, which can hold a provider's response body, the spool path or
|
`network`, `error`), never its text, which can hold a provider's response body, the spool path or
|
||||||
a word from the transcript (`internal/errkind`). The stored reconcile reason and an engine's
|
a word from the transcript (`internal/errkind`). The stored reconcile reason and an engine's
|
||||||
failure reason are kinds; the page no longer shows provider text.
|
failure reason are kinds; the page no longer shows provider text.
|
||||||
- **Every local engine call has its own deadline** (`TAPIR_TRANSCRIBE_ENGINE_TIMEOUT`, default 25
|
- **Every local engine call has its own deadline** (`TAPIR_TRANSCRIBE_ENGINE_TIMEOUT`, default 20
|
||||||
minutes), so a wedged engine fails while the job lives and the fallback can run; the job's own
|
minutes: two wedged engines in sequence then leave 20 of the job's 60 minutes for a fallback),
|
||||||
|
so a wedged engine fails while the job lives and the fallback can run; the job's own
|
||||||
deadline or a cancel still never reaches the cloud.
|
deadline or a cancel still never reaches the cloud.
|
||||||
- **The duration limit is checked against what is converted**, not the file's header: ffmpeg is
|
- **The duration limit is checked against what is converted**, not the file's header: ffmpeg is
|
||||||
capped at the limit plus a minute, a longer result fails the job (`audio_too_long`), and a result
|
capped at the limit plus a minute, a longer result fails the job (`audio_too_long`), and a result
|
||||||
under 80% of the claimed length and more than two minutes short fails it
|
under 80% of the claimed length and more than two minutes short is **transcribed and noted** on
|
||||||
(`audio_incomplete`) instead of returning a partial transcript with no warning.
|
the audio step ("the audio is 20 min long; the file claimed 1 h 19 min"), not refused. The second
|
||||||
- **The queue is bounded**, 20 waiting in total and 3 per user; a full queue is a 429 with a typed
|
adversarial review showed a complete variable-bitrate MP3 whose header over-estimated its length
|
||||||
reason, never a stuck row or a server error.
|
by 4x was wrongly refused by the first version of this check; a note keeps a truncated file
|
||||||
- **Bad input is a 4xx**: a non-UUID id is a 404, a malformed multipart header is a 400 whose text is
|
visible without turning away real recordings.
|
||||||
not logged, and a title or file name with control bytes or invalid UTF-8 is cleaned, not a 500.
|
- **The queue is bounded**, 20 waiting in total and 3 per user, and its room is checked before the
|
||||||
|
upload is spooled (not after 200 MB and an ffprobe run); a full queue is a 429 with a typed reason,
|
||||||
|
never a stuck row or a server error.
|
||||||
|
- **The configured upload chat model is callable.** It is added to the shared chat service's model
|
||||||
|
list when uploads are on and it is local, because the service re-maps a model it does not offer
|
||||||
|
(a valid `iguana/gemma4-31b` was being silently swapped for `koala/phi4-mini`). The video chat's own switcher
|
||||||
|
lists that local model too while uploads are on.
|
||||||
|
- **A client that hangs up mid-upload is not an error**: the handler logs INFO and writes nothing
|
||||||
|
(rendering the error page ran a database read on a cancelled context and logged a false ERROR). The
|
||||||
|
400 "cut off" answer is keyed on a marker for errors from *reading the client's body*
|
||||||
|
(`uploads.ErrClientRead`), not on `io.ErrUnexpectedEOF`, which is also what a database drop looks like.
|
||||||
|
- **A note is not hidden**: a finished upload's record opens by default when any step carries a note.
|
||||||
|
- **Bad input is a 4xx**: a non-UUID id is a 404, a malformed or cut-off multipart body is a 400
|
||||||
|
whose text is not logged, and a title with control bytes, bidirectional controls or invalid UTF-8 is
|
||||||
|
cleaned (emoji joiners, Swedish letters and variation selectors are kept), not a 500.
|
||||||
|
- **The image was built and inspected** (buildah on koala, 2026-10-02): the first build FAILED
|
||||||
|
(`tar: xz: Cannot exec`, the golang image has no xz); fixed, rebuilt at 180 MB, ffmpeg and ffprobe
|
||||||
|
run as `nonroot` in distroless. CI does not build the image on a pull request
|
||||||
|
(`if: github.event_name != 'pull_request'`), so the first CI build of it is the merge to main.
|
||||||
Not done, filed as follow-ups: a vendored ffmpeg tarball, CSRF cookie review, ffprobe CPU and
|
Not done, filed as follow-ups: a vendored ffmpeg tarball, CSRF cookie review, ffprobe CPU and
|
||||||
stream-count limits, mov external references, `alive_at` clock skew, a hung database blocking the
|
stream-count limits, mov external references, `alive_at` clock skew, a hung database blocking the
|
||||||
tracker, an unbounded gateway response body, a user-facing notice for a recording that is partial but
|
tracker, an unbounded gateway response body, a user-facing notice for a recording that is partial but
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ FROM golang:1.26 AS ffmpeg
|
|||||||
ARG FFMPEG_VERSION=7.0.2
|
ARG FFMPEG_VERSION=7.0.2
|
||||||
ARG FFMPEG_SHA256=abda8d77ce8309141f83ab8edf0596834087c52467f6badf376a6a2a4c87cf67
|
ARG FFMPEG_SHA256=abda8d77ce8309141f83ab8edf0596834087c52467f6badf376a6a2a4c87cf67
|
||||||
|
|
||||||
|
# xz is not in the golang image (found by building it: `tar: xz: Cannot exec`).
|
||||||
RUN set -eu; \
|
RUN set -eu; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends xz-utils; \
|
||||||
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
mkdir -p /out; \
|
mkdir -p /out; \
|
||||||
for dir in releases old-releases; do \
|
for dir in releases old-releases; do \
|
||||||
curl -fsSL -o /out/ffmpeg.tar.xz "https://johnvansickle.com/ffmpeg/${dir}/ffmpeg-${FFMPEG_VERSION}-amd64-static.tar.xz" && break; \
|
curl -fsSL -o /out/ffmpeg.tar.xz "https://johnvansickle.com/ffmpeg/${dir}/ffmpeg-${FFMPEG_VERSION}-amd64-static.tar.xz" && break; \
|
||||||
|
|||||||
@@ -134,6 +134,13 @@ func chatModels(cfg config.Config) []string {
|
|||||||
add(cfg.SummarizerModel)
|
add(cfg.SummarizerModel)
|
||||||
add(cfg.FallbackModel)
|
add(cfg.FallbackModel)
|
||||||
add(cfg.CloudFallbackModel)
|
add(cfg.CloudFallbackModel)
|
||||||
|
// The model chat over an uploaded recording opens on must be one the shared service can
|
||||||
|
// call: it re-maps a model it does not offer, which silently swapped a configured 31B for
|
||||||
|
// phi4-mini, whose context cannot hold an hour of speech. Local only, and only when
|
||||||
|
// uploads are on, so the video chat is unchanged otherwise (second adversarial review).
|
||||||
|
if cfg.UploadsEnabled && config.IsLocalModel(cfg.UploadChatModel) {
|
||||||
|
add(cfg.UploadChatModel)
|
||||||
|
}
|
||||||
return models
|
return models
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,3 +44,42 @@ func TestUploadChatModels_NoLocalModelMeansNoneOfferedNotACloudDefault(t *testin
|
|||||||
t.Errorf("uploadChatModels = %v, want none", got)
|
t.Errorf("uploadChatModels = %v, want none", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A valid local TAPIR_UPLOAD_CHAT_MODEL that is not in the video chat's chain used to be
|
||||||
|
// accepted by config and then silently ignored: the shared chat service re-maps a model it
|
||||||
|
// does not offer, so the user got koala/phi4-mini, whose 8,192-token context cannot hold an
|
||||||
|
// hour of speech (second adversarial review).
|
||||||
|
|
||||||
|
func TestChatModels_AnUploadChatModelOutsideTheChainIsOfferedWhenUploadsAreOn(t *testing.T) {
|
||||||
|
var cfg config.Config
|
||||||
|
cfg.SummarizerModel = "koala/phi4-mini"
|
||||||
|
cfg.FallbackModel = "iguana/gemma4-26b"
|
||||||
|
cfg.UploadsEnabled = true
|
||||||
|
cfg.UploadChatModel = "iguana/gemma4-31b"
|
||||||
|
|
||||||
|
service := chatModels(cfg)
|
||||||
|
upload := uploadChatModels(cfg)
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(service, []string{"koala/phi4-mini", "iguana/gemma4-26b", "iguana/gemma4-31b"}) {
|
||||||
|
t.Errorf("chatModels = %v: the shared service must be able to call the configured upload model", service)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(upload, service) {
|
||||||
|
t.Errorf("uploadChatModels = %v, want %v", upload, service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatModels_NothingIsAddedWhenUploadsAreOffOrTheModelIsNotLocal(t *testing.T) {
|
||||||
|
var cfg config.Config
|
||||||
|
cfg.SummarizerModel = "koala/phi4-mini"
|
||||||
|
cfg.UploadChatModel = "iguana/gemma4-31b"
|
||||||
|
|
||||||
|
if got := chatModels(cfg); !reflect.DeepEqual(got, []string{"koala/phi4-mini"}) {
|
||||||
|
t.Errorf("uploads off: chatModels = %v, want the video chain unchanged", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.UploadsEnabled = true
|
||||||
|
cfg.UploadChatModel = "berget/mistral-small"
|
||||||
|
if got := chatModels(cfg); !reflect.DeepEqual(got, []string{"koala/phi4-mini"}) {
|
||||||
|
t.Errorf("a cloud upload chat model was added to the service: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,3 +43,36 @@ func TestBuildTranscriber_AWedgedPrimaryReachesTheFallbackBeforeTheJobDeadline(t
|
|||||||
require.NoError(t, err, "the wedged primary used the whole job and the fallback never ran")
|
require.NoError(t, err, "the wedged primary used the whole job and the fallback never ran")
|
||||||
assert.Equal(t, "berget/kb-whisper", got.Endpoint)
|
assert.Equal(t, "berget/kb-whisper", got.Endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildTranscriber_AWedgedBaseEngineIsASoftFailureInTheEnsemble(t *testing.T) {
|
||||||
|
// The same deadline must guard the ensemble's BASE engine, not only the chain's primary
|
||||||
|
// (second adversarial review: the wiring was tested for the chain alone).
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = r.ParseMultipartForm(1 << 20)
|
||||||
|
if r.FormValue("model") == "iguana/pianissimo" {
|
||||||
|
<-r.Context().Done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"text":"hej","duration":1,"segments":[{"start":0,"end":1,"text":"hej"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
cfg := uploadCfg(t, true)
|
||||||
|
cfg.GatewayURL = srv.URL
|
||||||
|
cfg.TranscribeModel = "iguana/kb-whisper"
|
||||||
|
cfg.ReconcileBaseModel = "iguana/pianissimo"
|
||||||
|
cfg.ReconcileModel = "iguana/gemma4-31b"
|
||||||
|
cfg.TranscribeEngineTimeout = 150 * time.Millisecond
|
||||||
|
wav := filepath.Join(t.TempDir(), "a.wav")
|
||||||
|
require.NoError(t, os.WriteFile(wav, []byte("RIFF....WAVEfmt "), 0o600))
|
||||||
|
job, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
got, err := buildTranscriber(cfg).Transcribe(job, wav)
|
||||||
|
|
||||||
|
require.NoError(t, err, "a wedged base engine must not use up the job")
|
||||||
|
assert.Equal(t, "hej", got.Text)
|
||||||
|
require.NotNil(t, got.Reconciliation)
|
||||||
|
assert.Equal(t, "single_engine", string(got.Reconciliation.Status))
|
||||||
|
assert.Contains(t, got.Reconciliation.Reason, "timeout")
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ it** — endpoints and aliases drift, and this file is a snapshot (2026-06-06),
|
|||||||
`{"error":"failed to process audio"}` string — it has had at least five causes. Tapir's knobs:
|
`{"error":"failed to process audio"}` string — it has had at least five causes. Tapir's knobs:
|
||||||
`TAPIR_UPLOADS_ENABLED` (off by default), `TAPIR_UPLOAD_MAX_MB` (200), `TAPIR_UPLOAD_MAX_MINUTES`
|
`TAPIR_UPLOADS_ENABLED` (off by default), `TAPIR_UPLOAD_MAX_MB` (200), `TAPIR_UPLOAD_MAX_MINUTES`
|
||||||
(90), `TAPIR_TRANSCRIBE_MODEL`, `TAPIR_TRANSCRIBE_FALLBACK_MODEL` (empty disables the cloud
|
(90), `TAPIR_TRANSCRIBE_MODEL`, `TAPIR_TRANSCRIBE_FALLBACK_MODEL` (empty disables the cloud
|
||||||
fallback), `TAPIR_TRANSCRIBE_TIMEOUT` (60m, the whole job), `TAPIR_TRANSCRIBE_ENGINE_TIMEOUT` (25m, one engine call: a wedged engine then fails while the job lives and the fallback can run), `TAPIR_UPLOAD_TMP_DIR`, `TAPIR_FFMPEG_BIN`,
|
fallback), `TAPIR_TRANSCRIBE_TIMEOUT` (60m, the whole job), `TAPIR_TRANSCRIBE_ENGINE_TIMEOUT` (20m, one engine call: a wedged engine then fails while the job lives and the fallback can run), `TAPIR_UPLOAD_TMP_DIR`, `TAPIR_FFMPEG_BIN`,
|
||||||
`TAPIR_FFPROBE_BIN`, `TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS` (90000 — the measured ceiling of
|
`TAPIR_FFPROBE_BIN`, `TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS` (90000 — the measured ceiling of
|
||||||
`iguana/gemma4-26b`, which silently drops the head above it), `TAPIR_UPLOAD_CHAT_MODEL`
|
`iguana/gemma4-26b`, which silently drops the head above it), `TAPIR_UPLOAD_CHAT_MODEL`
|
||||||
(`iguana/gemma4-26b`; must be a local `koala/` or `iguana/` alias, since the whole private transcript is sent to it; `koala/phi4-mini`'s 8,192-token context 400s on an hour of speech). `ffmpeg` + `ffprobe` are in the runtime image (static, pinned by sha256; #34 decided A on 2026-10-02); Tapir's own ingress body limit for a 200 MB upload is unverified (infra).
|
(`iguana/gemma4-26b`; must be a local `koala/` or `iguana/` alias, since the whole private transcript is sent to it; `koala/phi4-mini`'s 8,192-token context 400s on an hour of speech). `ffmpeg` + `ffprobe` are in the runtime image (static, pinned by sha256; #34 decided A on 2026-10-02); Tapir's own ingress body limit for a 200 MB upload is unverified (infra).
|
||||||
|
|||||||
@@ -235,9 +235,10 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
|
|||||||
When a file claims to be short but converts to more than the limit
|
When a file claims to be short but converts to more than the limit
|
||||||
Then the job fails with a plain explanation and no engine receives the audio
|
Then the job fails with a plain explanation and no engine receives the audio
|
||||||
|
|
||||||
Scenario: A truncated recording is refused, not half transcribed
|
Scenario: A recording much shorter than its file claimed is transcribed and noted
|
||||||
When a file converts to far less audio than it claimed
|
When a file converts to under 80 percent of the length it claimed and more than two minutes short
|
||||||
Then the job fails and asks for the complete recording
|
Then it is transcribed anyway and its audio step says how long the audio really is
|
||||||
|
And a complete recording whose header over-estimated its length is not refused
|
||||||
|
|
||||||
Scenario: A full queue is a retry, not an error
|
Scenario: A full queue is a retry, not an error
|
||||||
When too many recordings are already waiting
|
When too many recordings are already waiting
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ printf data > "$last"`)
|
|||||||
assert.Less(t, fw, in)
|
assert.Less(t, fw, in)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFFmpegToWAV_CapsTheOutputLengthWhenAMaximumIsSet(t *testing.T) {
|
func TestFFmpegToWAV_PassesAnOutputLengthCapAfterTheInputWhenAMaximumIsSet(t *testing.T) {
|
||||||
// The duration limit is a CLAIM in the file's header; a file that lies about it can
|
// The duration limit is a CLAIM in the file's header; a file that lies about it can
|
||||||
// convert to hours of audio (adversarial review F2: a 2 h file claiming 10 s became a
|
// convert to hours of audio (adversarial review F2: a 2 h file claiming 10 s became a
|
||||||
// 120-minute WAV). The cap is an OUTPUT option, after -i, with a minute of slack.
|
// 120-minute WAV). The cap is an OUTPUT option, after -i, with a minute of slack.
|
||||||
@@ -97,7 +97,7 @@ printf data > "$last"`)
|
|||||||
assert.Less(t, strings.Index(joined, " -i /spool/in "), strings.Index(joined, " -t 5460 "), "-t after -i is an output cap; before it, an input one")
|
assert.Less(t, strings.Index(joined, " -i /spool/in "), strings.Index(joined, " -t 5460 "), "-t after -i is an output cap; before it, an input one")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFFmpegToWAV_WithNoMaximumThereIsNoLengthCap(t *testing.T) {
|
func TestFFmpegToWAV_PassesNoLengthCapWhenNoMaximumIsSet(t *testing.T) {
|
||||||
rec := filepath.Join(t.TempDir(), "argv")
|
rec := filepath.Join(t.TempDir(), "argv")
|
||||||
bin := stub(t, `printf '%s\n' "$@" > `+rec+`
|
bin := stub(t, `printf '%s\n' "$@" > `+rec+`
|
||||||
for a; do last="$a"; done
|
for a; do last="$a"; done
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const (
|
|||||||
defaultTranscribeModel = "iguana/kb-whisper"
|
defaultTranscribeModel = "iguana/kb-whisper"
|
||||||
defaultTranscribeFallbackModel = "berget/kb-whisper"
|
defaultTranscribeFallbackModel = "berget/kb-whisper"
|
||||||
defaultTranscribeTimeout = 60 * time.Minute
|
defaultTranscribeTimeout = 60 * time.Minute
|
||||||
defaultTranscribeEngineTimeout = 25 * time.Minute
|
defaultTranscribeEngineTimeout = 20 * time.Minute
|
||||||
defaultMaxUploadedChars = 90000
|
defaultMaxUploadedChars = 90000
|
||||||
defaultUploadChatModel = "iguana/gemma4-26b"
|
defaultUploadChatModel = "iguana/gemma4-26b"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -76,14 +76,14 @@ func TestLoad_ALocalUploadChatModelIsAccepted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_TheEngineTimeoutDefaultsToTwentyFiveMinutesAndMustBePositive(t *testing.T) {
|
func TestLoad_TheEngineTimeoutDefaultsToTwentyMinutesAndMustBePositive(t *testing.T) {
|
||||||
clearUploadEnv(t)
|
clearUploadEnv(t)
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
t.Fatalf("Load: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.TranscribeEngineTimeout != 25*time.Minute {
|
if cfg.TranscribeEngineTimeout != 20*time.Minute {
|
||||||
t.Errorf("TranscribeEngineTimeout = %v, want 25m: a 90-minute recording takes about 8 minutes on kb-whisper", cfg.TranscribeEngineTimeout)
|
t.Errorf("TranscribeEngineTimeout = %v, want 20m: a 90-minute recording takes about 8 minutes on kb-whisper, and two wedged engines in sequence must leave the job time to reach a fallback", cfg.TranscribeEngineTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
setEnv(t, map[string]string{"TAPIR_TRANSCRIBE_ENGINE_TIMEOUT": "7m"})
|
setEnv(t, map[string]string{"TAPIR_TRANSCRIBE_ENGINE_TIMEOUT": "7m"})
|
||||||
|
|||||||
@@ -203,6 +203,10 @@ type ProgressStep struct {
|
|||||||
FinishedAt time.Time `json:"finished_at,omitzero"`
|
FinishedAt time.Time `json:"finished_at,omitzero"`
|
||||||
Done int `json:"done,omitempty"`
|
Done int `json:"done,omitempty"`
|
||||||
Total int `json:"total,omitempty"`
|
Total int `json:"total,omitempty"`
|
||||||
|
// Note is a short plain remark about the phase that the reader should see even though
|
||||||
|
// it did not fail, for example that the audio is much shorter than the file claimed.
|
||||||
|
// Durations and counts only: never transcript text, a name or a path.
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadStatus is the state of an uploaded-audio transcription job.
|
// UploadStatus is the state of an uploaded-audio transcription job.
|
||||||
@@ -233,10 +237,6 @@ const (
|
|||||||
// UploadErrBusy: too many recordings were already waiting, so this one was not
|
// UploadErrBusy: too many recordings were already waiting, so this one was not
|
||||||
// queued. The user's to retry later (ADR-035 review).
|
// queued. The user's to retry later (ADR-035 review).
|
||||||
UploadErrBusy UploadErrorKind = "queue_full"
|
UploadErrBusy UploadErrorKind = "queue_full"
|
||||||
// UploadErrIncomplete: the audio, once converted, ends far earlier than the file
|
|
||||||
// claimed: a truncated recording that would otherwise give a partial transcript with
|
|
||||||
// no warning.
|
|
||||||
UploadErrIncomplete UploadErrorKind = "audio_incomplete"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// UploadedTranscript is a user's PRIVATE transcript of audio they uploaded. It is
|
// UploadedTranscript is a user's PRIVATE transcript of audio they uploaded. It is
|
||||||
|
|||||||
@@ -17,13 +17,20 @@ import (
|
|||||||
// HTTPStatuser is implemented by errors that are a non-2xx answer from a service.
|
// HTTPStatuser is implemented by errors that are a non-2xx answer from a service.
|
||||||
type HTTPStatuser interface{ HTTPStatus() int }
|
type HTTPStatuser interface{ HTTPStatus() int }
|
||||||
|
|
||||||
// Of is the kind of err: "timeout", "canceled", "http_<code>", "network", or "error".
|
// SQLStater is implemented by database errors that carry a SQLSTATE (pgx's PgError does).
|
||||||
|
// The five-character code is a fixed vocabulary; the error's message is not: Postgres quotes
|
||||||
|
// the offending value, which for a transcript row is transcript text.
|
||||||
|
type SQLStater interface{ SQLState() string }
|
||||||
|
|
||||||
|
// Of is the kind of err: "timeout", "canceled", "http_<code>", "sql_<state>", "network",
|
||||||
|
// or "error".
|
||||||
// It is empty for nil.
|
// It is empty for nil.
|
||||||
func Of(err error) string {
|
func Of(err error) string {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
var hs HTTPStatuser
|
var hs HTTPStatuser
|
||||||
|
var ss SQLStater
|
||||||
var ne net.Error
|
var ne net.Error
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
@@ -32,6 +39,8 @@ func Of(err error) string {
|
|||||||
return "canceled"
|
return "canceled"
|
||||||
case errors.As(err, &hs):
|
case errors.As(err, &hs):
|
||||||
return fmt.Sprintf("http_%d", hs.HTTPStatus())
|
return fmt.Sprintf("http_%d", hs.HTTPStatus())
|
||||||
|
case errors.As(err, &ss) && ss.SQLState() != "":
|
||||||
|
return "sql_" + ss.SQLState()
|
||||||
case errors.As(err, &ne):
|
case errors.As(err, &ne):
|
||||||
if ne.Timeout() {
|
if ne.Timeout() {
|
||||||
return "timeout"
|
return "timeout"
|
||||||
|
|||||||
@@ -54,6 +54,24 @@ func TestOf_ReducesAnErrorToAKindAndNeverItsText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type sqlErr struct{}
|
||||||
|
|
||||||
|
func (sqlErr) Error() string {
|
||||||
|
return `ERROR: invalid input syntax for type json: "Anders ansvarar för virket"`
|
||||||
|
}
|
||||||
|
func (sqlErr) SQLState() string { return "22P02" }
|
||||||
|
|
||||||
|
func TestOf_ADatabaseErrorIsReducedToItsSQLStateNeverItsMessage(t *testing.T) {
|
||||||
|
// A Postgres error can quote the offending value, which for a transcript row is
|
||||||
|
// transcript text. The five-character SQLSTATE is a fixed vocabulary (fourth review).
|
||||||
|
err := fmt.Errorf("store: complete upload: %w", sqlErr{})
|
||||||
|
|
||||||
|
got := errkind.Of(err)
|
||||||
|
|
||||||
|
assert.Equal(t, "sql_22P02", got)
|
||||||
|
assert.NotContains(t, got, "Anders")
|
||||||
|
}
|
||||||
|
|
||||||
func TestOf_AKindCarriesNoWordFromTheError(t *testing.T) {
|
func TestOf_AKindCarriesNoWordFromTheError(t *testing.T) {
|
||||||
err := fmt.Errorf("convert /spool/upload-555: %w", errors.New("Anders ansvarar för virket"))
|
err := fmt.Errorf("convert /spool/upload-555: %w", errors.New("Anders ansvarar för virket"))
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,20 @@ func (t *Tracker) Finish() {
|
|||||||
t.save()
|
t.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Annotate attaches a short plain note to a phase and saves it at once. It is for a
|
||||||
|
// remark the reader should see although nothing failed. Durations and counts only: a note
|
||||||
|
// is shown on a page about a private recording.
|
||||||
|
func (t *Tracker) Annotate(key, note string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
s := t.find(key)
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Note = note
|
||||||
|
t.save()
|
||||||
|
}
|
||||||
|
|
||||||
// Heartbeat stamps the job's row every beatEvery and logs which phase is still running
|
// Heartbeat stamps the job's row every beatEvery and logs which phase is still running
|
||||||
// every stillEvery, until ctx ends. It runs on its own, independent of any engine call:
|
// every stillEvery, until ctx ends. It runs on its own, independent of any engine call:
|
||||||
// a heartbeat that stopped means the process is gone, not that a request is slow.
|
// a heartbeat that stopped means the process is gone, not that a request is slow.
|
||||||
|
|||||||
@@ -387,3 +387,16 @@ func TestWithAndFrom_ACarriedReporterIsTheOneReturned(t *testing.T) {
|
|||||||
|
|
||||||
require.Equal(t, domain.StepRunning, stateOf(sink.last(t), "audio"))
|
require.Equal(t, domain.StepRunning, stateOf(sink.last(t), "audio"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTracker_AnnotateAttachesANoteToAStepAndSavesItAtOnce(t *testing.T) {
|
||||||
|
tr, sink, _, _ := newTracker(t)
|
||||||
|
tr.Start()
|
||||||
|
tr.Begin("audio", 0)
|
||||||
|
before := sink.saveCount()
|
||||||
|
|
||||||
|
tr.Annotate("audio", "20 min of the 79 the file claimed")
|
||||||
|
|
||||||
|
require.Equal(t, before+1, sink.saveCount())
|
||||||
|
require.Equal(t, "20 min of the 79 the file claimed", sink.last(t)[0].Note)
|
||||||
|
require.NotPanics(t, func() { tr.Annotate("no-such-step", "x") })
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ func TestReconcileSpots_AutoResolvedDisputesAreNotStored(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReconcileSpots_ASpotCarriesTheWordsJustBeforeIt(t *testing.T) {
|
func TestReconcileSpots_ASpotCarriesTheWordsJustBeforeIt(t *testing.T) {
|
||||||
out := merge(t, tr("vi skickar in ett vanligt meddelande till kassasystemet"), tr("vi skickar in ett KORT meddelande till kassasystemet"), always(reconcile.VerdictAlt))
|
out := merge(t, tr("grannen målar ett vanligt staket mot gatan"), tr("grannen målar ett KORT staket mot gatan"), always(reconcile.VerdictAlt))
|
||||||
|
|
||||||
require.Len(t, out.Reconciliation.Contested, 1)
|
require.Len(t, out.Reconciliation.Contested, 1)
|
||||||
assert.Equal(t, "vi skickar in ett", out.Reconciliation.Contested[0].Before)
|
assert.Equal(t, "grannen målar ett", out.Reconciliation.Contested[0].Before)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReconcileSpots_TheContextIsAFewWordsNotTheWholeMeeting(t *testing.T) {
|
func TestReconcileSpots_TheContextIsAFewWordsNotTheWholeMeeting(t *testing.T) {
|
||||||
|
|||||||
@@ -58,12 +58,42 @@ func NewIntake(s Store, p Prober, sub Submitter, l Limits, tmpDir string) *Intak
|
|||||||
return &Intake{store: s, prober: p, sub: sub, limits: l, tmpDir: tmpDir}
|
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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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) {
|
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-*")
|
f, err := os.CreateTemp(in.tmpDir, "upload-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("uploads: spool: %w", err)
|
return "", fmt.Errorf("uploads: spool: %w", err)
|
||||||
@@ -77,11 +107,15 @@ func (in *Intake) Accept(ctx context.Context, userID, title string, src io.Reade
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Read one byte past the cap so "exactly at the limit" and "over" are told apart.
|
// Read one byte past the cap so "exactly at the limit" and "over" are told apart.
|
||||||
n, err := io.Copy(f, io.LimitReader(src, in.limits.MaxBytes+1))
|
cr := &clientReader{r: io.LimitReader(src, in.limits.MaxBytes+1)}
|
||||||
|
n, err := io.Copy(f, cr)
|
||||||
if cerr := f.Close(); err == nil {
|
if cerr := f.Close(); err == nil {
|
||||||
err = cerr
|
err = cerr
|
||||||
}
|
}
|
||||||
if err != nil {
|
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)
|
return "", fmt.Errorf("uploads: receive: %w", err)
|
||||||
}
|
}
|
||||||
if n > in.limits.MaxBytes {
|
if n > in.limits.MaxBytes {
|
||||||
@@ -114,7 +148,7 @@ func (in *Intake) Accept(ctx context.Context, userID, title string, src io.Reade
|
|||||||
if ferr := in.store.FailUpload(ctx, userID, id, domain.UploadErrBusy); ferr != nil {
|
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 "", fmt.Errorf("uploads: submit: %w (and failing the row: %v)", err, ferr)
|
||||||
}
|
}
|
||||||
return "", &RejectError{Reason: ReasonBusy, Message: "Too many recordings are waiting to be transcribed. Please try again in a few minutes."}
|
return "", &RejectError{Reason: ReasonBusy, Message: busyMessage}
|
||||||
}
|
}
|
||||||
// A row that will never run must not sit queued: that is the stuck spinner.
|
// 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 {
|
if ferr := in.store.FailUpload(ctx, userID, id, domain.UploadErrInterrupted); ferr != nil {
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package uploads_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -29,3 +32,65 @@ func TestAccept_AFullQueueIsABusyRejectionNotAServerErrorAndLeavesNothingBehind(
|
|||||||
assert.Equal(t, domain.UploadErrBusy, r.st.failed["up-1"], "the row must not sit queued: that is the stuck spinner")
|
assert.Equal(t, domain.UploadErrBusy, r.st.failed["up-1"], "the row must not sit queued: that is the stuck spinner")
|
||||||
assert.Empty(t, r.tmpFiles(t), "the audio is deleted")
|
assert.Empty(t, r.tmpFiles(t), "the audio is deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// roomSubmitter is a Submitter that can say, before anything is spooled, that there is no room.
|
||||||
|
type roomSubmitter struct {
|
||||||
|
fakeSubmitter
|
||||||
|
room bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *roomSubmitter) HasRoom(string) bool { return r.room }
|
||||||
|
|
||||||
|
func TestAccept_ANoRoomQueueIsRefusedBeforeAnythingIsSpooledProbedOrStored(t *testing.T) {
|
||||||
|
// Checking only at Submit meant 200 MB was spooled and ffprobe run for every refused
|
||||||
|
// attempt, and a failed row was left behind each time (second adversarial review).
|
||||||
|
r := newRig(t)
|
||||||
|
full := &roomSubmitter{room: false}
|
||||||
|
in := uploads.NewIntake(r.st, r.pr, full, uploads.Limits{MaxBytes: 1024, MaxDuration: 90 * time.Minute}, r.tmp)
|
||||||
|
|
||||||
|
_, err := in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
|
||||||
|
|
||||||
|
var rej *uploads.RejectError
|
||||||
|
require.True(t, errors.As(err, &rej))
|
||||||
|
assert.Equal(t, uploads.ReasonBusy, rej.Reason)
|
||||||
|
assert.Empty(t, r.st.created, "no row for an upload that was never accepted")
|
||||||
|
assert.Empty(t, r.pr.paths, "ffprobe was never run on it")
|
||||||
|
assert.Empty(t, r.tmpFiles(t), "nothing was spooled")
|
||||||
|
assert.Empty(t, full.jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccept_ASubmitterThatCannotSayStillWorksAsBefore(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
|
||||||
|
_, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
|
||||||
|
|
||||||
|
require.NoError(t, err, "a Submitter without HasRoom is accepted: the check at Submit remains")
|
||||||
|
}
|
||||||
|
|
||||||
|
// brokenBody is a client body that fails mid-read, like a connection that dropped.
|
||||||
|
type brokenBody struct{ n int }
|
||||||
|
|
||||||
|
func (b *brokenBody) Read(p []byte) (int, error) {
|
||||||
|
if b.n > 0 {
|
||||||
|
b.n = 0
|
||||||
|
return copy(p, "partial audio"), nil
|
||||||
|
}
|
||||||
|
return 0, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccept_AnErrorReadingTheClientsBodyIsMarkedAsTheClientsAndAWrappedSubmitErrorIsNot(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
|
||||||
|
_, err := r.in.Accept(context.Background(), "user-1", "t", &brokenBody{n: 1})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, uploads.ErrClientRead, "a dropped client body is the sender's to retry")
|
||||||
|
assert.Empty(t, r.tmpFiles(t))
|
||||||
|
|
||||||
|
// A fault that is ours must NOT carry the marker, even if it wraps the same sentinel.
|
||||||
|
r2 := newRig(t)
|
||||||
|
r2.sub.err = fmt.Errorf("db: %w", io.ErrUnexpectedEOF)
|
||||||
|
_, err = r2.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotErrorIs(t, err, uploads.ErrClientRead)
|
||||||
|
}
|
||||||
|
|||||||
+51
-18
@@ -130,6 +130,24 @@ func NewWorker(s WorkerStore, c Converter, t Transcriber, tmpDir string, timeout
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasRoom reports whether a job from this user would be accepted right now. It lets the
|
||||||
|
// intake refuse BEFORE spooling up to the upload cap and running ffprobe; Submit still
|
||||||
|
// enforces the same limits atomically, so a race costs at worst one refused submit.
|
||||||
|
func (w *Worker) HasRoom(userID string) bool {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.stopped || len(w.queue) >= w.maxQueued {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mine := 0
|
||||||
|
for _, q := range w.queue {
|
||||||
|
if q.UserID == userID {
|
||||||
|
mine++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mine < w.maxPerUser
|
||||||
|
}
|
||||||
|
|
||||||
// Submit queues a job. It never blocks. It satisfies Submitter.
|
// Submit queues a job. It never blocks. It satisfies Submitter.
|
||||||
func (w *Worker) Submit(j Job) error {
|
func (w *Worker) Submit(j Job) error {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
@@ -224,7 +242,7 @@ func (w *Worker) Process(ctx context.Context, job Job) {
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
if err := w.store.StartTranscribing(ctx, job.UserID, job.UploadID); err != nil {
|
if err := w.store.StartTranscribing(ctx, job.UserID, job.UploadID); err != nil {
|
||||||
log.Error("upload: start transcribing", "err", err)
|
log.Error("upload: start transcribing", "kind", errkind.Of(err))
|
||||||
w.fail(ctx, job, log, domain.UploadErrTranscriptionFailed)
|
w.fail(ctx, job, log, domain.UploadErrTranscriptionFailed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -261,7 +279,7 @@ func (w *Worker) Process(ctx context.Context, job Job) {
|
|||||||
|
|
||||||
wav, err := w.tempWAV()
|
wav, err := w.tempWAV()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("upload: temp wav", "err", err)
|
log.Error("upload: temp wav", "kind", errkind.Of(err))
|
||||||
abort(domain.UploadErrTranscriptionFailed)
|
abort(domain.UploadErrTranscriptionFailed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -275,11 +293,18 @@ func (w *Worker) Process(ctx context.Context, job Job) {
|
|||||||
abort(domain.UploadErrTranscriptionFailed)
|
abort(domain.UploadErrTranscriptionFailed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if kind, bad := w.audioLengthProblem(wav, job.Duration); bad {
|
got, tooLong := w.audioLengthProblem(wav)
|
||||||
abort(kind)
|
if tooLong {
|
||||||
log.Error("upload: converted audio length rejected", "kind", string(kind))
|
abort(domain.UploadErrTooLong)
|
||||||
|
log.Error("upload: converted audio is over the limit", "audio_s", int(got.Seconds()), "limit_s", int(w.maxDuration.Seconds()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if w.maxDuration > 0 && job.Duration > 0 {
|
||||||
|
if note := shortfallNote(got, job.Duration); note != "" {
|
||||||
|
log.Warn("upload: audio is much shorter than the file claimed", "audio_s", int(got.Seconds()), "claimed_s", int(job.Duration.Seconds()))
|
||||||
|
tr.Annotate("audio", note)
|
||||||
|
}
|
||||||
|
}
|
||||||
tr.Done("audio")
|
tr.Done("audio")
|
||||||
|
|
||||||
if !planned {
|
if !planned {
|
||||||
@@ -297,7 +322,7 @@ func (w *Worker) Process(ctx context.Context, job Job) {
|
|||||||
|
|
||||||
tr.Begin("save", 0)
|
tr.Begin("save", 0)
|
||||||
if err := w.store.CompleteUpload(ctx, job.UserID, job.UploadID, t); err != nil {
|
if err := w.store.CompleteUpload(ctx, job.UserID, job.UploadID, t); err != nil {
|
||||||
log.Error("upload: store transcript", "err", err)
|
log.Error("upload: store transcript", "kind", errkind.Of(err))
|
||||||
abort(domain.UploadErrTranscriptionFailed)
|
abort(domain.UploadErrTranscriptionFailed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -314,7 +339,7 @@ func (w *Worker) fail(ctx context.Context, job Job, log *slog.Logger, kind domai
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := w.store.FailUpload(ctx, job.UserID, job.UploadID, kind); err != nil {
|
if err := w.store.FailUpload(ctx, job.UserID, job.UploadID, kind); err != nil {
|
||||||
log.Error("upload: record failure", "err", err)
|
log.Error("upload: record failure", "kind", errkind.Of(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,21 +368,29 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// audioLengthProblem measures the converted WAV (16 kHz mono 16-bit PCM is 32,000 bytes a
|
// audioLengthProblem measures the converted WAV (16 kHz mono 16-bit PCM is 32,000 bytes a
|
||||||
// second after a 44-byte header) against the limit and against what the file claimed.
|
// second after a 44-byte header) against the limit. It returns the real length and whether
|
||||||
func (w *Worker) audioLengthProblem(wav string, claimed time.Duration) (domain.UploadErrorKind, bool) {
|
// that is over the limit by more than the tolerance.
|
||||||
|
func (w *Worker) audioLengthProblem(wav string) (got time.Duration, tooLong bool) {
|
||||||
if w.maxDuration <= 0 {
|
if w.maxDuration <= 0 {
|
||||||
return "", false
|
return 0, false
|
||||||
}
|
}
|
||||||
info, err := os.Stat(wav)
|
info, err := os.Stat(wav)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return 0, false
|
||||||
}
|
}
|
||||||
got := time.Duration(float64(max(info.Size()-44, 0)) / 32000 * float64(time.Second))
|
got = time.Duration(float64(max(info.Size()-44, 0)) / 32000 * float64(time.Second))
|
||||||
switch {
|
return got, got > w.maxDuration+tooLongSlack
|
||||||
case got > w.maxDuration+tooLongSlack:
|
|
||||||
return domain.UploadErrTooLong, true
|
|
||||||
case float64(got) < incompleteBelow*float64(claimed) && claimed-got > incompleteShortfall:
|
|
||||||
return domain.UploadErrIncomplete, true
|
|
||||||
}
|
}
|
||||||
return "", false
|
|
||||||
|
// shortfallNote is a remark for the audio step when the converted audio is much shorter
|
||||||
|
// than the file claimed, or "" when it is not. It is a NOTE and never a failure: a
|
||||||
|
// container header can over-estimate a complete file (a variable-bitrate MP3 without a Xing
|
||||||
|
// header claimed 79 minutes for 20 real ones), so refusing would turn away real
|
||||||
|
// recordings, while saying nothing about a truncated one would hide a partial transcript.
|
||||||
|
func shortfallNote(got, claimed time.Duration) string {
|
||||||
|
if float64(got) >= incompleteBelow*float64(claimed) || claimed-got <= incompleteShortfall {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("The audio is %s long; the file claimed %s. If the recording was cut off, upload the complete one.",
|
||||||
|
FormatDuration(got), FormatDuration(claimed))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,15 +69,86 @@ func TestWorkerDuration_JustOverTheLimitWithinTheSlackStillTranscribes(t *testin
|
|||||||
assert.Contains(t, completed, "a", "ffmpeg rounds: 20 s over a 90-minute limit is not a lie")
|
assert.Contains(t, completed, "a", "ffmpeg rounds: 20 s over a 90-minute limit is not a lie")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkerDuration_AFileThatEndsFarEarlierThanItClaimedIsRefusedNotHalfTranscribed(t *testing.T) {
|
func audioStepNote(r *wrig) string {
|
||||||
|
for _, st := range r.st.lastSteps() {
|
||||||
|
if st.Key == "audio" {
|
||||||
|
return st.Note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkerDuration_AFileThatEndsFarEarlierThanItClaimedIsStillTranscribedAndSaysSo(t *testing.T) {
|
||||||
|
// A container header can over-estimate a complete file (VBR MP3 without a Xing header
|
||||||
|
// claimed 79 minutes for 20 real ones). Refusing it was wrong; staying silent about a
|
||||||
|
// truncated one is wrong too. So it is transcribed, and the audio step carries a note.
|
||||||
r := newWRig(t, time.Minute)
|
r := newWRig(t, time.Minute)
|
||||||
w := durationWorker(t, r, 20*60, 90*time.Minute)
|
w := durationWorker(t, r, 20*60, 90*time.Minute)
|
||||||
|
|
||||||
w.Process(context.Background(), jobWith(r, t, 60*time.Minute))
|
w.Process(context.Background(), jobWith(r, t, 79*time.Minute))
|
||||||
|
|
||||||
|
completed, failed := r.st.snapshot()
|
||||||
|
assert.Empty(t, failed, "a complete recording with a bad header estimate must not be refused")
|
||||||
|
assert.Contains(t, completed, "a")
|
||||||
|
note := audioStepNote(r)
|
||||||
|
assert.Contains(t, note, "20 min", "what was really transcribed")
|
||||||
|
assert.Contains(t, note, "1 h 19 min", "what the file claimed")
|
||||||
|
assert.Contains(t, note, "cut off", "and what to do if that is not a surprise")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkerDuration_TheShortfallNoteIsPinnedAtEightyPercentAndTwoMinutes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
claimed time.Duration
|
||||||
|
wav float64
|
||||||
|
noted bool
|
||||||
|
}{
|
||||||
|
{"79 percent of an hour", 60 * time.Minute, 0.79 * 3600, true},
|
||||||
|
{"exactly 80 percent of an hour", 60 * time.Minute, 2880, false},
|
||||||
|
{"one second under 80 percent of an hour", 60 * time.Minute, 2879, true},
|
||||||
|
{"81 percent of an hour", 60 * time.Minute, 0.81 * 3600, false},
|
||||||
|
{"exactly two minutes short and under 80 percent", 6 * time.Minute, 4 * 60, false},
|
||||||
|
{"two minutes and a second short and under 80 percent", 6 * time.Minute, 4*60 - 1, true},
|
||||||
|
{"unknown claimed length", 0, 20 * 60, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := newWRig(t, time.Minute)
|
||||||
|
w := durationWorker(t, r, tt.wav, 90*time.Minute)
|
||||||
|
|
||||||
|
w.Process(context.Background(), jobWith(r, t, tt.claimed))
|
||||||
|
|
||||||
|
assert.Equal(t, tt.noted, audioStepNote(r) != "")
|
||||||
|
completed, _ := r.st.snapshot()
|
||||||
|
assert.Contains(t, completed, "a", "a note never stops the job")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkerDuration_TheTooLongToleranceIsExactlyThirtySeconds(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
wav float64
|
||||||
|
fail bool
|
||||||
|
}{
|
||||||
|
{"at the limit plus 30 s", 90*60 + 30, false},
|
||||||
|
{"at the limit plus 31 s", 90*60 + 31, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := newWRig(t, time.Minute)
|
||||||
|
w := durationWorker(t, r, tt.wav, 90*time.Minute)
|
||||||
|
|
||||||
|
w.Process(context.Background(), jobWith(r, t, 90*time.Minute))
|
||||||
|
|
||||||
_, failed := r.st.snapshot()
|
_, failed := r.st.snapshot()
|
||||||
assert.Equal(t, domain.UploadErrIncomplete, failed["a"], "a recording truncated at a third must not return a third of a transcript with no warning")
|
if tt.fail {
|
||||||
assert.Empty(t, r.tr.paths)
|
assert.Equal(t, domain.UploadErrTooLong, failed["a"])
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, failed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkerDuration_AnEstimateThatIsMerelyALittleOffIsNotTruncation(t *testing.T) {
|
func TestWorkerDuration_AnEstimateThatIsMerelyALittleOffIsNotTruncation(t *testing.T) {
|
||||||
|
|||||||
@@ -197,3 +197,19 @@ func TestWorkerProgress_AFailureLogsAKindNeverTheErrorTextOrThePath(t *testing.T
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkerProgress_AStoreFailureLogsItsSQLStateNeverTheValueItQuotes(t *testing.T) {
|
||||||
|
// A Postgres error can quote the offending value; for a transcript row that is the
|
||||||
|
// transcript (fourth adversarial review). The log gets the SQLSTATE.
|
||||||
|
r := newWRig(t, time.Minute)
|
||||||
|
r.st.completeErr = dbErr{}
|
||||||
|
buf := &syncLog{}
|
||||||
|
w := newProgressWorker(t, r, r.tr, uploads.WithLogger(slog.New(slog.NewTextHandler(buf, nil))))
|
||||||
|
|
||||||
|
w.Process(context.Background(), r.spool(t, "a"))
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
assert.NotContains(t, out, "zebrakaka")
|
||||||
|
assert.Contains(t, out, "upload: store transcript")
|
||||||
|
assert.Contains(t, out, "kind=sql_22P02")
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,3 +78,31 @@ func TestWorkerSubmit_AJobThatHasLeftTheQueueFreesItsPlace(t *testing.T) {
|
|||||||
|
|
||||||
assert.NoError(t, w.Submit(r.spool(t, "second")), "the cap counts jobs waiting, not jobs ever submitted")
|
assert.NoError(t, w.Submit(r.spool(t, "second")), "the cap counts jobs waiting, not jobs ever submitted")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkerHasRoom_SaysWhetherASubmitWouldBeAccepted(t *testing.T) {
|
||||||
|
w := queueWorker(t, 3, 2)
|
||||||
|
assert.True(t, w.HasRoom("alice"))
|
||||||
|
require.NoError(t, submit(w, "alice", 1))
|
||||||
|
require.NoError(t, submit(w, "alice", 2))
|
||||||
|
|
||||||
|
assert.False(t, w.HasRoom("alice"), "alice is at her share")
|
||||||
|
assert.True(t, w.HasRoom("bob"), "bob is not")
|
||||||
|
require.NoError(t, submit(w, "bob", 1))
|
||||||
|
assert.False(t, w.HasRoom("carol"), "the queue as a whole is at its cap")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkerHasRoom_AgreesWithSubmitAtEveryStepAndIsFalseOnceStopped(t *testing.T) {
|
||||||
|
w := queueWorker(t, 2, 1)
|
||||||
|
for i, user := range []string{"a", "a", "b", "c"} {
|
||||||
|
room := w.HasRoom(user)
|
||||||
|
err := submit(w, user, i)
|
||||||
|
assert.Equal(t, room, err == nil, "step %d (%s): HasRoom=%v but Submit returned %v", i, user, room, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := newWRig(t, time.Minute)
|
||||||
|
stopped := uploads.NewWorker(r.st, r.conv, r.tr, r.tmp, time.Minute)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
stopped.Run(ctx)
|
||||||
|
assert.False(t, stopped.HasRoom("a"), "a worker that has stopped has no room")
|
||||||
|
}
|
||||||
|
|||||||
@@ -324,3 +324,11 @@ func TestWorkerRun_StoppingDeletesQueuedAudioAndRefusesNewJobs(t *testing.T) {
|
|||||||
assert.Empty(t, r.leftovers(t), "audio of queued jobs is deleted on stop: the rows are swept to interrupted at next start")
|
assert.Empty(t, r.leftovers(t), "audio of queued jobs is deleted on stop: the rows are swept to interrupted at next start")
|
||||||
assert.ErrorIs(t, r.w.Submit(r.spool(t, "c")), uploads.ErrStopped)
|
assert.ErrorIs(t, r.w.Submit(r.spool(t, "c")), uploads.ErrStopped)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dbErr is an error shaped like pgx's: a message that quotes a value, and an SQLSTATE.
|
||||||
|
type dbErr struct{}
|
||||||
|
|
||||||
|
func (dbErr) Error() string {
|
||||||
|
return `ERROR: invalid input syntax for type json: "zebrakaka secret words"`
|
||||||
|
}
|
||||||
|
func (dbErr) SQLState() string { return "22P02" }
|
||||||
|
|||||||
+49
-3
@@ -95,6 +95,9 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var tooBig *http.MaxBytesError
|
var tooBig *http.MaxBytesError
|
||||||
|
if a.abandoned(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if !errors.As(err, &tooBig) {
|
if !errors.As(err, &tooBig) {
|
||||||
// A malformed multipart body (a header with a control byte, a truncated
|
// A malformed multipart body (a header with a control byte, a truncated
|
||||||
// part) is the sender's to fix: a 400, not a server error, and the error
|
// part) is the sender's to fix: a 400, not a server error, and the error
|
||||||
@@ -110,6 +113,9 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
case "title":
|
case "title":
|
||||||
b, err := io.ReadAll(io.LimitReader(part, maxTitleRunes*utf8.UTFMax))
|
b, err := io.ReadAll(io.LimitReader(part, maxTitleRunes*utf8.UTFMax))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if a.abandoned(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "That upload could not be read. Please try again.")
|
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "That upload could not be read. Please try again.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -130,10 +136,27 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
|
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// abandoned reports, logs at INFO and answers 499, because the client has already gone: its request
|
||||||
|
// context is cancelled, so there is nobody to answer and rendering a page would run its
|
||||||
|
// database read on a dead context and log a false ERROR. A hang-up mid-upload is routine.
|
||||||
|
func (a *App) abandoned(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if r.Context().Err() == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a.logger().Info("upload abandoned", "reason", "client_gone")
|
||||||
|
// 499 (the "client closed request" convention): a client that half-closed after sending
|
||||||
|
// can still read, and an unwritten status would reach it as a false, empty 200.
|
||||||
|
w.WriteHeader(499)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// uploadFailure maps an Accept (or multipart) error to a response: a validation
|
// uploadFailure maps an Accept (or multipart) error to a response: a validation
|
||||||
// rejection is the user's to fix (422, or 413 for size) and carries its own
|
// rejection is the user's to fix (422, or 413 for size) and carries its own
|
||||||
// message; anything else is ours.
|
// message; anything else is ours.
|
||||||
func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID string, err error) {
|
func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID string, err error) {
|
||||||
|
if a.abandoned(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var rej *uploads.RejectError
|
var rej *uploads.RejectError
|
||||||
var tooBig *http.MaxBytesError
|
var tooBig *http.MaxBytesError
|
||||||
switch {
|
switch {
|
||||||
@@ -152,6 +175,11 @@ func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID strin
|
|||||||
a.logger().Info("upload rejected", "reason", string(uploads.ReasonTooLarge))
|
a.logger().Info("upload rejected", "reason", string(uploads.ReasonTooLarge))
|
||||||
a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge,
|
a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge,
|
||||||
"This file is larger than the upload limit.")
|
"This file is larger than the upload limit.")
|
||||||
|
case errors.Is(err, uploads.ErrClientRead):
|
||||||
|
// An error reading the CLIENT's body: theirs to retry, not a server fault. Only that
|
||||||
|
// marker counts: io.ErrUnexpectedEOF alone is also what a database drop looks like.
|
||||||
|
a.logger().Info("upload rejected", "reason", "truncated")
|
||||||
|
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "The upload was cut off before it finished. Please try again.")
|
||||||
default:
|
default:
|
||||||
// Not err: it can carry the file's name, a spool path or a database value.
|
// Not err: it can carry the file's name, a spool path or a database value.
|
||||||
a.logger().Error("upload failed", "kind", errkind.Of(err))
|
a.logger().Error("upload failed", "kind", errkind.Of(err))
|
||||||
@@ -215,9 +243,26 @@ func titleFromFilename(name string) string {
|
|||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasNote reports whether any phase carries a note. A note is the only warning for a
|
||||||
|
// recording that is shorter than its file claimed, so a record that has one is shown open.
|
||||||
|
func hasNote(steps []domain.ProgressStep) bool {
|
||||||
|
for _, s := range steps {
|
||||||
|
if s.Note != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// uuidShape is the form of an upload id. Anything else cannot be one, so it is a 404.
|
// uuidShape is the form of an upload id. Anything else cannot be one, so it is a 404.
|
||||||
var uuidShape = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
var uuidShape = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||||
|
|
||||||
|
// isBidiControl reports the invisible characters that change text direction. Emoji joiners
|
||||||
|
// (ZWJ, ZWNJ) and variation selectors are not among them and are kept.
|
||||||
|
func isBidiControl(r rune) bool {
|
||||||
|
return r >= 0x202A && r <= 0x202E || r >= 0x2066 && r <= 0x2069 || r == 0x200E || r == 0x200F || r == 0x061C
|
||||||
|
}
|
||||||
|
|
||||||
// cleanTitle makes a title safe to store and show: invalid UTF-8 dropped, control
|
// cleanTitle makes a title safe to store and show: invalid UTF-8 dropped, control
|
||||||
// characters (NUL, newlines, escapes) turned into spaces, runs of space collapsed, trimmed
|
// characters (NUL, newlines, escapes) turned into spaces, runs of space collapsed, trimmed
|
||||||
// and clipped. A title is cosmetic; bad bytes in it must never fail an upload, and Postgres
|
// and clipped. A title is cosmetic; bad bytes in it must never fail an upload, and Postgres
|
||||||
@@ -225,7 +270,10 @@ var uuidShape = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4
|
|||||||
func cleanTitle(s string) string {
|
func cleanTitle(s string) string {
|
||||||
s = strings.ToValidUTF8(s, "")
|
s = strings.ToValidUTF8(s, "")
|
||||||
s = strings.Map(func(r rune) rune {
|
s = strings.Map(func(r rune) rune {
|
||||||
if unicode.IsControl(r) {
|
switch {
|
||||||
|
case isBidiControl(r):
|
||||||
|
return -1 // dropped: U+202E would reverse the rest of the title on its owner's page
|
||||||
|
case unicode.IsControl(r):
|
||||||
return ' '
|
return ' '
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
@@ -250,8 +298,6 @@ func uploadFailureMessage(k domain.UploadErrorKind) string {
|
|||||||
return "Too many recordings were waiting to be transcribed when this one was uploaded. Please upload it again in a few minutes."
|
return "Too many recordings were waiting to be transcribed when this one was uploaded. Please upload it again in a few minutes."
|
||||||
case domain.UploadErrTooLong:
|
case domain.UploadErrTooLong:
|
||||||
return "This recording is longer than the limit once its audio is read (the file's header said otherwise). Please upload a shorter recording."
|
return "This recording is longer than the limit once its audio is read (the file's header said otherwise). Please upload a shorter recording."
|
||||||
case domain.UploadErrIncomplete:
|
|
||||||
return "This recording ends much earlier than the file says it should; it may be cut off or damaged. Please upload the complete recording."
|
|
||||||
case domain.UploadErrTranscriptionFailed:
|
case domain.UploadErrTranscriptionFailed:
|
||||||
return "Transcription failed on every available service. Please upload it again later."
|
return "Transcription failed on every available service. Please upload it again later."
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
|
|||||||
</p>
|
</p>
|
||||||
@reconciliationNote(u.Reconciliation)
|
@reconciliationNote(u.Reconciliation)
|
||||||
if len(u.Progress) > 0 {
|
if len(u.Progress) > 0 {
|
||||||
<details class="what-was-done">
|
<details class="what-was-done" if hasNote(u.Progress) { open }>
|
||||||
<summary>What was done</summary>
|
<summary>What was done</summary>
|
||||||
@progressSteps(u.Progress, now, false)
|
@progressSteps(u.Progress, now, false)
|
||||||
</details>
|
</details>
|
||||||
@@ -218,6 +218,9 @@ templ progressSteps(steps []domain.ProgressStep, now time.Time, live bool) {
|
|||||||
if t := stepTime(s, now); t != "" {
|
if t := stepTime(s, now); t != "" {
|
||||||
<span class="step-time">{ t }</span>
|
<span class="step-time">{ t }</span>
|
||||||
}
|
}
|
||||||
|
if s.Note != "" {
|
||||||
|
<span class="step-note">{ s.Note }</span>
|
||||||
|
}
|
||||||
if s.State == domain.StepRunning && s.Total > 0 {
|
if s.State == domain.StepRunning && s.Total > 0 {
|
||||||
<progress class="step-bar" value={ strconv.Itoa(s.Done) } max={ strconv.Itoa(s.Total) }></progress>
|
<progress class="step-bar" value={ strconv.Itoa(s.Done) } max={ strconv.Itoa(s.Total) }></progress>
|
||||||
<span class="step-count">{ strconv.Itoa(s.Done) } of { strconv.Itoa(s.Total) }</span>
|
<span class="step-count">{ strconv.Itoa(s.Done) } of { strconv.Itoa(s.Total) }</span>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package web_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -60,3 +61,33 @@ func TestUploadPost_AMalformedMultipartHeaderIsA400AndItsTextIsNotLogged(t *test
|
|||||||
assert.Empty(t, sub.jobs)
|
assert.Empty(t, sub.jobs)
|
||||||
assert.NotContains(t, buf.String(), "zebrakaka", "a file name is private content")
|
assert.NotContains(t, buf.String(), "zebrakaka", "a file name is private content")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUploadPost_EveryBidirectionalControlCharacterIsStrippedFromATitle(t *testing.T) {
|
||||||
|
// U+202E reverses the text that follows it, so a title could render reversed on its
|
||||||
|
// owner's own page (second adversarial review). Every one of the twelve is pinned.
|
||||||
|
controls := []rune{0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069, 0x200E, 0x200F, 0x061C}
|
||||||
|
for _, c := range controls {
|
||||||
|
t.Run(fmt.Sprintf("U+%04X", c), func(t *testing.T) {
|
||||||
|
app, sub := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
|
||||||
|
rec := do(t, app, uploadReq(t, "Sprint"+string(c)+"review", "a.m4a", []byte("x")))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||||
|
var title string
|
||||||
|
require.NoError(t, rawPool(t).QueryRow(context.Background(), `SELECT title FROM uploaded_transcripts WHERE id = $1`, sub.jobs[0].UploadID).Scan(&title))
|
||||||
|
assert.Equal(t, "Sprintreview", title, "dropped, not replaced by a space: a space would leave a visible gap")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadPost_SwedishLettersAndEmojiSurviveTheTitleCleaning(t *testing.T) {
|
||||||
|
app, sub := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
|
||||||
|
// Includes a ZWJ sequence (woman + ZWJ + laptop) and a ZWNJ: joiners are not direction controls.
|
||||||
|
rec := do(t, app, uploadReq(t, "Möte åäö 🎙️ 👩\u200d💻 a\u200cb", "a.m4a", []byte("x")))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||||
|
var title string
|
||||||
|
require.NoError(t, rawPool(t).QueryRow(context.Background(), `SELECT title FROM uploaded_transcripts WHERE id = $1`, sub.jobs[0].UploadID).Scan(&title))
|
||||||
|
assert.Equal(t, "Möte åäö 🎙️ 👩\u200d💻 a\u200cb", title, "joiners and variation selectors belong to the text")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A client that hangs up in the middle of an upload is routine, not a server fault. The
|
||||||
|
// in-process recorder cannot see it (its context never cancels), so this talks to a real
|
||||||
|
// listener (third adversarial review: over a socket every such case logged an ERROR and
|
||||||
|
// half-close returned a 500, because the error page was rendered on a cancelled context).
|
||||||
|
|
||||||
|
// body positions at which the client can hang up: inside the title field, inside the file
|
||||||
|
// part's own headers, and mid-file. Every one must be handled alike (fourth adversarial
|
||||||
|
// review: the title branch had no guard and still logged an ERROR).
|
||||||
|
const (
|
||||||
|
inTitle = "in the title"
|
||||||
|
inHeaders = "in the file part's headers"
|
||||||
|
midFile = "mid-file"
|
||||||
|
)
|
||||||
|
|
||||||
|
func partialUpload(t *testing.T, addr, where string, hangUp func(*net.TCPConn)) {
|
||||||
|
t.Helper()
|
||||||
|
c, err := net.Dial("tcp", addr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
tc := c.(*net.TCPConn)
|
||||||
|
const boundary = "xBOUNDARYx"
|
||||||
|
var body string
|
||||||
|
switch where {
|
||||||
|
case inTitle:
|
||||||
|
body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nhalf a tit"
|
||||||
|
case inHeaders:
|
||||||
|
body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filena"
|
||||||
|
default:
|
||||||
|
body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.m4a\"\r\nContent-Type: audio/mp4\r\n\r\n" + strings.Repeat("a", 4000)
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(tc, "POST /uploads HTTP/1.1\r\nHost: x\r\nContent-Type: multipart/form-data; boundary=%s\r\nContent-Length: 200000\r\n\r\n%s", boundary, body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
time.Sleep(50 * time.Millisecond) // let the server start reading
|
||||||
|
hangUp(tc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadPost_AClientThatHangsUpAnywhereInTheBodyIsLoggedAtInfoAndNeverAsAnError(t *testing.T) {
|
||||||
|
hangUps := map[string]func(*net.TCPConn){
|
||||||
|
"a half close": func(c *net.TCPConn) { _ = c.CloseWrite(); time.Sleep(100 * time.Millisecond); _ = c.Close() },
|
||||||
|
"a full close": func(c *net.TCPConn) { _ = c.Close() },
|
||||||
|
"a reset (RST)": func(c *net.TCPConn) { _ = c.SetLinger(0); _ = c.Close() },
|
||||||
|
}
|
||||||
|
for _, where := range []string{inTitle, inHeaders, midFile} {
|
||||||
|
for name, hangUp := range hangUps {
|
||||||
|
t.Run(where+", "+name, func(t *testing.T) {
|
||||||
|
app, sub := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
buf := &lockedBuf{}
|
||||||
|
app.Log = slog.New(slog.NewTextHandler(buf, nil))
|
||||||
|
srv := httptest.NewServer(app.Router())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
partialUpload(t, strings.TrimPrefix(srv.URL, "http://"), where, hangUp)
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
out := buf.String()
|
||||||
|
return strings.Contains(out, "upload abandoned") || strings.Contains(out, "upload rejected") || strings.Contains(out, "level=ERROR")
|
||||||
|
}, 3*time.Second, 20*time.Millisecond, "the server never finished handling the aborted upload")
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
out := buf.String()
|
||||||
|
assert.NotContains(t, out, "level=ERROR", "a client hanging up is not a server fault:\n%s", out)
|
||||||
|
assert.Empty(t, sub.jobs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// goneMidAccept is an Uploader during whose Accept the client disappears: the request's
|
||||||
|
// context is cancelled, then Accept fails the way a read from a dead connection does.
|
||||||
|
type goneMidAccept struct{ cancel context.CancelFunc }
|
||||||
|
|
||||||
|
func (g goneMidAccept) Accept(context.Context, string, string, io.Reader) (string, error) {
|
||||||
|
g.cancel()
|
||||||
|
return "", fmt.Errorf("receive: %w", io.ErrClosedPipe)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadPost_AnAbandonedRequestGetsA499NotAnImplicitEmpty200(t *testing.T) {
|
||||||
|
// If the context is cancelled but the connection can still be written to (a client that
|
||||||
|
// half-closed after sending), a missing status becomes 200 with an empty body: a false
|
||||||
|
// success (fourth adversarial review).
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
buf := &lockedBuf{}
|
||||||
|
app.Log = slog.New(slog.NewTextHandler(buf, nil))
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
app.Uploads = goneMidAccept{cancel: cancel}
|
||||||
|
|
||||||
|
rec := do(t, app, uploadReq(t, "t", "a.m4a", []byte("x")).WithContext(ctx))
|
||||||
|
|
||||||
|
assert.Equal(t, 499, rec.Code, "never an implicit 200 for an upload that was not accepted")
|
||||||
|
assert.Contains(t, buf.String(), "upload abandoned")
|
||||||
|
assert.NotContains(t, buf.String(), "level=ERROR")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadPost_ADatabaseDropThatLooksLikeAnEOFIsStillAServerError(t *testing.T) {
|
||||||
|
// pgx turns a connection that drops mid-message into io.ErrUnexpectedEOF. Mapping that
|
||||||
|
// sentinel to "your upload was cut off" (a 400) would hide a real fault: only an error
|
||||||
|
// from reading the CLIENT's body is theirs (third adversarial review).
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
buf := &lockedBuf{}
|
||||||
|
app.Log = slog.New(slog.NewTextHandler(buf, nil))
|
||||||
|
app.Uploads = failingUploader{err: fmt.Errorf("failed to receive message: %w", io.ErrUnexpectedEOF)}
|
||||||
|
|
||||||
|
rec := do(t, app, uploadReq(t, "t", "a.m4a", []byte("x")))
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||||
|
assert.Contains(t, buf.String(), "level=ERROR")
|
||||||
|
}
|
||||||
@@ -465,7 +465,6 @@ func TestUploadStatus_ALengthFailureIsExplainedInWordsAndNotAsAGenericError(t *t
|
|||||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
tests := []struct{ kind, want string }{
|
tests := []struct{ kind, want string }{
|
||||||
{"audio_too_long", "longer than the limit"},
|
{"audio_too_long", "longer than the limit"},
|
||||||
{"audio_incomplete", "ends much earlier than"},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.kind, func(t *testing.T) {
|
t.Run(tt.kind, func(t *testing.T) {
|
||||||
|
|||||||
@@ -251,3 +251,37 @@ func TestUploadProgress_TheFullPageCarriesTheVerySameProgressListAsTheFragment(t
|
|||||||
assert.Equal(t, stepsBlock(t, fragment), stepsBlock(t, full),
|
assert.Equal(t, stepsBlock(t, fragment), stepsBlock(t, full),
|
||||||
"the page a user opens and the fragment HTMX swaps in must show the same list, or the display jumps on the first poll")
|
"the page a user opens and the fragment HTMX swaps in must show the same list, or the display jumps on the first poll")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUploadProgress_AStepNoteIsShownDirectlyUnderItsStepAndEscaped(t *testing.T) {
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
fixedClock(app)
|
||||||
|
id := seedUpload(t, userID, "m", "transcribing", "")
|
||||||
|
steps := twoEngineRun()
|
||||||
|
steps[1].Note = "The audio is 20 min long; the file claimed 1 h 19 min. <b>cut off</b>?"
|
||||||
|
seedProgress(t, id, steps, progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second))
|
||||||
|
|
||||||
|
b := statusBody(t, app, id)
|
||||||
|
|
||||||
|
audio := strings.Index(b, "Preparing the audio")
|
||||||
|
note := strings.Index(b, "The audio is 20 min long")
|
||||||
|
next := strings.Index(b, "Transcribing with iguana/pianissimo")
|
||||||
|
require.True(t, audio >= 0 && note > audio && next > note, "the note must sit between its own step and the next one:\n%s", b)
|
||||||
|
assert.Contains(t, b, "step-note")
|
||||||
|
assert.NotContains(t, b, "<b>cut off</b>", "a note is words, never markup")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadProgress_ADonePageShowsItsRecordOpenWhenAStepHasANote(t *testing.T) {
|
||||||
|
// A truncated recording must not look like a clean one: the note that replaced the
|
||||||
|
// refusal is the only warning, so it cannot sit inside a collapsed list (third review).
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
fixedClock(app)
|
||||||
|
noted := seedUpload(t, userID, "noted", "done", "Synthetic transcript text.")
|
||||||
|
steps := []domain.ProgressStep{step("audio", "Preparing the audio", domain.StepDone, 20*time.Minute, 40*time.Second, 0, 0)}
|
||||||
|
steps[0].Note = "The audio is 20 min long; the file claimed 1 h 19 min."
|
||||||
|
seedProgress(t, noted, steps, progressNow.Add(-time.Minute), progressNow.Add(-time.Minute))
|
||||||
|
clean := seedUpload(t, userID, "clean", "done", "Synthetic transcript text.")
|
||||||
|
seedProgress(t, clean, []domain.ProgressStep{step("audio", "Preparing the audio", domain.StepDone, 20*time.Minute, 40*time.Second, 0, 0)}, progressNow.Add(-time.Minute), progressNow.Add(-time.Minute))
|
||||||
|
|
||||||
|
assert.Contains(t, statusBody(t, app, noted), `<details class="what-was-done" open>`)
|
||||||
|
assert.NotContains(t, statusBody(t, app, clean), `<details class="what-was-done" open>`, "a clean record stays collapsed")
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ func TestRenderUploadProgressStatesForReview(t *testing.T) {
|
|||||||
step("save", "Saving the transcript", domain.StepDone, time.Minute, time.Second, 0, 0),
|
step("save", "Saving the transcript", domain.StepDone, time.Minute, time.Second, 0, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
noted := twoEngineRun()
|
||||||
|
noted[1].Note = "The audio is 20 min long; the file claimed 1 h 19 min. If the recording was cut off, upload the complete one."
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name, status, content string
|
name, status, content string
|
||||||
steps []domain.ProgressStep
|
steps []domain.ProgressStep
|
||||||
@@ -58,6 +61,7 @@ func TestRenderUploadProgressStatesForReview(t *testing.T) {
|
|||||||
{"lost", "transcribing", "", twoEngineRun(), 3 * time.Minute, 3 * time.Minute},
|
{"lost", "transcribing", "", twoEngineRun(), 3 * time.Minute, 3 * time.Minute},
|
||||||
{"failed", "failed", "transcription_failed", failed, 2 * time.Minute, 2 * time.Minute},
|
{"failed", "failed", "transcription_failed", failed, 2 * time.Minute, 2 * time.Minute},
|
||||||
{"done", "done", "Synthetic transcript text for the review.", done, time.Minute, time.Minute},
|
{"done", "done", "Synthetic transcript text for the review.", done, time.Minute, time.Minute},
|
||||||
|
{"noted", "transcribing", "", noted, 10 * time.Second, 4 * time.Second},
|
||||||
{"queued", "queued", "", nil, 0, 0},
|
{"queued", "queued", "", nil, 0, 0},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
|
|||||||
@@ -62,11 +62,11 @@ func TestUploadStatus_ASpotShowsTheWordsJustBeforeIt(t *testing.T) {
|
|||||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||||
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":1,"changed":1,
|
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":1,"changed":1,
|
||||||
"contested":[{"seconds":2640,"base":"vanligt","alt":"KORT","used":"alt","unsure":false,"before":"vi skickar in ett"}]}`)
|
"contested":[{"seconds":2640,"base":"vanligt","alt":"KORT","used":"alt","unsure":false,"before":"grannen målar ett"}]}`)
|
||||||
|
|
||||||
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||||
|
|
||||||
assert.Contains(t, b, "vi skickar in ett", "the words before the spot are how a reader finds it in the audio")
|
assert.Contains(t, b, "grannen målar ett", "the words before the spot are how a reader finds it in the audio")
|
||||||
assert.Contains(t, b, "vanligt")
|
assert.Contains(t, b, "vanligt")
|
||||||
assert.Contains(t, b, "KORT")
|
assert.Contains(t, b, "KORT")
|
||||||
}
|
}
|
||||||
|
|||||||
+144
-115
@@ -312,7 +312,17 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(u.Progress) > 0 {
|
if len(u.Progress) > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<details class=\"what-was-done\"><summary>What was done</summary>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<details class=\"what-was-done\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if hasNote(u.Progress) {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " open")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "><summary>What was done</summary>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -320,13 +330,13 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</details> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</details> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if chatEnabled {
|
if chatEnabled {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<p><a class=\"btn\" href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<p><a class=\"btn\" href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -339,12 +349,12 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">Ask about this recording</a></p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">Ask about this recording</a></p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<details open><summary>Transcript</summary><pre class=\"transcript\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<details open><summary>Transcript</summary><pre class=\"transcript\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -357,12 +367,12 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</pre></details></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</pre></details></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case domain.UploadFailed:
|
case domain.UploadFailed:
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"upload-status upload-failed flash flash-error\" role=\"alert\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"upload-status upload-failed flash flash-error\" role=\"alert\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -370,7 +380,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -383,12 +393,12 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(u.Progress) > 0 {
|
if len(u.Progress) > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<details class=\"what-was-done\" open><summary>Where it stopped</summary>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<details class=\"what-was-done\" open><summary>Where it stopped</summary>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -396,17 +406,17 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</details>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</details>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"upload-status upload-working\" role=\"status\" hx-get=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div class=\"upload-status upload-working\" role=\"status\" hx-get=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -419,7 +429,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-trigger=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" hx-trigger=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -432,7 +442,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" hx-swap=\"outerHTML\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" hx-swap=\"outerHTML\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -447,7 +457,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -456,18 +466,18 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<p>Transcribing… a long recording takes a few minutes.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<p>Transcribing… a long recording takes a few minutes.</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<p>Queued — waiting for its turn to be transcribed.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<p>Queued — waiting for its turn to be transcribed.</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -511,7 +521,7 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<article class=\"account\"><h1>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<article class=\"account\"><h1>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -524,7 +534,7 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</h1><section id=\"chat-section\" class=\"chat-dock chat-dock-open\"><h2 class=\"chat-heading\">Ask about this recording</h2><p class=\"chat-scope muted\">Answers come only from this recording's transcript, which is private to you.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</h1><section id=\"chat-section\" class=\"chat-dock chat-dock-open\"><h2 class=\"chat-heading\">Ask about this recording</h2><p class=\"chat-scope muted\">Answers come only from this recording's transcript, which is private to you.</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -532,7 +542,7 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</section><p><a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</section><p><a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -545,7 +555,7 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">Back to the transcript</a></p></article>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">Back to the transcript</a></p></article>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -585,7 +595,7 @@ func videoNotice(hadVideo bool) templ.Component {
|
|||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
if hadVideo {
|
if hadVideo {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<p class=\"muted video-notice\">This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<p class=\"muted video-notice\">This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -622,7 +632,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if r != nil {
|
if r != nil {
|
||||||
switch r.Status {
|
switch r.Status {
|
||||||
case domain.ReconcileDone:
|
case domain.ReconcileDone:
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"reconciliation\"><p>Reconciled from two transcripts (")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<div class=\"reconciliation\"><p>Reconciled from two transcripts (")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -635,7 +645,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " and ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " and ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -648,7 +658,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "): ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "): ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -661,7 +671,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " differences, ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " differences, ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -674,7 +684,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " settled automatically, ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " settled automatically, ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -687,7 +697,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " decided by the model (")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " decided by the model (")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -700,7 +710,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "), ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "), ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -713,17 +723,17 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " uncertain.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " uncertain.</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(shownSpots(r)) > 0 {
|
if len(shownSpots(r)) > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<details><summary>Spots to check against the audio</summary><ul>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<details><summary>Spots to check against the audio</summary><ul>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, c := range shownSpots(r) {
|
for _, c := range shownSpots(r) {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<li>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<li>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -736,12 +746,12 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ": ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ": ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if c.Before != "" {
|
if c.Before != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"muted\">…")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<span class=\"muted\">…")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -754,7 +764,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -768,7 +778,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " / ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " / ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -781,12 +791,12 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " — ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " — ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if c.Unsure {
|
if c.Unsure {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<span class=\"chip chip-warn\">uncertain</span> kept ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<span class=\"chip chip-warn\">uncertain</span> kept ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -800,7 +810,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<span class=\"chip\">changed</span> used ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<span class=\"chip\">changed</span> used ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -814,17 +824,17 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</li>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</li>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</ul>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</ul>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if moreSpots(r) > 0 {
|
if moreSpots(r) > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<p class=\"muted\">and ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<p class=\"muted\">and ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -837,22 +847,22 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " more not listed</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " more not listed</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</details>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</details>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case domain.ReconcileSingle:
|
case domain.ReconcileSingle:
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<p class=\"muted reconciliation\">Only one engine produced this transcript (")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<p class=\"muted reconciliation\">Only one engine produced this transcript (")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -865,12 +875,12 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "), so there was no second opinion to reconcile it with.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "), so there was no second opinion to reconcile it with.</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
case domain.ReconcileSkipped:
|
case domain.ReconcileSkipped:
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<p class=\"muted reconciliation\">Two transcripts were made but could not be reconciled (")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<p class=\"muted reconciliation\">Two transcripts were made but could not be reconciled (")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -883,7 +893,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "); the text shown is ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "); the text shown is ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -896,7 +906,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "'s, unreconciled.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "'s, unreconciled.</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -932,7 +942,7 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
|
|||||||
templ_7745c5c3_Var43 = templ.NopComponent
|
templ_7745c5c3_Var43 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<ol class=\"steps\" aria-label=\"Progress\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<ol class=\"steps\" aria-label=\"Progress\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -942,7 +952,7 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<li class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<li class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -955,32 +965,32 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if s.State == domain.StepRunning {
|
if s.State == domain.StepRunning {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " aria-current=\"step\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " aria-current=\"step\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, ">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, ">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if s.State == domain.StepRunning && live {
|
if s.State == domain.StepRunning && live {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span class=\"step-mark\" aria-hidden=\"true\"><span class=\"spinner\"></span></span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span class=\"step-mark\" aria-hidden=\"true\"><span class=\"spinner\"></span></span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else if s.State == domain.StepRunning {
|
} else if s.State == domain.StepRunning {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"step-mark step-mark-warn\" aria-hidden=\"true\">!</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<span class=\"step-mark step-mark-warn\" aria-hidden=\"true\">!</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span class=\"step-mark\" aria-hidden=\"true\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<span class=\"step-mark\" aria-hidden=\"true\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -993,12 +1003,12 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<span class=\"step-label\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span class=\"step-label\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1011,12 +1021,12 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if t := stepTime(s, now); t != "" {
|
if t := stepTime(s, now); t != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span class=\"step-time\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<span class=\"step-time\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1029,75 +1039,94 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Note != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<span class=\"step-note\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var49 string
|
||||||
|
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(s.Note)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 222, Col: 37}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.State == domain.StepRunning && s.Total > 0 {
|
if s.State == domain.StepRunning && s.Total > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<progress class=\"step-bar\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<progress class=\"step-bar\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var49 string
|
|
||||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.Itoa(s.Done))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 222, Col: 60}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" max=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var50 string
|
var templ_7745c5c3_Var50 string
|
||||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.Itoa(s.Total))
|
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.Itoa(s.Done))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 222, Col: 90}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 225, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\"></progress> <span class=\"step-count\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" max=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var51 string
|
var templ_7745c5c3_Var51 string
|
||||||
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Done))
|
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.Itoa(s.Total))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 223, Col: 52}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 225, Col: 90}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " of ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\"></progress> <span class=\"step-count\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var52 string
|
var templ_7745c5c3_Var52 string
|
||||||
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Total))
|
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Done))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 223, Col: 81}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 226, Col: 52}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " of ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var53 string
|
||||||
|
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Total))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 226, Col: 81}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</li>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</li>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</ol>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</ol>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1123,45 +1152,45 @@ func healthNote(h jobHealth, silent time.Duration) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var53 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var54 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var53 == nil {
|
if templ_7745c5c3_Var54 == nil {
|
||||||
templ_7745c5c3_Var53 = templ.NopComponent
|
templ_7745c5c3_Var54 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
switch h {
|
switch h {
|
||||||
case healthLost:
|
case healthLost:
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<p class=\"step-health step-health-lost\" role=\"alert\">The service stopped reporting ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<p class=\"step-health step-health-lost\" role=\"alert\">The service stopped reporting ")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var54 string
|
|
||||||
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 236, Col: 54}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, " ago. It may have restarted or crashed. This page keeps checking; if nothing changes, upload the recording again.</p>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
case healthQuiet:
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<p class=\"step-health step-health-quiet\">Nothing new for ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var55 string
|
var templ_7745c5c3_Var55 string
|
||||||
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent))
|
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 241, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 239, Col: 54}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, ": the model may be slow. The service is still running, and the job gives up on its own if it takes too long.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, " ago. It may have restarted or crashed. This page keeps checking; if nothing changes, upload the recording again.</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
case healthQuiet:
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<p class=\"step-health step-health-quiet\">Nothing new for ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var56 string
|
||||||
|
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 244, Col: 40}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, ": the model may be slow. The service is still running, and the job gives up on its own if it takes too long.</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -780,6 +780,7 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
|
|||||||
.step-skipped { opacity: .7; }
|
.step-skipped { opacity: .7; }
|
||||||
.step-pending { opacity: .75; }
|
.step-pending { opacity: .75; }
|
||||||
.step-bar { flex: none; width: calc(100% - 1.6rem); margin: 0 0 0 1.6rem; height: .5rem; accent-color: var(--accent); }
|
.step-bar { flex: none; width: calc(100% - 1.6rem); margin: 0 0 0 1.6rem; height: .5rem; accent-color: var(--accent); }
|
||||||
|
.step-note { flex: none; width: calc(100% - 1.6rem); margin-left: 1.6rem; font-weight: 400; color: var(--muted); font-family: system-ui, -apple-system, sans-serif; font-size: .82rem; }
|
||||||
.step-count { flex: none; width: calc(100% - 1.6rem); margin-left: 1.6rem; font-weight: 400; color: var(--muted); }
|
.step-count { flex: none; width: calc(100% - 1.6rem); margin-left: 1.6rem; font-weight: 400; color: var(--muted); }
|
||||||
.step-running .step-mark-warn { color: var(--err-fg); }
|
.step-running .step-mark-warn { color: var(--err-fg); }
|
||||||
.spinner { display: inline-block; vertical-align: -.1em; width: .8rem; height: .8rem; box-sizing: border-box; border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: tapir-spin 1s linear infinite; }
|
.spinner { display: inline-block; vertical-align: -.1em; width: .8rem; height: .8rem; box-sizing: border-box; border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: tapir-spin 1s linear infinite; }
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ var scenarioCoverage = map[string]string{
|
|||||||
"A forged cloud model is never sent the transcript": "TestUploadChatPost_AForgedCloudModelIsReplacedByALocalOneBeforeTheTranscriptIsSent",
|
"A forged cloud model is never sent the transcript": "TestUploadChatPost_AForgedCloudModelIsReplacedByALocalOneBeforeTheTranscriptIsSent",
|
||||||
"A wedged engine does not use up the whole job": "TestChain_AHungPrimaryReachesTheFallbackWhenItHasItsOwnDeadline",
|
"A wedged engine does not use up the whole job": "TestChain_AHungPrimaryReachesTheFallbackWhenItHasItsOwnDeadline",
|
||||||
"A file that lies about its length is refused": "TestWorkerDuration_AConvertedLengthOverTheLimitFailsTheJobBeforeAnyEngineSeesIt",
|
"A file that lies about its length is refused": "TestWorkerDuration_AConvertedLengthOverTheLimitFailsTheJobBeforeAnyEngineSeesIt",
|
||||||
"A truncated recording is refused, not half transcribed": "TestWorkerDuration_AFileThatEndsFarEarlierThanItClaimedIsRefusedNotHalfTranscribed",
|
"A recording much shorter than its file claimed is transcribed and noted": "TestWorkerDuration_AFileThatEndsFarEarlierThanItClaimedIsStillTranscribedAndSaysSo",
|
||||||
"A full queue is a retry, not an error": "TestAccept_AFullQueueIsABusyRejectionNotAServerErrorAndLeavesNothingBehind",
|
"A full queue is a retry, not an error": "TestAccept_AFullQueueIsABusyRejectionNotAServerErrorAndLeavesNothingBehind",
|
||||||
"One user cannot fill the queue for everyone": "TestWorkerSubmit_OneUserCannotFillTheQueueForEveryoneElse",
|
"One user cannot fill the queue for everyone": "TestWorkerSubmit_OneUserCannotFillTheQueueForEveryoneElse",
|
||||||
"Errors are shown as kinds, never their text": "TestReconcile_TheBreakerReasonCarriesAKindNotTheModelsText",
|
"Errors are shown as kinds, never their text": "TestReconcile_TheBreakerReasonCarriesAKindNotTheModelsText",
|
||||||
|
|||||||
Reference in New Issue
Block a user