mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
O diálogo "Enviar gravação" mandava o arquivo inteiro num único XHR e a produção vive atrás do Cloudflare Tunnel, cujo edge responde 413 para qualquer request body acima de ~100 MB ANTES do app ver o corpo (probe read-only: POST de 150 MB cortado após ~3,2 MB). Um .mp4 de 6 GB de plenária nunca chegava à rota — o C199-large havia removido só o teto do app, não o do proxy. O upload agora é fatiado ponta a ponta: o start bodyless cria a row e a sessão em disco (`recording-upload-<id>` com estado + arquivo anexado), o diálogo envia partes de 16 MiB em ordem estrita com progresso agregado e o último byte do tamanho declarado finaliza a mídia privada e o job. Cada parte faz heartbeat do `updatedAt` contra o reaper de 1 h, o start faz preflight de disco (statfs + margem), falha de parte aborta a sessão e o delete do usuário/reaper limpam o diretório. Sem migration e sem schema. Regressão: protocolo int nas rotas start/chunk/abort (multi-parte, fora de ordem, tamanho excedido, seam multipart, falha S3, reaper), guarda unit do orçamento do edge, guardas e2e das rotas. Post-mortem e entrada de changelog em docs/.
245 lines
11 KiB
TypeScript
245 lines
11 KiB
TypeScript
/**
|
|
* C199 — pure rules of an uploaded recording: the status/step vocabulary and
|
|
* the honest failure copy. No I/O and no `server-only`: the client dialog, the
|
|
* JSON routes, the job and the unit tests share this module.
|
|
*/
|
|
import {
|
|
CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES,
|
|
campaignRecordingDeleteHref,
|
|
campaignRecordingFileHref,
|
|
campaignRecordingRetryHref,
|
|
} from '@/lib/campaignPaths'
|
|
import { formatSpeechClock, formatSpeechDate } from '@/lib/speechClock'
|
|
|
|
/** Owner of the private upload collection slug (collection, config and routes). */
|
|
export const RECORDING_MEDIA_SLUG = 'recordingMedia' as const
|
|
|
|
export const RECORDING_STATUSES = ['uploading', 'processing', 'ready', 'failed'] as const
|
|
export type RecordingStatus = (typeof RECORDING_STATUSES)[number]
|
|
|
|
export const recordingStatusLabels: Record<RecordingStatus, string> = {
|
|
uploading: 'Enviando',
|
|
processing: 'Processando',
|
|
ready: 'Pronto',
|
|
failed: 'Falhou',
|
|
}
|
|
|
|
export const isRecordingStatus = (value: unknown): value is RecordingStatus =>
|
|
RECORDING_STATUSES.includes(value as RecordingStatus)
|
|
|
|
/** One owner for the persisted field limits (collection, schema and dialog). */
|
|
export const RECORDING_TITLE_MAX_LENGTH = 200
|
|
|
|
/**
|
|
* Every request body that crosses the production edge (Cloudflare Tunnel) must
|
|
* stay under the edge's request ceiling — a bigger single request is refused
|
|
* with `413 Payload Too Large` BEFORE the app sees it (probed against prod:
|
|
* 150 MB answered 413 after ~3 MB). The dialog therefore slices the file into
|
|
* parts of this size; 16 MiB keeps a wide margin under the ~100 MB edge limit
|
|
* and still moves hours of video in few requests.
|
|
*/
|
|
export const RECORDING_UPLOAD_CHUNK_BYTES = 16 * 1024 * 1024
|
|
|
|
/** The edge ceiling the chunk size must stay under (Cloudflare: ~100 MB). */
|
|
export const RECORDING_UPLOAD_EDGE_LIMIT_BYTES = 100 * 1024 * 1024
|
|
|
|
/**
|
|
* Free-space margin the upload start demands beyond the declared file size:
|
|
* the temp copy must fit with room for the post-upload job (which downloads
|
|
* the same file again) and the running container. Failing here is honest and
|
|
* immediate; failing mid-way wastes hours of transfer.
|
|
*/
|
|
const RECORDING_UPLOAD_DISK_MARGIN_BYTES = 512 * 1024 * 1024
|
|
|
|
/** Pure budget check of the upload start (the fs call lives in the session owner). */
|
|
export const recordingUploadFitsInDisk = ({
|
|
freeBytes,
|
|
expectedBytes,
|
|
marginBytes = RECORDING_UPLOAD_DISK_MARGIN_BYTES,
|
|
}: {
|
|
freeBytes: number
|
|
expectedBytes: number
|
|
marginBytes?: number
|
|
}): boolean => Number.isFinite(freeBytes) && freeBytes - expectedBytes >= marginBytes
|
|
|
|
/** A proxy/edge refused the request body (413) before the app ever saw it. */
|
|
export const RECORDING_UPLOAD_REJECTED_MESSAGE =
|
|
'O servidor recusou uma parte do envio por tamanho. Tente novamente; se persistir, avise a equipe.'
|
|
|
|
/** C200 — the human label the team types for one speaker cluster. */
|
|
export const RECORDING_SPEAKER_LABEL_MAX_LENGTH = 120
|
|
export const RECORDING_SPEAKER_LABEL_REQUIRED_MESSAGE = 'Informe o nome do falante.'
|
|
export const RECORDING_SPEAKER_LABEL_LONG_MESSAGE = `O nome do falante deve ter até ${RECORDING_SPEAKER_LABEL_MAX_LENGTH} caracteres.`
|
|
|
|
/** Accepted upload type: any video container (`.mkv` often arrives with no type). */
|
|
const RECORDING_ACCEPTED_MIME_PREFIX = 'video/'
|
|
|
|
export const RECORDING_STEPS = ['extracting', 'transcribing', 'saving'] as const
|
|
export type RecordingStep = (typeof RECORDING_STEPS)[number]
|
|
|
|
export const recordingStepLabels: Record<RecordingStep, string> = {
|
|
extracting: 'Preparando o áudio da gravação',
|
|
transcribing: 'Transcrevendo a gravação',
|
|
saving: 'Guardando a transcrição',
|
|
}
|
|
|
|
const isRecordingStep = (value: unknown): value is RecordingStep =>
|
|
RECORDING_STEPS.includes(value as RecordingStep)
|
|
|
|
/**
|
|
* C199 — the causes the job and the reaper store in `error` (the operator's
|
|
* detail) and the honest pt-BR copy the detail shows. A raw transport message
|
|
* (URL, stderr) stays in the admin.
|
|
*/
|
|
export const RECORDING_FAILURE_INTERRUPTED = 'O processamento foi interrompido antes de terminar.'
|
|
|
|
const RECORDING_FAILURE_EXTRACTING_COPY = 'Não foi possível preparar o áudio da gravação.'
|
|
const RECORDING_FAILURE_TRANSCRIBING_COPY = 'Não foi possível transcrever a gravação.'
|
|
const RECORDING_FAILURE_SAVING_COPY = 'Não foi possível guardar a transcrição.'
|
|
const RECORDING_FAILURE_UNKNOWN_COPY = 'Não foi possível transcrever a gravação.'
|
|
|
|
/**
|
|
* Maps the stored cause of a failed recording to what the person reads: the
|
|
* exact literal when the job named it, otherwise the failure step, otherwise a
|
|
* generic-but-honest line. Null when the row stored no cause at all.
|
|
*/
|
|
export const recordingFailureMessage = ({
|
|
error,
|
|
step,
|
|
}: {
|
|
error?: string | null
|
|
step?: RecordingStep | null
|
|
}): string | null => {
|
|
const stored = error?.trim()
|
|
if (!stored) return null
|
|
|
|
if (stored === RECORDING_FAILURE_INTERRUPTED) return RECORDING_FAILURE_INTERRUPTED
|
|
if (step === 'extracting') return RECORDING_FAILURE_EXTRACTING_COPY
|
|
if (step === 'transcribing') return RECORDING_FAILURE_TRANSCRIBING_COPY
|
|
if (step === 'saving') return RECORDING_FAILURE_SAVING_COPY
|
|
return RECORDING_FAILURE_UNKNOWN_COPY
|
|
}
|
|
|
|
/**
|
|
* The recording file (player/download) only exists once the upload stream
|
|
* finished: `uploading` has no stored object yet, every other status serves it.
|
|
*/
|
|
export const canServeRecordingMedia = (status: RecordingStatus): boolean => status !== 'uploading'
|
|
|
|
/** The retry button only applies to a failed transcription. */
|
|
export const canRetryRecording = (status: RecordingStatus): boolean => status === 'failed'
|
|
|
|
/** C200 — dialog copy of the human identification step (design scenes 2/5). */
|
|
export const RECORDING_SPEAKER_DIALOG_TITLE = 'Identificar falante'
|
|
export const RECORDING_SPEAKER_DIALOG_INTRO =
|
|
'Ouça um trecho e informe quem é a pessoa. O acervo não sugere nem identifica pessoas automaticamente.'
|
|
export const RECORDING_SPEAKER_DIALOG_HELP =
|
|
'O nome será aplicado a todas as falas deste agrupamento nesta gravação.'
|
|
export const RECORDING_SPEAKER_LABEL_FIELD_LABEL = 'Nome do falante'
|
|
export const RECORDING_SPEAKER_LABEL_PLACEHOLDER = 'Digite o nome do falante'
|
|
export const RECORDING_SPEAKER_SAVE_LABEL = 'Salvar identificação'
|
|
|
|
/** C200 — the transcript banner of the grouped detail (design scenes 1/4/5). */
|
|
export const RECORDING_SPEAKER_INFO_BANNER =
|
|
'Os falantes são agrupados automaticamente pelo áudio. A identificação de cada pessoa é feita pela equipe.'
|
|
export const RECORDING_SPEAKER_INFO_BANNER_MOBILE =
|
|
'Agrupamento automático e aproximado. A identificação é feita pela equipe.'
|
|
export const RECORDING_SPEAKER_BANNER_TITLE = 'A separação por falante pode estar imprecisa'
|
|
export const RECORDING_SPEAKER_BANNER_BODY =
|
|
'O agrupamento é automático e aproximado. Ruído e falas sobrepostas podem colocar trechos no agrupamento errado. A equipe continua responsável por identificar cada falante.'
|
|
export const RECORDING_SPEAKER_BANNER_DROPPED =
|
|
'Esta gravação foi reprocessada e algumas identificações não puderam ser mantidas. Confira os falantes.'
|
|
export const RECORDING_SPEAKER_GROUP_UNIDENTIFIED = 'Agrupamento sem identificação'
|
|
export const RECORDING_SPEAKER_GROUP_IDENTIFIED = 'Identificação feita pela equipe'
|
|
export const RECORDING_SPEAKER_IDENTIFY_ACTION = 'Identificar falante'
|
|
export const RECORDING_SPEAKER_EDIT_ACTION = 'Editar identificação'
|
|
|
|
/** Errors of the upload form (client validation). Wire messages live in schemas. */
|
|
export const RECORDING_FILE_REQUIRED_MESSAGE = 'Escolha um arquivo de vídeo para enviar.'
|
|
export const RECORDING_TITLE_REQUIRED_MESSAGE = 'Informe um título para a gravação.'
|
|
export const RECORDING_FILE_TYPE_MESSAGE =
|
|
'Envie um arquivo de vídeo (MP4, MOV, MKV e formatos compatíveis).'
|
|
|
|
/** A missing type is accepted: `.mkv` often arrives with no `type` from the OS. */
|
|
export const recordingFileTypeAllowed = (mimeType: string | null | undefined): boolean =>
|
|
!mimeType || mimeType.startsWith(RECORDING_ACCEPTED_MIME_PREFIX)
|
|
|
|
/** Basename without path parts or unsafe characters, keeping the extension. */
|
|
export const sanitizeRecordingFilename = (filename: string): string => {
|
|
const base = filename.split(/[\\/]/).pop() ?? ''
|
|
const safe = base.replace(/[^\w.-]+/g, '_').replace(/^[._]+/, '')
|
|
return safe || 'gravacao'
|
|
}
|
|
|
|
/** Human-readable file size in pt-BR (`4,6 GB`, `812 MB`, `900 KB`, `12 B`). */
|
|
export const formatRecordingFileSize = (bytes: number): string => {
|
|
if (!Number.isFinite(bytes) || bytes < 0) return '—'
|
|
if (bytes >= 1024 ** 3)
|
|
return `${(bytes / 1024 ** 3).toLocaleString('pt-BR', { maximumFractionDigits: 1 })} GB`
|
|
if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2).toLocaleString('pt-BR')} MB`
|
|
if (bytes >= 1024) return `${Math.round(bytes / 1024).toLocaleString('pt-BR')} KB`
|
|
return `${Math.round(bytes)} B`
|
|
}
|
|
|
|
export type RecordingViewModel = {
|
|
id: number
|
|
title: string
|
|
status: RecordingStatus
|
|
step: RecordingStep | null
|
|
/** `dd/mm/aaaa` when the row has a date; null otherwise. */
|
|
recordedAtLabel: string | null
|
|
durationLabel: string | null
|
|
/** Raw duration for the detail player; null while it is unknown. */
|
|
durationSeconds: number | null
|
|
detailHref: string
|
|
fileHref: string
|
|
downloadHref: string
|
|
retryHref: string
|
|
deleteHref: string
|
|
}
|
|
|
|
type RecordingViewRecord = {
|
|
id: number
|
|
title?: string | null
|
|
status: string
|
|
step?: string | null
|
|
recordedAt?: string | null
|
|
durationSeconds?: number | null
|
|
}
|
|
|
|
const dateLabelOf = (value: string | null | undefined): string | null =>
|
|
/^(\d{4})-(\d{2})-(\d{2})/.test(value ?? '') && value ? formatSpeechDate(value) : null
|
|
|
|
const durationLabelOf = (seconds: number | null | undefined): string | null =>
|
|
typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0
|
|
? formatSpeechClock(seconds)
|
|
: null
|
|
|
|
const normalizedStatus = (value: string): RecordingStatus =>
|
|
isRecordingStatus(value) ? value : 'failed'
|
|
|
|
/**
|
|
* Internal/status wire view of a recording. `error` and `createdBy` never pass
|
|
* through here: the detail receives only what it renders — a failure becomes
|
|
* the mapped `failureMessage`, never the raw detail.
|
|
*/
|
|
export const toRecordingViewModel = (record: RecordingViewRecord): RecordingViewModel => {
|
|
const status = normalizedStatus(record.status)
|
|
return {
|
|
id: record.id,
|
|
title: record.title?.trim() || `Gravação ${record.id}`,
|
|
status,
|
|
step: isRecordingStep(record.step) ? record.step : null,
|
|
recordedAtLabel: dateLabelOf(record.recordedAt),
|
|
durationLabel: durationLabelOf(record.durationSeconds),
|
|
durationSeconds:
|
|
typeof record.durationSeconds === 'number' && Number.isFinite(record.durationSeconds)
|
|
? Math.max(0, record.durationSeconds)
|
|
: null,
|
|
detailHref: `${CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES}/${record.id}`,
|
|
fileHref: campaignRecordingFileHref(record.id),
|
|
downloadHref: campaignRecordingFileHref(record.id, true),
|
|
retryHref: campaignRecordingRetryHref(record.id),
|
|
deleteHref: campaignRecordingDeleteHref(record.id),
|
|
}
|
|
}
|