teqo-backup/src/lib/recording.ts
Francisco Solla 722cd6cf94 fix(C199): upload de gravações fatiado em 16 MiB — escapa do 413 do edge
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/.
2026-09-28 19:36:12 -03:00

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),
}
}