Compare commits
17
Commits
6ebb4e49e9
...
365dff5c85
+25
-11
@@ -1363,11 +1363,16 @@ an hour of Swedish *was* an unknown here; it is now measured, see decision 8.)
|
|||||||
|
|
||||||
**Decision.**
|
**Decision.**
|
||||||
|
|
||||||
1. **Audio only; transcript is the only thing kept.** An authenticated user uploads an audio
|
1. **Audio is what is used; the transcript is the only thing kept.** An authenticated user
|
||||||
file; Tapir transcribes it and stores the transcript. The audio itself is deleted after
|
uploads a recording; Tapir transcribes its audio and stores the transcript. The audio itself
|
||||||
transcription (temp file, removed in a `defer`, swept at start-up) — no custody problem to
|
is deleted after transcription (temp file, removed in a `defer`, swept at start-up) — no
|
||||||
solve, and nothing to hand to #31. Video is rejected with a message naming #28. YouTube
|
custody problem to solve, and nothing to hand to #31. **A file that also carries a video
|
||||||
audio-download stays rejected. No public/anonymous upload (ADR-008).
|
track is accepted: its audio is extracted, the video is discarded, and the user is told so**
|
||||||
|
(see 4). *Amended 2026-10-01, the same day this ADR was drafted: the first draft rejected
|
||||||
|
video and named #28. The maintainer's first real recording — a Teams meeting with the camera
|
||||||
|
off — is still an `.mp4` with a video track, so rejecting video rejected the primary use
|
||||||
|
case.* This keeps no video and analyses none: playback, keyframes and what-is-shown analysis
|
||||||
|
remain #28. YouTube audio-download stays rejected. No public/anonymous upload (ADR-008).
|
||||||
|
|
||||||
2. **Private store: a separate RLS-scoped table, `uploaded_transcripts`** (resolves #28's
|
2. **Private store: a separate RLS-scoped table, `uploaded_transcripts`** (resolves #28's
|
||||||
decision 1). `user_id NOT NULL REFERENCES users ON DELETE CASCADE`, `ENABLE` + `FORCE ROW LEVEL
|
decision 1). `user_id NOT NULL REFERENCES users ON DELETE CASCADE`, `ENABLE` + `FORCE ROW LEVEL
|
||||||
@@ -1387,14 +1392,20 @@ an hour of Swedish *was* an unknown here; it is now measured, see decision 8.)
|
|||||||
asserted exactly** — so a new table added without RLS fails CI instead of passing it. (c) is
|
asserted exactly** — so a new table added without RLS fails CI instead of passing it. (c) is
|
||||||
new; the existing test only checks the named tables.
|
new; the existing test only checks the named tables.
|
||||||
|
|
||||||
4. **Validate by probing, reject clearly.** Format and duration cannot be read from a filename
|
4. **Validate by probing, reject clearly, and say what was ignored.** Format, duration and
|
||||||
or MIME type — a Teams "voice-only" recording is typically an `.mp4` container with no video
|
what a file contains cannot be read from a filename or MIME type — a Teams recording is an
|
||||||
stream, which is indistinguishable from video by extension. The upload is probed
|
`.mp4` whether or not the camera was on. The upload is probed (`ffprobe`): **accept any
|
||||||
(`ffprobe`): accept any container with an audio stream and **no** video stream; reject video
|
container with an audio stream, whether or not it also has a video stream** (an mp3's cover
|
||||||
with "video is #28"; reject unreadable files. Limits, all configurable, all rejected
|
art counts as neither). Whether a real video track was present is recorded on the row
|
||||||
|
(`had_video`) and the transcript page says so plainly — *this file contained a video track;
|
||||||
|
only its audio was used and the video was discarded* — so the user is never left to wonder
|
||||||
|
what happened to the picture. Reject a file with **no** audio stream (including a screen
|
||||||
|
recording with no microphone) and an unreadable one. Limits, all configurable, all rejected
|
||||||
synchronously with a specific message rather than by timeout: **200 MB** upload and
|
synchronously with a specific message rather than by timeout: **200 MB** upload and
|
||||||
**90 minutes** duration. 90 minutes is *the longest length measured*, not a ceiling that was
|
**90 minutes** duration. 90 minutes is *the longest length measured*, not a ceiling that was
|
||||||
found; raising it is a measurement, not an edit.
|
found; raising it is a measurement, not an edit. The size cap now matters more: a video file
|
||||||
|
is much larger than its audio and is spooled whole before the audio is extracted, so a long
|
||||||
|
recording with screen sharing can exceed 200 MB while its audio is a few tens of MB.
|
||||||
|
|
||||||
5. **Transcode server-side, always, to 16 kHz mono PCM WAV** — even when the input already is,
|
5. **Transcode server-side, always, to 16 kHz mono PCM WAV** — even when the input already is,
|
||||||
rather than assuming. whisper-server's `--convert` is rejected (its `--tmp-dir` is created at
|
rather than assuming. whisper-server's `--convert` is rejected (its `--tmp-dir` is created at
|
||||||
@@ -1483,6 +1494,9 @@ deferred. The scope cut — chat first, no rich analysis, no video — is what k
|
|||||||
whisper-server permanently. Chatting with a transcript can therefore load the model that
|
whisper-server permanently. Chatting with a transcript can therefore load the model that
|
||||||
wedges the transcriber. Not solved here; the berget fallback limits the blast radius.
|
wedges the transcriber. Not solved here; the berget fallback limits the blast radius.
|
||||||
- Tapir's own ingress body limit for a 200 MB upload is **unverified** (it lives in `infra`).
|
- Tapir's own ingress body limit for a 200 MB upload is **unverified** (it lives in `infra`).
|
||||||
|
- Accepting video means large files are spooled before their audio is extracted, and ffmpeg
|
||||||
|
demuxes (but, with `-vn`, does not decode) video it then throws away. A client-side audio
|
||||||
|
extraction would avoid the upload cost; it is not built.
|
||||||
|
|
||||||
**Validation / resolution triggers** (each observable by someone other than the author):
|
**Validation / resolution triggers** (each observable by someone other than the author):
|
||||||
- *Chunking*: build it when the duration cap is raised above 90 min, or when a measured
|
- *Chunking*: build it when the duration cap is raised above 90 min, or when a measured
|
||||||
|
|||||||
@@ -129,10 +129,16 @@ func TestE2E_UploadTranscribeAndChat(t *testing.T) {
|
|||||||
|
|
||||||
var status, endpoint, content string
|
var status, endpoint, content string
|
||||||
var secs float64
|
var secs float64
|
||||||
|
var hadVideo bool
|
||||||
require.NoError(t, raw.QueryRow(ctx,
|
require.NoError(t, raw.QueryRow(ctx,
|
||||||
`SELECT status, endpoint, content, audio_seconds FROM uploaded_transcripts WHERE user_id = $1`, uid).
|
`SELECT status, endpoint, content, audio_seconds, had_video FROM uploaded_transcripts WHERE user_id = $1`, uid).
|
||||||
Scan(&status, &endpoint, &content, &secs))
|
Scan(&status, &endpoint, &content, &secs, &hadVideo))
|
||||||
t.Logf("done: endpoint=%s audio_s=%.1f transcript_chars=%d", endpoint, secs, len(content))
|
t.Logf("done: endpoint=%s audio_s=%.1f transcript_chars=%d had_video=%v", endpoint, secs, len(content), hadVideo)
|
||||||
|
if os.Getenv("TAPIR_E2E_HAD_VIDEO") == "1" {
|
||||||
|
// A Teams meeting with the camera off: a video file whose audio is what matters.
|
||||||
|
assert.True(t, hadVideo, "the file had a video track and the row must say so")
|
||||||
|
assert.Contains(t, page, "Only its audio was used", "and the user must be told")
|
||||||
|
}
|
||||||
assert.Equal(t, "done", status)
|
assert.Equal(t, "done", status)
|
||||||
assert.NotEmpty(t, endpoint)
|
assert.NotEmpty(t, endpoint)
|
||||||
assert.NotEmpty(t, content)
|
assert.NotEmpty(t, content)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type fakeUploadsStore struct {
|
|||||||
sweepErr error
|
sweepErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeUploadsStore) CreateUpload(context.Context, string, string) (string, error) {
|
func (f *fakeUploadsStore) CreateUpload(context.Context, string, string, bool) (string, error) {
|
||||||
return "id", nil
|
return "id", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -152,6 +152,7 @@ erDiagram
|
|||||||
text endpoint "which backend served it: iguana/... | berget/... (berget = audio left the homelab)"
|
text endpoint "which backend served it: iguana/... | berget/... (berget = audio left the homelab)"
|
||||||
float audio_seconds
|
float audio_seconds
|
||||||
text error_kind "typed: interrupted | transcription_failed — never a provider string"
|
text error_kind "typed: interrupted | transcription_failed — never a provider string"
|
||||||
|
bool had_video "the file carried a video track; only its audio was used (migration 018)"
|
||||||
timestamptz created_at
|
timestamptz created_at
|
||||||
timestamptz finished_at
|
timestamptz finished_at
|
||||||
}
|
}
|
||||||
@@ -223,7 +224,7 @@ mechanism.
|
|||||||
grounds that it holds only public caption content, and a recorded meeting is the opposite.
|
grounds that it holds only public caption content, and a recorded meeting is the opposite.
|
||||||
`user_id NOT NULL ... ON DELETE CASCADE`, FORCE RLS'd. `status` runs `queued → transcribing →
|
`user_id NOT NULL ... ON DELETE CASCADE`, FORCE RLS'd. `status` runs `queued → transcribing →
|
||||||
done | failed`; `done` requires `content`, `failed` requires a typed `error_kind` (CHECK
|
done | failed`; `done` requires `content`, `failed` requires a typed `error_kind` (CHECK
|
||||||
constraints). `endpoint` records which backend served it — `berget/...` means the audio left the
|
constraints). `had_video` (migration 018) records that the file carried a video track — a Teams meeting with the camera off is still a video file — so the page can say only its audio was used and the video was discarded. `endpoint` records which backend served it — `berget/...` means the audio left the
|
||||||
homelab. The audio itself is never stored. `segments` keeps the timestamps (re-transcribing 90
|
homelab. The audio itself is never stored. `segments` keeps the timestamps (re-transcribing 90
|
||||||
minutes costs ~7). Unlike `summary_actions`/`login_events` it **does** cascade on user deletion.
|
minutes costs ~7). Unlike `summary_actions`/`login_events` it **does** cascade on user deletion.
|
||||||
- **LOGIN_EVENTS** (migration 010) — throttled one-row-per-(user, date) login stamp. Used by the
|
- **LOGIN_EVENTS** (migration 010) — throttled one-row-per-(user, date) login stamp. Used by the
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
|
|||||||
# PRIVATE content, so it lives in its own RLS-scoped store and never in the
|
# PRIVATE content, so it lives in its own RLS-scoped store and never in the
|
||||||
# shared public-caption `transcripts` table (ADR-021). Rejection is synchronous,
|
# shared public-caption `transcripts` table (ADR-021). Rejection is synchronous,
|
||||||
# the audio is never retained, and a failed job ends in a terminal state with a
|
# the audio is never retained, and a failed job ends in a terminal state with a
|
||||||
# message — never a spinner (ADR-025). Audio only: video is #28.
|
# message — never a spinner (ADR-025). A file with a video track (a Teams meeting
|
||||||
|
# with the camera off is one) is accepted: only its audio is used and the user is
|
||||||
|
# told. Video playback, keyframes and analysis of what is shown remain #28.
|
||||||
|
|
||||||
Background:
|
Background:
|
||||||
Given I am a registered user with audio upload enabled
|
Given I am a registered user with audio upload enabled
|
||||||
@@ -25,9 +27,15 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
|
|||||||
When I upload an audio file and give no title
|
When I upload an audio file and give no title
|
||||||
Then its title is the filename without its extension
|
Then its title is the filename without its extension
|
||||||
|
|
||||||
Scenario: A file containing video is rejected, naming issue 28
|
Scenario: A file with a video track is accepted and I am told the video was ignored
|
||||||
When I upload a file that has a video track
|
When I upload a recording that also has a video track
|
||||||
Then it is rejected with a message that video is tracked in issue 28
|
Then it is accepted and queued like any other
|
||||||
|
And its page says only its audio was used and the video was discarded
|
||||||
|
And it says so while queued, while transcribing, when done and when failed
|
||||||
|
|
||||||
|
Scenario: A video file with no audio is rejected for having no audio
|
||||||
|
When I upload a video that has no audio track
|
||||||
|
Then it is rejected with a message that it has no audio
|
||||||
And nothing is recorded
|
And nothing is recorded
|
||||||
|
|
||||||
Scenario: A recording over the length limit is rejected with both lengths
|
Scenario: A recording over the length limit is rejected with both lengths
|
||||||
@@ -40,10 +48,9 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it
|
|||||||
Then it is rejected with 413 and a message about the limit
|
Then it is rejected with 413 and a message about the limit
|
||||||
And nothing is recorded
|
And nothing is recorded
|
||||||
|
|
||||||
Scenario: An audio-only MP4 is not mistaken for video
|
Scenario: Cover art on an audio file is not a video track
|
||||||
When I upload an mp4 container that has an audio stream and no video stream
|
When I upload an mp3 whose only video stream is attached cover art
|
||||||
Then it is accepted
|
Then it is treated as audio and no video notice is shown
|
||||||
And an mp3 whose only video stream is cover art is accepted too
|
|
||||||
|
|
||||||
Scenario: A file that is not media is rejected as unreadable
|
Scenario: A file that is not media is rejected as unreadable
|
||||||
When I upload a file ffprobe cannot read
|
When I upload a file ffprobe cannot read
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE uploaded_transcripts DROP COLUMN had_video;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration 018: record whether an uploaded file carried a video track (ADR-033,
|
||||||
|
-- amended 2026-10-01).
|
||||||
|
--
|
||||||
|
-- A Teams meeting with the camera off is still an .mp4 with a video track, so video
|
||||||
|
-- files are ACCEPTED: only the audio is extracted and the video is discarded. This
|
||||||
|
-- flag lets the transcript page say so — "this file contained a video track; only its
|
||||||
|
-- audio was used" — so the user is never left to wonder what happened to the picture.
|
||||||
|
-- It outlives the job: completing or failing an upload does not clear it.
|
||||||
|
ALTER TABLE uploaded_transcripts ADD COLUMN had_video BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -25,12 +25,14 @@ var ErrUploadNotFound = errors.New("store: uploaded transcript not found")
|
|||||||
// superuser connection the unit tests use.
|
// superuser connection the unit tests use.
|
||||||
|
|
||||||
// CreateUpload records a new upload in the queued state and returns its id.
|
// CreateUpload records a new upload in the queued state and returns its id.
|
||||||
func (s *Store) CreateUpload(ctx context.Context, userID, title string) (string, error) {
|
// hadVideo records that the file carried a video track (only its audio is used);
|
||||||
|
// it is kept for the life of the row so the page can say so.
|
||||||
|
func (s *Store) CreateUpload(ctx context.Context, userID, title string, hadVideo bool) (string, error) {
|
||||||
var id string
|
var id string
|
||||||
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
return tx.QueryRow(ctx,
|
return tx.QueryRow(ctx,
|
||||||
`INSERT INTO uploaded_transcripts (user_id, title) VALUES ($1, $2) RETURNING id`,
|
`INSERT INTO uploaded_transcripts (user_id, title, had_video) VALUES ($1, $2, $3) RETURNING id`,
|
||||||
userID, title).Scan(&id)
|
userID, title, hadVideo).Scan(&id)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return "", fmt.Errorf("store: create upload: %w", err)
|
return "", fmt.Errorf("store: create upload: %w", err)
|
||||||
}
|
}
|
||||||
@@ -155,7 +157,7 @@ func (s *Store) FailInterruptedUploads(ctx context.Context) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uploadSelect = `SELECT id, user_id, title, status, content, segments, endpoint,
|
const uploadSelect = `SELECT id, user_id, title, status, content, segments, endpoint,
|
||||||
audio_seconds, error_kind, created_at, finished_at FROM uploaded_transcripts`
|
audio_seconds, error_kind, had_video, created_at, finished_at FROM uploaded_transcripts`
|
||||||
|
|
||||||
// scanUpload scans one uploadSelect row. withText controls whether the transcript
|
// scanUpload scans one uploadSelect row. withText controls whether the transcript
|
||||||
// body and segments are kept (GetUpload) or dropped (ListUploads).
|
// body and segments are kept (GetUpload) or dropped (ListUploads).
|
||||||
@@ -167,7 +169,7 @@ func scanUpload(row pgx.Row, u *domain.UploadedTranscript, withText bool) error
|
|||||||
finished *time.Time
|
finished *time.Time
|
||||||
)
|
)
|
||||||
if err := row.Scan(&u.ID, &u.UserID, &u.Title, &status, &content, &segments, &endpoint,
|
if err := row.Scan(&u.ID, &u.UserID, &u.Title, &status, &content, &segments, &endpoint,
|
||||||
&seconds, &errKind, &u.CreatedAt, &finished); err != nil {
|
&seconds, &errKind, &u.HadVideo, &u.CreatedAt, &finished); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u.Status = domain.UploadStatus(deref(status))
|
u.Status = domain.UploadStatus(deref(status))
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestCreateUpload_StartsQueuedAndIsOwnedByTheCreator(t *testing.T) {
|
|||||||
seedUsers(t, userA)
|
seedUsers(t, userA)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
id, err := s.CreateUpload(ctx, userA, "Teams export")
|
id, err := s.CreateUpload(ctx, userA, "Teams export", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
got, err := s.GetUpload(ctx, userA, id)
|
got, err := s.GetUpload(ctx, userA, id)
|
||||||
@@ -50,7 +50,7 @@ func TestUploadLifecycle_CompletedKeepsTextSegmentsEndpointAndDuration(t *testin
|
|||||||
s := newStore(t)
|
s := newStore(t)
|
||||||
seedUsers(t, userA)
|
seedUsers(t, userA)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id, err := s.CreateUpload(ctx, userA, "m")
|
id, err := s.CreateUpload(ctx, userA, "m", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
require.NoError(t, s.StartTranscribing(ctx, userA, id))
|
require.NoError(t, s.StartTranscribing(ctx, userA, id))
|
||||||
@@ -84,7 +84,7 @@ func TestUploadLifecycle_FailedRecordsTypedKindAndNoContent(t *testing.T) {
|
|||||||
s := newStore(t)
|
s := newStore(t)
|
||||||
seedUsers(t, userA)
|
seedUsers(t, userA)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id, err := s.CreateUpload(ctx, userA, "m")
|
id, err := s.CreateUpload(ctx, userA, "m", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, s.StartTranscribing(ctx, userA, id))
|
require.NoError(t, s.StartTranscribing(ctx, userA, id))
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ func TestGetUpload_AnotherUsersIDIsNotFound(t *testing.T) {
|
|||||||
s := newStore(t)
|
s := newStore(t)
|
||||||
seedUsers(t, userA, userB)
|
seedUsers(t, userA, userB)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id, err := s.CreateUpload(ctx, userA, "A's meeting")
|
id, err := s.CreateUpload(ctx, userA, "A's meeting", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = s.GetUpload(ctx, userB, id)
|
_, err = s.GetUpload(ctx, userB, id)
|
||||||
@@ -115,7 +115,7 @@ func TestUploadWrites_ByAnotherUserChangeNothing(t *testing.T) {
|
|||||||
s := newStore(t)
|
s := newStore(t)
|
||||||
seedUsers(t, userA, userB)
|
seedUsers(t, userA, userB)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id, err := s.CreateUpload(ctx, userA, "A's meeting")
|
id, err := s.CreateUpload(ctx, userA, "A's meeting", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.ErrorIs(t, s.StartTranscribing(ctx, userB, id), store.ErrUploadNotFound)
|
assert.ErrorIs(t, s.StartTranscribing(ctx, userB, id), store.ErrUploadNotFound)
|
||||||
@@ -132,11 +132,11 @@ func TestListUploads_ReturnsOnlyTheCallersNewestFirstWithoutTranscriptText(t *te
|
|||||||
s := newStore(t)
|
s := newStore(t)
|
||||||
seedUsers(t, userA, userB)
|
seedUsers(t, userA, userB)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
first, err := s.CreateUpload(ctx, userA, "first")
|
first, err := s.CreateUpload(ctx, userA, "first", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
second, err := s.CreateUpload(ctx, userA, "second")
|
second, err := s.CreateUpload(ctx, userA, "second", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = s.CreateUpload(ctx, userB, "B's")
|
_, err = s.CreateUpload(ctx, userB, "B's", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, s.StartTranscribing(ctx, userA, first))
|
require.NoError(t, s.StartTranscribing(ctx, userA, first))
|
||||||
require.NoError(t, s.CompleteUpload(ctx, userA, first, domain.Transcription{Text: "long text body", Endpoint: "e"}))
|
require.NoError(t, s.CompleteUpload(ctx, userA, first, domain.Transcription{Text: "long text body", Endpoint: "e"}))
|
||||||
@@ -160,12 +160,12 @@ func TestFailInterruptedUploads_FailsOnlyUnfinishedRowsForEveryUser(t *testing.T
|
|||||||
_, err := super.Exec(ctx, `INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`, subject, uid)
|
_, err := super.Exec(ctx, `INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)`, subject, uid)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
queued, err := s.CreateUpload(ctx, userA, "queued")
|
queued, err := s.CreateUpload(ctx, userA, "queued", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
running, err := s.CreateUpload(ctx, userB, "running")
|
running, err := s.CreateUpload(ctx, userB, "running", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, s.StartTranscribing(ctx, userB, running))
|
require.NoError(t, s.StartTranscribing(ctx, userB, running))
|
||||||
done, err := s.CreateUpload(ctx, userA, "done")
|
done, err := s.CreateUpload(ctx, userA, "done", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, s.StartTranscribing(ctx, userA, done))
|
require.NoError(t, s.StartTranscribing(ctx, userA, done))
|
||||||
require.NoError(t, s.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "kept", Endpoint: "e"}))
|
require.NoError(t, s.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "kept", Endpoint: "e"}))
|
||||||
@@ -186,3 +186,47 @@ func TestFailInterruptedUploads_FailsOnlyUnfinishedRowsForEveryUser(t *testing.T
|
|||||||
assert.Equal(t, domain.UploadDone, kept.Status, "a finished transcript must survive the sweep")
|
assert.Equal(t, domain.UploadDone, kept.Status, "a finished transcript must survive the sweep")
|
||||||
assert.Equal(t, "kept", kept.Content)
|
assert.Equal(t, "kept", kept.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateUpload_RecordsWhetherTheFileHadAVideoTrack(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
seedUsers(t, userA)
|
||||||
|
ctx := context.Background()
|
||||||
|
withVideo, err := s.CreateUpload(ctx, userA, "camera off", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
audioOnly, err := s.CreateUpload(ctx, userA, "voice only", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
gotVideo, err := s.GetUpload(ctx, userA, withVideo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
gotAudio, err := s.GetUpload(ctx, userA, audioOnly)
|
||||||
|
require.NoError(t, err)
|
||||||
|
listed, err := s.ListUploads(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, gotVideo.HadVideo, "the page must be able to say the video was ignored")
|
||||||
|
assert.False(t, gotAudio.HadVideo)
|
||||||
|
byID := map[string]bool{}
|
||||||
|
for _, u := range listed {
|
||||||
|
byID[u.ID] = u.HadVideo
|
||||||
|
}
|
||||||
|
assert.Equal(t, map[string]bool{withVideo: true, audioOnly: false}, byID, "the list carries it too")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadHadVideo_SurvivesCompletionAndFailure(t *testing.T) {
|
||||||
|
s := newStore(t)
|
||||||
|
seedUsers(t, userA)
|
||||||
|
ctx := context.Background()
|
||||||
|
done, err := s.CreateUpload(ctx, userA, "d", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
failed, err := s.CreateUpload(ctx, userA, "f", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, s.StartTranscribing(ctx, userA, done))
|
||||||
|
require.NoError(t, s.CompleteUpload(ctx, userA, done, domain.Transcription{Text: "x", Endpoint: "e"}))
|
||||||
|
require.NoError(t, s.FailUpload(ctx, userA, failed, domain.UploadErrTranscriptionFailed))
|
||||||
|
|
||||||
|
for _, id := range []string{done, failed} {
|
||||||
|
got, err := s.GetUpload(ctx, userA, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, got.HadVideo, "completing or failing must not clear it: the notice outlives the job")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -167,6 +167,10 @@ type UploadedTranscript struct {
|
|||||||
Endpoint string
|
Endpoint string
|
||||||
AudioSeconds float64
|
AudioSeconds float64
|
||||||
ErrorKind UploadErrorKind
|
ErrorKind UploadErrorKind
|
||||||
|
// HadVideo records that the uploaded file carried a video track. Only its audio
|
||||||
|
// was used and the video was discarded; the page says so. It is set at upload
|
||||||
|
// and never cleared (ADR-033).
|
||||||
|
HadVideo bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
FinishedAt time.Time // zero until the job reaches done or failed
|
FinishedAt time.Time // zero until the job reaches done or failed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type Prober interface {
|
|||||||
|
|
||||||
// Store is the slice of the private transcript store the intake needs.
|
// Store is the slice of the private transcript store the intake needs.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
CreateUpload(ctx context.Context, userID, title string) (string, error)
|
CreateUpload(ctx context.Context, userID, title string, hadVideo bool) (string, error)
|
||||||
FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error
|
FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ func (in *Intake) Accept(ctx context.Context, userID, title string, src io.Reade
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := in.store.CreateUpload(ctx, userID, title)
|
id, err := in.store.CreateUpload(ctx, userID, title, probe.HasVideo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("uploads: create: %w", err)
|
return "", fmt.Errorf("uploads: create: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ import (
|
|||||||
|
|
||||||
type fakeStore struct {
|
type fakeStore struct {
|
||||||
created []string // titles
|
created []string // titles
|
||||||
|
hadVideo []bool
|
||||||
failed map[string]domain.UploadErrorKind
|
failed map[string]domain.UploadErrorKind
|
||||||
nextID string
|
nextID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) CreateUpload(_ context.Context, _, title string) (string, error) {
|
func (f *fakeStore) CreateUpload(_ context.Context, _, title string, hadVideo bool) (string, error) {
|
||||||
f.created = append(f.created, title)
|
f.created = append(f.created, title)
|
||||||
|
f.hadVideo = append(f.hadVideo, hadVideo)
|
||||||
return f.nextID, nil
|
return f.nextID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +142,7 @@ func TestAccept_ProbeVerdictRejectsWithoutRowAndRemovesTheFile(t *testing.T) {
|
|||||||
probe uploads.Probe
|
probe uploads.Probe
|
||||||
reason uploads.Reason
|
reason uploads.Reason
|
||||||
}{
|
}{
|
||||||
{"video", uploads.Probe{Duration: time.Minute, HasAudio: true, HasVideo: true}, uploads.ReasonVideo},
|
{"video with no audio", uploads.Probe{Duration: time.Minute, HasVideo: true}, uploads.ReasonNoAudio},
|
||||||
{"too long", uploads.Probe{Duration: 2 * time.Hour, HasAudio: true}, uploads.ReasonTooLong},
|
{"too long", uploads.Probe{Duration: 2 * time.Hour, HasAudio: true}, uploads.ReasonTooLong},
|
||||||
{"no audio", uploads.Probe{Duration: time.Minute}, uploads.ReasonNoAudio},
|
{"no audio", uploads.Probe{Duration: time.Minute}, uploads.ReasonNoAudio},
|
||||||
}
|
}
|
||||||
@@ -210,3 +212,28 @@ func TestAccept_SubmitFailureFailsTheRowHonestlyAndRemovesTheFile(t *testing.T)
|
|||||||
"a row that will never run must not sit queued: that is the stuck spinner ADR-025 forbids")
|
"a row that will never run must not sit queued: that is the stuck spinner ADR-025 forbids")
|
||||||
assert.Empty(t, r.tmpFiles(t))
|
assert.Empty(t, r.tmpFiles(t))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAccept_AFileWithAVideoTrackIsAcceptedAndRecordsThatItHadOne(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
probe uploads.Probe
|
||||||
|
wantVideo bool
|
||||||
|
}{
|
||||||
|
{"audio only", uploads.Probe{Duration: 10 * time.Minute, HasAudio: true}, false},
|
||||||
|
{"audio with a video track (a Teams meeting with the camera off)", uploads.Probe{Duration: 10 * time.Minute, HasAudio: true, HasVideo: true}, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
r.pr.probe = tt.probe
|
||||||
|
|
||||||
|
id, err := r.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "up-1", id)
|
||||||
|
assert.Equal(t, []bool{tt.wantVideo}, r.st.hadVideo,
|
||||||
|
"the row must say whether a video track was present, so the page can say it was ignored")
|
||||||
|
assert.Len(t, r.sub.jobs, 1, "it is queued like any other recording")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ type Limits struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Probe is what inspecting an uploaded file revealed. It is the input to Validate
|
// Probe is what inspecting an uploaded file revealed. It is the input to Validate
|
||||||
// because a name or MIME type cannot say whether a container carries video: a
|
// because a name or MIME type cannot say what a container holds: a Teams recording
|
||||||
// Teams voice-only export is typically an .mp4 with no video stream.
|
// is an .mp4 whether or not the camera was on.
|
||||||
type Probe struct {
|
type Probe struct {
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
HasAudio bool
|
HasAudio bool
|
||||||
@@ -30,7 +30,6 @@ type Probe struct {
|
|||||||
type Reason string
|
type Reason string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ReasonVideo Reason = "video"
|
|
||||||
ReasonNoAudio Reason = "no_audio"
|
ReasonNoAudio Reason = "no_audio"
|
||||||
ReasonTooLong Reason = "too_long"
|
ReasonTooLong Reason = "too_long"
|
||||||
ReasonTooLarge Reason = "too_large"
|
ReasonTooLarge Reason = "too_large"
|
||||||
@@ -46,13 +45,13 @@ type RejectError struct {
|
|||||||
|
|
||||||
func (e *RejectError) Error() string { return string(e.Reason) + ": " + e.Message }
|
func (e *RejectError) Error() string { return string(e.Reason) + ": " + e.Message }
|
||||||
|
|
||||||
// Validate decides whether a probed file may be transcribed. Video is checked
|
// Validate decides whether a probed file may be transcribed. A video track is NOT
|
||||||
// first so a video file is always refused as video, whatever its length.
|
// a reason to refuse: a Teams meeting with the camera off is still an .mp4 with a
|
||||||
|
// video track, so rejecting video would reject the primary use case. Only the audio
|
||||||
|
// is used (Probe.HasVideo is recorded so the user can be told the video was
|
||||||
|
// ignored). What IS refused: no audio at all, an unreadable length, too long.
|
||||||
func Validate(p Probe, l Limits) error {
|
func Validate(p Probe, l Limits) error {
|
||||||
switch {
|
switch {
|
||||||
case p.HasVideo:
|
|
||||||
return &RejectError{Reason: ReasonVideo, Message: "This file contains a video track. " +
|
|
||||||
"Only audio recordings are supported for now; video upload is tracked in issue #28."}
|
|
||||||
case !p.HasAudio:
|
case !p.HasAudio:
|
||||||
return &RejectError{Reason: ReasonNoAudio, Message: "This file has no audio track to transcribe."}
|
return &RejectError{Reason: ReasonNoAudio, Message: "This file has no audio track to transcribe."}
|
||||||
case p.Duration <= 0:
|
case p.Duration <= 0:
|
||||||
|
|||||||
@@ -29,16 +29,14 @@ func TestValidate(t *testing.T) {
|
|||||||
probe: uploads.Probe{Duration: 90 * time.Minute, HasAudio: true},
|
probe: uploads.Probe{Duration: 90 * time.Minute, HasAudio: true},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "a file with a video stream is rejected and names issue 28",
|
name: "a recording that also has a video stream is accepted: the audio is what is used",
|
||||||
probe: uploads.Probe{Duration: 4 * time.Minute, HasAudio: true, HasVideo: true},
|
probe: uploads.Probe{Duration: 4 * time.Minute, HasAudio: true, HasVideo: true},
|
||||||
wantReason: uploads.ReasonVideo,
|
|
||||||
wantInMsg: []string{"video", "#28"},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "video with no audio is still rejected as video, not as missing audio",
|
name: "video with no audio is rejected as having no audio, and says so",
|
||||||
probe: uploads.Probe{Duration: 4 * time.Minute, HasVideo: true},
|
probe: uploads.Probe{Duration: 4 * time.Minute, HasVideo: true},
|
||||||
wantReason: uploads.ReasonVideo,
|
wantReason: uploads.ReasonNoAudio,
|
||||||
wantInMsg: []string{"#28"},
|
wantInMsg: []string{"no audio"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "a file with no audio stream is rejected",
|
name: "a file with no audio stream is rejected",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
|
|||||||
switch u.Status {
|
switch u.Status {
|
||||||
case domain.UploadDone:
|
case domain.UploadDone:
|
||||||
<div class="upload-status upload-done" role="status">
|
<div class="upload-status upload-done" role="status">
|
||||||
|
@videoNotice(u.HadVideo)
|
||||||
<p>
|
<p>
|
||||||
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
|
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
|
||||||
of audio by <span class="chip">{ u.Endpoint }</span>.
|
of audio by <span class="chip">{ u.Endpoint }</span>.
|
||||||
@@ -74,6 +75,7 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
|
|||||||
</div>
|
</div>
|
||||||
case domain.UploadFailed:
|
case domain.UploadFailed:
|
||||||
<div class="upload-status upload-failed flash flash-error" role="alert">
|
<div class="upload-status upload-failed flash flash-error" role="alert">
|
||||||
|
@videoNotice(u.HadVideo)
|
||||||
<p>{ uploadFailureMessage(u.ErrorKind) }</p>
|
<p>{ uploadFailureMessage(u.ErrorKind) }</p>
|
||||||
</div>
|
</div>
|
||||||
default:
|
default:
|
||||||
@@ -84,6 +86,7 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool) {
|
|||||||
hx-trigger={ "every " + uploadPollEvery }
|
hx-trigger={ "every " + uploadPollEvery }
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
|
@videoNotice(u.HadVideo)
|
||||||
if u.Status == domain.UploadTranscribing {
|
if u.Status == domain.UploadTranscribing {
|
||||||
<p>Transcribing… a long recording takes a few minutes.</p>
|
<p>Transcribing… a long recording takes a few minutes.</p>
|
||||||
} else {
|
} else {
|
||||||
@@ -108,3 +111,15 @@ templ UploadChatPage(u domain.UploadedTranscript, v chatView) {
|
|||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// videoNotice tells the user a video track was present and what became of it. A
|
||||||
|
// Teams meeting with the camera off is still a video file; it is accepted, only the
|
||||||
|
// audio is transcribed, and the picture is thrown away — so say so, in every state,
|
||||||
|
// instead of leaving the user to wonder (ADR-033).
|
||||||
|
templ videoNotice(hadVideo bool) {
|
||||||
|
if hadVideo {
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -158,12 +158,12 @@ func TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow(t *testing.T) {
|
|||||||
wantInBody []string
|
wantInBody []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "video names issue 28",
|
name: "video with no audio is rejected for having no audio",
|
||||||
probe: uploads.Probe{Duration: time.Minute, HasAudio: true, HasVideo: true},
|
probe: uploads.Probe{Duration: time.Minute, HasVideo: true},
|
||||||
maxBytes: 1 << 20,
|
maxBytes: 1 << 20,
|
||||||
content: "x",
|
content: "x",
|
||||||
wantStatus: http.StatusUnprocessableEntity,
|
wantStatus: http.StatusUnprocessableEntity,
|
||||||
wantInBody: []string{"video", "#28"},
|
wantInBody: []string{"no audio"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "too long states both lengths",
|
name: "too long states both lengths",
|
||||||
@@ -275,3 +275,64 @@ func TestUploadStatus_DoneNamesTheEndpointThatServedIt(t *testing.T) {
|
|||||||
"which backend served a transcript is a quality fact, and berget means the audio left the homelab")
|
"which backend served a transcript is a quality fact, and berget means the audio left the homelab")
|
||||||
assert.True(t, strings.Contains(b, "45 min"), "the audio length is shown")
|
assert.True(t, strings.Contains(b, "45 min"), "the audio length is shown")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markHadVideo(t *testing.T, id string) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := rawPool(t).Exec(context.Background(), `UPDATE uploaded_transcripts SET had_video = TRUE WHERE id = $1`, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadPost_AFileWithAVideoTrackIsAcceptedAndTheRowRemembersIt(t *testing.T) {
|
||||||
|
// A Teams meeting with the camera off is still an .mp4 with a video track.
|
||||||
|
app, sub := newUploadApp(t, uploads.Probe{Duration: 62 * time.Minute, HasAudio: true, HasVideo: true}, 200<<20)
|
||||||
|
|
||||||
|
rec := do(t, app, uploadReq(t, "Project notes", "Teams meeting.mp4", []byte("synthetic bytes")))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, rec.Code, "it must be accepted, not rejected as video")
|
||||||
|
var hadVideo bool
|
||||||
|
require.NoError(t, rawPool(t).QueryRow(context.Background(),
|
||||||
|
`SELECT had_video FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&hadVideo))
|
||||||
|
assert.True(t, hadVideo)
|
||||||
|
assert.Len(t, sub.jobs, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadPost_AudioOnlyIsNotMarkedAsHavingHadVideo(t *testing.T) {
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
|
||||||
|
do(t, app, uploadReq(t, "Voice memo", "memo.m4a", []byte("x")))
|
||||||
|
|
||||||
|
var hadVideo bool
|
||||||
|
require.NoError(t, rawPool(t).QueryRow(context.Background(),
|
||||||
|
`SELECT had_video FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&hadVideo))
|
||||||
|
assert.False(t, hadVideo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadStatus_TellsTheUserTheVideoWasIgnoredInEveryStateAndOnlyWhenItWas(t *testing.T) {
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
states := []struct{ status, content string }{
|
||||||
|
{"queued", ""}, {"transcribing", ""}, {"done", "Synthetic transcript."}, {"failed", "interrupted"},
|
||||||
|
}
|
||||||
|
for _, st := range states {
|
||||||
|
t.Run(st.status, func(t *testing.T) {
|
||||||
|
withVideo := seedUpload(t, userID, "camera off "+st.status, st.status, st.content)
|
||||||
|
markHadVideo(t, withVideo)
|
||||||
|
audioOnly := seedUpload(t, userID, "voice only "+st.status, st.status, st.content)
|
||||||
|
|
||||||
|
notice := body(t, do(t, app, get("/uploads/"+withVideo+"/status")))
|
||||||
|
plain := body(t, do(t, app, get("/uploads/"+audioOnly+"/status")))
|
||||||
|
|
||||||
|
assert.Contains(t, notice, "video track")
|
||||||
|
assert.Contains(t, notice, "Only its audio was used", "say what happened, not just that something did")
|
||||||
|
assert.Contains(t, notice, "discarded")
|
||||||
|
assert.NotContains(t, plain, "video track", "no notice when there was no video")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadPage_AlsoCarriesTheVideoNotice(t *testing.T) {
|
||||||
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||||
|
id := seedUpload(t, userID, "camera off", "done", "Synthetic transcript.")
|
||||||
|
markHadVideo(t, id)
|
||||||
|
|
||||||
|
assert.Contains(t, body(t, do(t, app, get("/uploads/"+id))), "Only its audio was used")
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ package web
|
|||||||
|
|
||||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
import (
|
import "github.com/a-h/templ"
|
||||||
"github.com/a-h/templ"
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
templruntime "github.com/a-h/templ/runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -268,133 +266,153 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool) templ.Component
|
|||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
switch u.Status {
|
switch u.Status {
|
||||||
case domain.UploadDone:
|
case domain.UploadDone:
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"upload-status upload-done\" role=\"status\"><p>Transcribed ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"upload-status upload-done\" role=\"status\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = videoNotice(u.HadVideo).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<p>Transcribed ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))))
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 64, Col: 95}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 65, Col: 95}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
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, 18, " of audio by <span class=\"chip\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " of audio by <span class=\"chip\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(u.Endpoint)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(u.Endpoint)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 65, Col: 48}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 66, Col: 48}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
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, 19, "</span>.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span>.</p>")
|
||||||
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, 20, "<p><a class=\"btn\" href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<p><a class=\"btn\" href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 templ.SafeURL
|
var templ_7745c5c3_Var15 templ.SafeURL
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/uploads/" + u.ID + "/chat"))
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/uploads/" + u.ID + "/chat"))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 68, Col: 73}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 69, Col: 73}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
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, 21, "\">Ask about this recording</a></p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">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, 22, "<details open><summary>Transcript</summary><pre class=\"transcript\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<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
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(u.Content)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(u.Content)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 72, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 73, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
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, 23, "</pre></details></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</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, 24, "<div class=\"upload-status upload-failed flash flash-error\" role=\"alert\"><p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"upload-status upload-failed flash flash-error\" role=\"alert\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = videoNotice(u.HadVideo).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(uploadFailureMessage(u.ErrorKind))
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(uploadFailureMessage(u.ErrorKind))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 77, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 79, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
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, "</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p></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, 26, "<div class=\"upload-status upload-working\" role=\"status\" hx-get=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<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
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/uploads/" + u.ID + "/status")
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/uploads/" + u.ID + "/status")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 83, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 85, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
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, 27, "\" hx-trigger=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" hx-trigger=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("every " + uploadPollEvery)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("every " + uploadPollEvery)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 84, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 86, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
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, "\" hx-swap=\"outerHTML\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" hx-swap=\"outerHTML\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = videoNotice(u.HadVideo).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if u.Status == domain.UploadTranscribing {
|
if u.Status == domain.UploadTranscribing {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<p>Transcribing… a long recording takes a few minutes.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<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, 30, "<p>Queued — waiting for its turn to be transcribed.</p>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<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, 31, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -438,20 +456,20 @@ 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, 32, "<article class=\"account\"><h1>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<article class=\"account\"><h1>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 101, Col: 16}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 104, Col: 16}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
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, 33, "</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, 35, "</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
|
||||||
}
|
}
|
||||||
@@ -459,20 +477,20 @@ 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, 34, "</section><p><a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</section><p><a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var23 templ.SafeURL
|
var templ_7745c5c3_Var23 templ.SafeURL
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/uploads/" + u.ID))
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/uploads/" + u.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 107, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 110, Col: 49}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
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, "\">Back to the transcript</a></p></article>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\">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
|
||||||
}
|
}
|
||||||
@@ -486,4 +504,39 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// videoNotice tells the user a video track was present and what became of it. A
|
||||||
|
// Teams meeting with the camera off is still a video file; it is accepted, only the
|
||||||
|
// audio is transcribed, and the picture is thrown away — so say so, in every state,
|
||||||
|
// instead of leaving the user to wonder (ADR-033).
|
||||||
|
func videoNotice(hadVideo bool) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var24 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var24 == nil {
|
||||||
|
templ_7745c5c3_Var24 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
if hadVideo {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<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 {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
var _ = templruntime.GeneratedTemplate
|
||||||
|
|||||||
@@ -105,10 +105,11 @@ var scenarioCoverage = map[string]string{
|
|||||||
"The upload form states the limits up front": "TestUploadsPage_ShowsAMultipartFormWithAFileField",
|
"The upload form states the limits up front": "TestUploadsPage_ShowsAMultipartFormWithAFileField",
|
||||||
"An accepted recording is queued and I am taken to its page": "TestUploadPost_AcceptedAudioQueuesARowAndRedirectsToIt",
|
"An accepted recording is queued and I am taken to its page": "TestUploadPost_AcceptedAudioQueuesARowAndRedirectsToIt",
|
||||||
"A recording with no title is named after its file": "TestUploadPost_WithoutATitleUsesTheFilenameMinusItsExtension",
|
"A recording with no title is named after its file": "TestUploadPost_WithoutATitleUsesTheFilenameMinusItsExtension",
|
||||||
"A file containing video is rejected, naming issue 28": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
|
"A file with a video track is accepted and I am told the video was ignored": "TestUploadStatus_TellsTheUserTheVideoWasIgnoredInEveryStateAndOnlyWhenItWas",
|
||||||
|
"A video file with no audio is rejected for having no audio": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
|
||||||
"A recording over the length limit is rejected with both lengths": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
|
"A recording over the length limit is rejected with both lengths": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
|
||||||
"A file over the size limit is rejected as too large": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
|
"A file over the size limit is rejected as too large": "TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow",
|
||||||
"An audio-only MP4 is not mistaken for video": "TestParseProbe",
|
"Cover art on an audio file is not a video track": "TestParseProbe",
|
||||||
"A file that is not media is rejected as unreadable": "TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError",
|
"A file that is not media is rejected as unreadable": "TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError",
|
||||||
"A broken ffprobe is reported as our fault, not as a bad file": "TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError",
|
"A broken ffprobe is reported as our fault, not as a bad file": "TestAccept_UnreadableFileIsARejectionButAProberFaultIsAnError",
|
||||||
"A recording becomes a transcript and the audio is deleted": "TestWorkerProcess_SuccessConvertsTranscribesStoresAndDeletesTheAudio",
|
"A recording becomes a transcript and the audio is deleted": "TestWorkerProcess_SuccessConvertsTranscribesStoresAndDeletesTheAudio",
|
||||||
|
|||||||
Reference in New Issue
Block a user