diff --git a/docs/changelog/2026-09-15-c169.md b/docs/changelog/2026-09-15-c169.md new file mode 100644 index 00000000..dabada4d --- /dev/null +++ b/docs/changelog/2026-09-15-c169.md @@ -0,0 +1 @@ +**Corte do acervo resolve o trecho com a Câmara instável (C169, 2026-09-15):** a primeira etapa do "Cortar vídeo" deixou de morrer no primeiro timeout. O resolver do VOD (`src/utilities/speech/speechVodResolver.ts`) ganhou política por chamador — o player C162 mantém a janela curta (15 s, sem poll, pinada por unit) e o corte usa `SPEECH_VOD_CUT_POLICY` (45 s/req, 1 retry, 2 polls de 5 s cobrindo o primeiro request de ~30 s; teto ~4,7 min) — e um fallback opt-in que, quando a API não entrega URL verificado (`INDISPONIVEL`, `GERANDO` esgotado, throw ou hash morto), proba os links `vodPlaybackUrl`/`vodDownloadUrl` já gravados na fala com o mesmo probe do resolver (Range GET, `text/html` recusado); sem candidato verificado o erro original continua subindo. A falha ficou honesta: o job preserva o step em que morreu e grava o literal da causa, e `speechCutFailureMessage` (`src/lib/speechCut.ts`) mapeia causa+step para o texto pt-BR que o diálogo mostra (a fala sem trecho não culpa mais a Câmara; cortar ≠ guardar); o `error` cru segue só no admin, e "Tentar novamente" reusa a mesma row. Sem migration/collection/Consent; `excerptTMs` verbatim e o gate `canReadSpeechCatalog` intactos. Unit (políticas/poll/fallback/mapper/diálogo) + int (job publica pelo link gravado com API `INDISPONIVEL` e com hash morto; falha com step e causa) verdes. diff --git a/docs/plans/c169-corte-vod-confiabilidade-impl.md b/docs/plans/c169-corte-vod-confiabilidade-impl.md new file mode 100644 index 00000000..28b5149f --- /dev/null +++ b/docs/plans/c169-corte-vod-confiabilidade-impl.md @@ -0,0 +1,145 @@ +# Impl: Corte do acervo: resolver o trecho com a Câmara instável e falhar dizendo o porquê + +Status: em execução +Atualizado em: 2026-09-15 +Issue: #1045 +Intenção: docs/plans/c169-corte-vod-confiabilidade.md +Appetite restante: herdado (~1 dia eng) + +## Leitura da intenção + +- **Outcome:** o "Cortar vídeo" da assessoria não morre mais na primeira etapa ("Localizando o trecho na Câmara") enquanto existir arquivo verificável — a API real ganha a janela medida (~30 s no primeiro request, depois polls curtos) e o link VOD já gravado na fala é tentado e verificado quando a API não serve; sem arquivo, a falha nomeia a causa em pt-BR, nada é publicado e "Tentar novamente" reusa a mesma row. +- **O que NÃO negociar:** `excerptTMs` verbatim (nunca arredondar/deslocar o trecho); link morto/falso (403, timeout, `text/html`) nunca vira arquivo; nada publicado em falha; crédito `Fonte: Câmara dos Deputados · CC BY 4.0`; gate do acervo (`canReadSpeechCatalog`; `advisor`/`leader` negados fail-closed; `leader` lockdown); sem espelhar MP4/S3, sem upload externo, sem YouTube; sem novo `Consent`/collection/migration; URLs públicas do acervo intocadas. +- **O que reavaliar (hipóteses da intenção):** + 1. "Ampliar a janela afeta também o clique do player" — verdade, mas resolve-se **parametrizando a política por chamador** (default = comportamento C162 de hoje), não com janela global nem caminho paralelo (D1). + 2. "O fallback do link gravado vive no job" — reavaliado: o dono do probe é o resolver; o fallback vira **opt-in no resolver** (`cachedUrls`), com o job passando os candidatos (D2). + 3. "A causa real fica só na coluna interna" — o discriminador (`error`) existe, mas `toSpeechCutViewModel` o descarta; a menor exposição é **um campo novo no VM** (`failureMessage`, mapeado no servidor), com o **step da falha preservado** para separar cortar de guardar; não é enum/migration (D3). + 4. "`tests/int` só cobre o caminho feliz" e o unit do resolver pina a janela — o default do player preservado mantém o teste pinado verde; poll/fallback entram como **unit com fake timers** (D4). + +## Abordagem recomendada + +```mermaid +flowchart TD + A["Diálogo: Cortar e publicar"] --> B["POST acervo/cortar — gate speechCatalog"] + B --> C["row processing · step=resolving"] + C --> D["after(): runSpeechCutJob"] + D --> E["resolveSpeechVod(coords, CUT_POLICY, cachedUrls)"] + E --> F{"video-sob-demanda"} + F -->|GERANDO| G["poll 5 s (até 2)"] + G --> F + F -->|PRONTO| H["probe playback/download"] + H -->|verificado| J["sourceUrl"] + F -->|"INDISPONIVEL / esgotado / throw"| I["probe links gravados (cache verificado)"] + H -->|"nenhum verificado"| I + I -->|verificado| J + I -->|nada verificado| K["failed: step + error (cru, admin)"] + J --> L["ffmpeg → media → published"] + K --> M["toSpeechCutViewModel.failureMessage (mapeado)"] + M --> N["Diálogo: causa + Tentar novamente (mesma row)"] +``` + +**Opções consideradas:** A) endurecer o resolver dono (política por chamador + fallback opt-in do cache verificado) e levar a causa ao VM | B) só trocar as mensagens do job/diálogo, sem mexer na janela nem no fallback | C) fila/fluxo novo de resolução. +**Recomendação:** A — cobre os dois aceites (resiliência e falha honesta) sem schema e sem tocar o player; o resolver já é o dono do fetch/probe do VOD e o VM é o dono do que o diálogo renderiza. +**Rejeitadas:** B porque não atende o primeiro aceite (segue morrendo no timeout mesmo com link vivo); C porque é o rabbit hole nomeado (worker/fila/pendências) e o C167 já decidiu job in-process com `after()`. + +### Decisões de engenharia (caras de reverter) + +**D1 — Janela/poll do resolver sem regredir o player C162 (contrato compartilhado).** +Opções: A) `resolveSpeechVod(coords, { policy })` com default = política de hoje do player e `SPEECH_VOD_CUT_POLICY` passada só pelo job | B) uma janela única maior para todos os chamadores | C) resolver paralelo só do corte. +**Recomendação: A.** `SpeechVodPolicy = { statusTimeoutMs, statusRetries, retryDelayMs, pollAttempts, pollDelayMs }` em `src/utilities/speech/speechVodResolver.ts`: `SPEECH_VOD_PLAYER_POLICY = { 15_000, 1, 1_000, 0, 1_000 }` (idêntica ao comportamento atual: 2 requests no erro de transporte, resposta imediata em `GERANDO`) e `SPEECH_VOD_CUT_POLICY = { 45_000, 1, 1_000, 2, 5_000 }` (absorve o primeiro request de ~30 s, 2 polls de 5 s após `GERANDO`; teto declarado ~4,7 min no pior caso — 3 leituras × (2 × 45 s + 1 s) + 2 × 5 s, caso medido ~40 s). O loop só polla `GERANDO` enquanto houver budget; `PRONTO`/`INDISPONIVEL`/`DESCONHECIDO` retornam na hora; erro de transporte consome `statusRetries` e ao final relança (contrato atual do player intacto). `resolveSpeechVodForActor` não muda uma linha. +**Alternativas rejeitadas:** B porque transforma "Assistir/baixar" (clique síncrono e deliberadamente bounded do C162) num spinner de minutos sem aceite de produto; C porque fere o corte explícito da intenção ("não criar caminho de resolução paralelo ao C162 — editar o dono"). + +**D2 — Onde vive o fallback do link gravado.** +Opções: A) opt-in no resolver: `resolveSpeechVod(coords, { policy?, cachedUrls? })`; quando a API não entrega URL verificado (throw, `gerando`, `indisponivel`, `pronto` sem probe ok), proba os `cachedUrls` com o mesmo `probeSpeechMedia` (privado) e devolve `pronto` com o link verificado; sem `cachedUrls`, comportamento de hoje (inclusive relançar) | B) o job compõe a escada importando `probeSpeechMedia` exportado | C) fallback para todos os chamadores (player incluso). +**Recomendação: A** — um único ponto de entrada; o probe (Range GET, `text/html` = morto, HEAD só em 405/501) continua privado e com um dono; a precedência "API primeiro, cache depois" fica num lugar só; o job só passa `cachedUrls: { playbackUrl, downloadUrl }` (a fala já vem completa do `loadCutSystem`, depth 1) e consome `playbackUrl ?? downloadUrl`. +**Alternativas rejeitadas:** B porque exporta um interno usado uma vez e move a escada/precedência para o job (vazamento de conhecimento do resolver); C porque muda o contrato do C162 ("cache nunca é URL entregue ao browser") e o aceite do player sem produto. **Trigger de revisitação:** se os cortes salvos pelo cache provarem valor no uso real, reavaliar o player numa linha (passar `cachedUrls` na action) com aceite próprio. + +**D3 — Como a causa chega à UI (wire do VM × semântica do step).** +Opções: A) `SpeechCutViewModel.failureMessage: string | null`, mapeado no servidor por `speechCutFailureMessage({ error, step })` (literais exatos + fallback por step); o job persiste o **step da falha** (`failCut(..., step)`), com `error` cru seguindo só no admin | B) campo `failureReason` enum na collection + `pnpm migrate:create` | C) expor `error` cru no status/VM e o diálogo mapear. +**Recomendação: A** — sem migration (`step` já é um select existente e nenhum consumidor o lê fora do diálogo em `running`); o operador segue vendo `HTTP 403 em `/stderr no admin; a pessoa vê a causa; o diálogo só troca o texto (`setError(cut.failureMessage)` quando a poll vira `failed`; default atual mantido como defesa para payload sem o campo). O mapeamento é puro, client-safe em `src/lib/speechCut.ts`, e unit-testado. +**Alternativas rejeitadas:** B porque campo/migration estão explicitamente fora do escopo; C porque vaza URL/stderr no wire e viola o comentário contrato do VM ("`error` e `createdBy` nunca passam"). Também rejeitadas: classificar só por literal do `error` (ffmpeg/storage não têm literal estável e o aceite quer "cortar" ≠ "guardar" separados) e prefixar a fase no `error` (polui o detalhe do operador). + +**D4 — Estratégia de teste e e2e.** +Opções: A) unit primeiro (fake timers para política/poll/fallback; mapper/VM), int para o job com estados imediatos, dialog unit para o texto; e2e existente **não muda** | B) e2e novo do job/poll | C) só int. +**Recomendação: A** — fake timers pinam a janela sem esperar (o unit atual que fixa "exatamente 2 fetches" do player continua verde, provando a não-regressão); o job é deliberadamente fora do HTTP (C167) e o int prova o fallback com `INDISPONIVEL`/`PRONTO`-hash-morto/throw curto; o manifest já mapeia `src/utilities/speech`, `src/components/campaign/speech`, `src/lib/speech*` e `src/lib/schemas` → `campaignSpeechCut` (+`campaignSpeechAcervo`), com `campaignSpeechCut` no `E2E_CURATED_SPECS` — o diff roda o spec sem editar manifest/curado. +**Alternativas rejeitadas:** B porque não há Câmara/ffmpeg dirigível no e2e e o custo não paga; C porque a política do player (risco de regressão) e o mapping puro precisam de pinagem barata. + +### Componentes / mudanças + +- **`resolveSpeechVod` + políticas** (`src/utilities/speech/speechVodResolver.ts`): `SpeechVodPolicy`, `SPEECH_VOD_PLAYER_POLICY`, `SPEECH_VOD_CUT_POLICY`, loop de poll em `GERANDO`, fallback opt-in via `cachedUrls` (reusa `probeSpeechMedia`/`probeMediaOnce` como estão); header do módulo atualizado (C162 + C169; player não passa `cachedUrls`). Reusa `buildVodUrl`, `parseVodStatus`, `CAMARA_USER_AGENT`. +- **`runSpeechCutJob`** (`src/utilities/speech/speechCutJob.ts`): primeira etapa chama `resolveSpeechVod(coordinates, { policy: SPEECH_VOD_CUT_POLICY, cachedUrls })`; `failCut(payload, cutId, message, step)` persiste o step da falha; um `currentStep` local acompanha `updateStep` para o catch; literais viram constantes compartilhadas; reaper importa a constante de interrompido. +- **`src/lib/speechCut.ts`**: constantes de causa (`SPEECH_CUT_FAILURE_SPEECH_GONE`, `..._GENERATING`, `..._UNAVAILABLE`, `..._UNPLAYABLE`, `..._INTERRUPTED` — a inelegível continua `SPEECH_VOD_INELIGIBLE_MESSAGE`), mapper `speechCutFailureMessage({ error, step })` e `SpeechCutRecordForView.error?: string | null` / `SpeechCutViewModel.failureMessage: string | null` (não-nulo só para `status='failed'`). Tabela de copy: + | `error` gravado (admin) | `failureMessage` (diálogo) | + | --- | --- | + | 'A fala deste corte não está mais disponível.' | idem | + | 'Esta fala não tem trecho de vídeo para resolver na Câmara.' | 'Esta fala não tem trecho de vídeo para cortar.' | + | 'A Câmara ainda está gerando o vídeo deste trecho.' | idem | + | 'A Câmara não entregou o arquivo deste trecho.' | 'A Câmara não disponibiliza mais o arquivo deste trecho.' | + | 'A Câmara não entregou um arquivo jogável deste trecho.' | 'A Câmara não disponibilizou um arquivo válido deste trecho.' | + | 'O corte foi interrompido antes de terminar.' | idem | + | desconhecido · step resolving | 'Não foi possível localizar o trecho na Câmara.' | + | desconhecido · step cutting | 'Não foi possível cortar o trecho.' | + | desconhecido · steps metadata/publishing | 'Não foi possível guardar o arquivo do corte.' | + | desconhecido · step null | 'Não foi possível preparar o corte.' | + | sem `error` | `null` (diálogo mantém o default atual) | +- **`SpeechCutDialog.tsx`**: na poll, `cut.status === 'failed'` passa a setar `error` com `cut.failureMessage` (sem tocar layout, título ou ações; `SPEECH_CUT_GENERIC_ERROR_MESSAGE` segue como defesa). +- **Migration:** nenhuma (declarado; `step`/`error` já existem). +- **Access / Consent:** nenhum. O job mantém o bypass admin documentado (`overrideAccess: true`); actions/rotas seguem com o gate fresco `canReadSpeechCatalog` e `user`/`overrideAccess: false`; sem `Consent`, sem collection nova. +- **UI:** Impeccable A — só texto da cena de falha existente; sem shell novo, sem lista, sem migration. + +### Dados → forma (se aplicável) + +- **Não se aplica** — a intenção fixou "Vou apresentar dados? Não": progresso e erro são feedback de ação. A "forma" é a mensagem única de causa (tabela da D3) + os botões atuais; nada de dashboard/métrica de falhas. + +## Fases verificáveis + +1. **Tracer / server — resolver (quota ~0,4 dia):** `SpeechVodPolicy` + constantes, loop de poll, fallback opt-in. Unit: default do player intacto (2 requests, 0 polls, `GERANDO`/`INDISPONIVEL` em 1 request), `CUT_POLICY` pollando `GERANDO`→`PRONTO` com fake timers, fallback verificando cache em `INDISPONIVEL`/throw/`pronto`-sem-URL e recusando 403/timeout/`text/html`. Prova: `pnpm test:unit` + `pnpm gate:fast`. +2. **Server — job + VM (quota ~0,3 dia):** job usa política/candidatos, `failCut` com step, constantes; mapper + `failureMessage`. Int: (a) API `INDISPONIVEL` e link gravado vivo → job publica com o arquivo; (b) API `PRONTO` com hash morto (links do stub distintos dos gravados) e gravado vivo → idem; (c) tudo morto → `failed` com `step='resolving'`, `error` cru no doc, `getSpeechCutStatusForActor` devolvendo a causa; happy path/ffmpeg-fail/retry-mesma-row atuais verdes. Prova: `pnpm test:int` + `pnpm gate:fast`. +3. **UI — diálogo (quota ~0,15 dia):** poll `failed` usa `cut.failureMessage`; unit do diálogo com `failureMessage` no fixture (e o retry `{retryOf}` inalterado); revisão de copy (Impeccable A; ajuste opcional do "menos de um minuto" da cena de running). Prova: `pnpm test:unit` + smoke local. +4. **Gates (quota ~0,15 dia):** `pnpm gate:fast`; conferir a seleção de e2e (sem edição de spec/manifest; o diff cai nos prefixes já mapeados e roda `campaignSpeechCut`); changelog `docs/changelog/2026-09-15-c169.md`; `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- **Extrair `resolveVod`/`probeLink` de `scripts/lib/camaraFetch.mjs` para `src/`:** proibido/desnecessário; editar o dono (`speechVodResolver`) como referência de política, sem import. +- **Fila/worker/cron** de regeneração, pré-resolução em lote ou "gerar novamente": fora (C162/C167 já cortaram). +- **Escrever o link recém-resolvido de volta em `speech`:** exigiria `canUpdateSpeech` (unrestricted) — fora (Q4 assumida). +- **Campo enum de causa / migration / collection nova:** fora; literal + step resolvem. +- **Expor `error` cru no wire** (URLs/stderr): fora; o mapper devolve só literais seguros. +- **Dar o fallback ao player agora:** fora (contrato/aceite C162); trigger registrado na D2. +- **Redesenhar a cena de falha/layout, dashboard de falhas, biblioteca mostrando a causa:** fora (texto da falha existente apenas). +- **Mexer em `excerptTMs`/vizinhança ou na elegibilidade (`speechVodCoordinates`):** fora (guardrail verbatim; fala sem VOD continua inelegível). +- **Abstração genérica de HTTP/backoff compartilhada com `scripts/`:** dois call sites; DRY não paga. +- **e2e novo do job/poll:** fora (job não é HTTP-dirigível). + +## Riscos e mitigação + +- **Regressão do player C162 (mesmo resolver):** default `SPEECH_VOD_PLAYER_POLICY` byte a byte; unit atual (2 fetches, `gerando`/`indisponivel` em 1 request) fica verde; `resolveSpeechVodForActor` e os testes do player não mudam. +- **Janela maior × reaper (15 min):** pior caso resolving ~4,7 min + probes 10 s; cada `updateStep` renova `updatedAt`; teto declarado e ajustável num único ponto (`SPEECH_VOD_CUT_POLICY`). O pior caso combinado (resolving + download 180 s + ffmpeg 300 s) segue abaixo do `SPEECH_CUT_STALE_MS`. +- **Link verificado que morre entre probe e download:** `downloadSource` revalida (`!ok || text/html`) e o job falha fechado — nada publicado; o retry refaz a escada. +- **Vazamento do cru para a pessoa:** mapper só devolve literais conhecidos/por step; unit pina `HTTP 403 em `/stderr → mensagem segura; comentário do VM atualizado. +- **`step` não-nulo em `failed`:** nenhum consumidor além do diálogo em `running` lê `step` (grep); retry reseta para `resolving`; admin ganha o passo real da falha. +- **Int lento por delays reais:** o int usa estados imediatos (`INDISPONIVEL`, `PRONTO`, throw) e deixa poll puro para o unit com fake timers. +- **Copy otimista do diálogo ("menos de um minuto")** no caso poll: ajuste textual opcional na fase 3 (Impeccable A), sem layout. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (tabela abaixo) +- [ ] Invariantes AGENTS/engineering-standards (Local API com `user`/`overrideAccess: false`; job com bypass documentado; sem multi-collection write; sem `Contact`/`Consent`/migration; Dependency Rule `lib → utilities`; copy pt-BR/identificadores em inglês) +- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — sem mudança de access; write path do job (step/error) coberto por int + unit + +| Aceite (intenção) | Evidência | +| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| API lenta/instável não morre no 1º timeout, dentro de um limite declarado | `SPEECH_VOD_CUT_POLICY` (45 s/req, 1 retry, 2 polls de 5 s; teto ~4,7 min) + unit fake timers `GERANDO`→`PRONTO` + pin da política do player | +| Hash morto com link gravado vivo → corta; morto/falso nunca | fallback opt-in com `probeSpeechMedia`; unit (403/timeout/html nunca retornam) + int do job (INDISPONIVEL e hash morto) | +| Sem arquivo recuperável → falha honesta e retomável, sem duplicar | `failureMessage` no VM + diálogo; `retryOf` mesma row (int/e2e C167 atuais) | +| Fala inelegível → mensagem direta | `SPEECH_VOD_INELIGIBLE_MESSAGE` compartilhado no create (rota) e no job; e2e atual pinado | +| `excerptTMs` verbatim, crédito CC BY, gate do acervo, sem migration/collection/Consent, URLs públicas | contratos intocados pelo diff (nenhuma mudança em elegibilidade, página `/corte/` ou access) | +| Qualidade | `pnpm gate:fast` verde + `pnpm push`; changelog `docs/changelog/2026-09-15-c169.md` | + +## Self-score (decision-quality ≥4) + +1. **Decisões caras com rejeitadas — 5/5:** D1 (contrato do resolver compartilhado com o player), D2 (semântica de seleção de fonte/cache), D3 (wire do VM + semântica do `step` na falha) e D4 (testes/e2e) têm `Opções/Recomendação/Alternativas rejeitadas`, com as hipóteses da intenção reavaliadas explicitamente. +2. **Cabe no appetite — 5/5:** ~1 dia dividido em resolver (0,4), job/VM (0,3), diálogo (0,15) e gates (0,15); sem schema, sem UI nova, sem infra. +3. **Rabbit holes nomeados — 5/5:** extração dos scripts, fila/cron, write-back do link, enum/migration, `error` cru, fallback no player (com trigger), redesign/dashboard e mudança de trecho estão fora. +4. **Depth check reusa o dono — 5/5:** um único resolver parametrizado (sem caminho paralelo), `probeSpeechMedia` privado, VM/mapper puros no dono do vocabulário do corte, job com bypass documentado; nada de pass-through novo de cerimônia. +5. **Intenção de produto satisfeita — 5/5:** cada aceite literal tem evidência e os guardrails (verbatim, CC BY, gate, nada publicado, retry sem duplicar) permanecem; as duas assunções de produto (janela e uso do link gravado) ficam validadas pelos testes de aceite no gate. diff --git a/src/components/campaign/speech/SpeechCutDialog.tsx b/src/components/campaign/speech/SpeechCutDialog.tsx index 5779e102..1e745b21 100644 --- a/src/components/campaign/speech/SpeechCutDialog.tsx +++ b/src/components/campaign/speech/SpeechCutDialog.tsx @@ -173,7 +173,10 @@ export const SpeechCutDialog = ({ return } if (cut.status === 'failed') { + // C169 — the row carries the mapped cause; null keeps the fallback + // line of the failure scene (a failure the server did not name). setPhase('failed') + setError(cut.failureMessage) return } } catch { diff --git a/src/lib/speechCut.ts b/src/lib/speechCut.ts index ca010081..7922e450 100644 --- a/src/lib/speechCut.ts +++ b/src/lib/speechCut.ts @@ -7,6 +7,7 @@ import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths' import { formatBahiaCivilDate } from '@/lib/campaignTime' import { buildWhatsAppTextShareUrl } from '@/lib/phone' +import { SPEECH_VOD_INELIGIBLE_MESSAGE } from '@/lib/schemas/speechVod' import { formatSpeechDate, formatSpeechSpan } from '@/lib/speechClock' import { parseYoutubeVideoId } from '@/lib/speechVod' @@ -41,6 +42,57 @@ export const speechCutStepLabels: Record = { const isSpeechCutStep = (value: unknown): value is SpeechCutStep => SPEECH_CUT_STEPS.includes(value as SpeechCutStep) +/** + * C169 — the causes the job and the reaper store in `error` (the operator's + * detail) and the honest pt-BR copy the dialog shows for each one. Only these + * literals — or the failure step — ever reach the person; a raw transport + * message (URL, stderr) stays in the admin. + */ +export const SPEECH_CUT_FAILURE_SPEECH_GONE = 'A fala deste corte não está mais disponível.' +export const SPEECH_CUT_FAILURE_GENERATING = 'A Câmara ainda está gerando o vídeo deste trecho.' +export const SPEECH_CUT_FAILURE_UNAVAILABLE = 'A Câmara não entregou o arquivo deste trecho.' +export const SPEECH_CUT_FAILURE_UNPLAYABLE = + 'A Câmara não entregou um arquivo jogável deste trecho.' +export const SPEECH_CUT_FAILURE_INTERRUPTED = 'O corte foi interrompido antes de terminar.' + +const SPEECH_CUT_FAILURE_INELIGIBLE_COPY = 'Esta fala não tem trecho de vídeo para cortar.' +const SPEECH_CUT_FAILURE_UNAVAILABLE_COPY = + 'A Câmara não disponibiliza mais o arquivo deste trecho.' +const SPEECH_CUT_FAILURE_UNPLAYABLE_COPY = + 'A Câmara não disponibilizou um arquivo válido deste trecho.' +const SPEECH_CUT_FAILURE_RESOLVING_COPY = 'Não foi possível localizar o trecho na Câmara.' +const SPEECH_CUT_FAILURE_CUTTING_COPY = 'Não foi possível cortar o trecho.' +const SPEECH_CUT_FAILURE_STORING_COPY = 'Não foi possível guardar o arquivo do corte.' +const SPEECH_CUT_FAILURE_UNKNOWN_COPY = 'Não foi possível preparar o corte.' + +/** + * Maps the stored cause of a failed cut to what the person reads: the exact + * literal when the job named it, otherwise the failure step (cortar ≠ guardar), + * otherwise a generic-but-honest line. Null when the row stored no cause at all. + */ +export const speechCutFailureMessage = ({ + error, + step, +}: { + error?: string | null + step?: SpeechCutStep | null +}): string | null => { + const stored = error?.trim() + if (!stored) return null + + if (stored === SPEECH_CUT_FAILURE_SPEECH_GONE) return SPEECH_CUT_FAILURE_SPEECH_GONE + if (stored === SPEECH_VOD_INELIGIBLE_MESSAGE) return SPEECH_CUT_FAILURE_INELIGIBLE_COPY + if (stored === SPEECH_CUT_FAILURE_GENERATING) return SPEECH_CUT_FAILURE_GENERATING + if (stored === SPEECH_CUT_FAILURE_UNAVAILABLE) return SPEECH_CUT_FAILURE_UNAVAILABLE_COPY + if (stored === SPEECH_CUT_FAILURE_UNPLAYABLE) return SPEECH_CUT_FAILURE_UNPLAYABLE_COPY + if (stored === SPEECH_CUT_FAILURE_INTERRUPTED) return SPEECH_CUT_FAILURE_INTERRUPTED + + if (step === 'resolving') return SPEECH_CUT_FAILURE_RESOLVING_COPY + if (step === 'cutting') return SPEECH_CUT_FAILURE_CUTTING_COPY + if (step === 'metadata' || step === 'publishing') return SPEECH_CUT_FAILURE_STORING_COPY + return SPEECH_CUT_FAILURE_UNKNOWN_COPY +} + type SpeechCutStepState = { step: SpeechCutStep label: string @@ -187,6 +239,8 @@ export type SpeechCutRecordForView = CutDurationRecord & { publishedAt?: string | null createdAt?: string | null updatedAt?: string | null + /** Raw internal cause of a failure; never handed to the client as-is. */ + error?: string | null } export type SpeechCutViewModel = { @@ -205,6 +259,8 @@ export type SpeechCutViewModel = { mediaFilename: string | null youtubeVideoId: string | null publishedAt: string | null + /** C169 — honest cause of a failure; null unless `status === 'failed'`. */ + failureMessage: string | null } const mediaOf = (media: SpeechCutRecordForView['media']): CutMediaRecord | null => @@ -215,7 +271,8 @@ const youtubeUrlOf = (speech: SpeechCutRecordForView['speech']): string | null = /** * Internal/status wire view of a cut. `error` and `createdBy` never pass - * through here: the public page and the dialog receive only what they render. + * through here: the public page and the dialog receive only what they render — + * a failure becomes the mapped `failureMessage`, never the raw detail. */ export const toSpeechCutViewModel = (record: SpeechCutRecordForView): SpeechCutViewModel => { const media = mediaOf(record.media) @@ -241,6 +298,8 @@ export const toSpeechCutViewModel = (record: SpeechCutRecordForView): SpeechCutV mediaFilename: media?.filename ?? null, youtubeVideoId: parseYoutubeVideoId(youtubeUrlOf(record.speech)), publishedAt: record.publishedAt ?? null, + failureMessage: + status === 'failed' ? speechCutFailureMessage({ error: record.error, step }) : null, } } @@ -277,8 +336,8 @@ const originOf = (speech: SpeechCutRecordForView['speech']): SpeechCutOriginView /** * C168 — library view of a cut: the base view plus the origin speech (label + - * link to the acervo) and the creation date the list shows. `error`, `step` - * and `createdBy` still never pass through. + * link to the acervo) and the creation date the list shows. `error` and + * `createdBy` still never pass through (a failure travels as `failureMessage`). */ export const toSpeechCutLibraryItemViewModel = ( record: SpeechCutRecordForView, diff --git a/src/utilities/speech/speechCutJob.ts b/src/utilities/speech/speechCutJob.ts index 90880358..e0137e89 100644 --- a/src/utilities/speech/speechCutJob.ts +++ b/src/utilities/speech/speechCutJob.ts @@ -9,10 +9,16 @@ import { Readable, Transform } from 'node:stream' import { pipeline } from 'node:stream/promises' import type { Payload } from 'payload' +import { SPEECH_VOD_INELIGIBLE_MESSAGE } from '@/lib/schemas/speechVod' import { formatSpeechDate } from '@/lib/speechClock' import { buildSpeechCutFallbackMetadata, buildSpeechCutFfmpegArgs, + SPEECH_CUT_FAILURE_GENERATING, + SPEECH_CUT_FAILURE_INTERRUPTED, + SPEECH_CUT_FAILURE_SPEECH_GONE, + SPEECH_CUT_FAILURE_UNAVAILABLE, + SPEECH_CUT_FAILURE_UNPLAYABLE, type SpeechCutStep, } from '@/lib/speechCut' import { CAMARA_USER_AGENT, speechVodCoordinates } from '@/lib/speechVod' @@ -21,7 +27,7 @@ import { withPayloadTransaction, type PayloadTransactionRequest, } from '@/utilities/payloadTransaction' -import { resolveSpeechVod } from '@/utilities/speech/speechVodResolver' +import { resolveSpeechVod, SPEECH_VOD_CUT_POLICY } from '@/utilities/speech/speechVodResolver' /** * C167 — the cut job: resolve the Câmara VOD, cut the exact [start, end] with @@ -91,8 +97,17 @@ const createMediaSystem = ( ...(req ? { req } : {}), }) -const failCut = (payload: Payload, cutId: number, message: string): Promise => - updateCutSystem(payload, cutId, { status: 'failed', step: null, error: message }) +/** + * Fails the row and keeps the step it died on (the first stage is `resolving` + * by default): the dialog maps the step and the known literals into the honest + * message, while `error` keeps the raw detail for the admin. + */ +const failCut = ( + payload: Payload, + cutId: number, + message: string, + step: SpeechCutStep | null = 'resolving', +): Promise => updateCutSystem(payload, cutId, { status: 'failed', step, error: message }) const updateStep = (payload: Payload, cutId: number, step: SpeechCutStep): Promise => updateCutSystem(payload, cutId, { step }) @@ -167,39 +182,51 @@ const runFfmpeg = (args: string[], durationSeconds: number): Promise => */ export const runSpeechCutJob = async (payload: Payload, cutId: number): Promise => { let tempDir: string | null = null + // The step the row would be showing if the job died right now: the catch + // records it so the failure message separates cortar from guardar. + let currentStep: SpeechCutStep = 'resolving' + const markStep = async (step: SpeechCutStep): Promise => { + currentStep = step + await updateStep(payload, cutId, step) + } try { const cut = await loadCutSystem(payload, cutId) if (cut.status !== 'processing') return - await updateStep(payload, cutId, 'resolving') + await markStep('resolving') const speech = typeof cut.speech === 'object' && cut.speech !== null ? cut.speech : null if (!speech) { - await failCut(payload, cutId, 'A fala deste corte não está mais disponível.') + await failCut(payload, cutId, SPEECH_CUT_FAILURE_SPEECH_GONE) return } const coordinates = speechVodCoordinates(speech) if (!coordinates) { - await failCut(payload, cutId, 'Esta fala não tem trecho de vídeo para resolver na Câmara.') + await failCut(payload, cutId, SPEECH_VOD_INELIGIBLE_MESSAGE) return } - const resolution = await resolveSpeechVod(coordinates) + const resolution = await resolveSpeechVod(coordinates, { + policy: SPEECH_VOD_CUT_POLICY, + // The stored links are the Câmara's own cache: tried only when the API + // left no verified URL, and probed exactly like the API links. + cachedUrls: { playbackUrl: speech.vodPlaybackUrl, downloadUrl: speech.vodDownloadUrl }, + }) if (resolution.state === 'gerando') { - await failCut(payload, cutId, 'A Câmara ainda está gerando o vídeo deste trecho.') + await failCut(payload, cutId, SPEECH_CUT_FAILURE_GENERATING) return } if (resolution.state !== 'pronto') { - await failCut(payload, cutId, 'A Câmara não entregou o arquivo deste trecho.') + await failCut(payload, cutId, SPEECH_CUT_FAILURE_UNAVAILABLE) return } const sourceUrl = resolution.playbackUrl ?? resolution.downloadUrl if (!sourceUrl) { - await failCut(payload, cutId, 'A Câmara não entregou um arquivo jogável deste trecho.') + await failCut(payload, cutId, SPEECH_CUT_FAILURE_UNPLAYABLE) return } - await updateStep(payload, cutId, 'cutting') + await markStep('cutting') tempDir = await mkdtemp(join(tmpdir(), 'speech-cut-')) const inputPath = join(tempDir, 'source.mp4') const outputName = `corte-${cut.id}-${Math.round(cut.startSeconds)}-${Math.round(cut.endSeconds)}.mp4` @@ -217,7 +244,7 @@ export const runSpeechCutJob = async (payload: Payload, cutId: number): Promise< durationSeconds, ) - await updateStep(payload, cutId, 'metadata') + await markStep('metadata') const fallback = buildSpeechCutFallbackMetadata({ speechType: speech.type ?? null, dateLabel: formatSpeechDate(speech.speechAt), @@ -229,7 +256,7 @@ export const runSpeechCutJob = async (payload: Payload, cutId: number): Promise< await updateCutSystem(payload, cutId, { title, description }) } - await updateStep(payload, cutId, 'publishing') + await markStep('publishing') await withPayloadTransaction(payload, async ({ req }) => { const media = await createMediaSystem(payload, title, outputPath, req) await updateCutSystem( @@ -246,7 +273,7 @@ export const runSpeechCutJob = async (payload: Payload, cutId: number): Promise< ) }) } catch (error) { - await failCut(payload, cutId, messageOf(error)).catch(() => undefined) + await failCut(payload, cutId, messageOf(error), currentStep).catch(() => undefined) } finally { if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) } @@ -269,7 +296,7 @@ export const reapStaleSpeechCut = async ( const result = await payload.update({ collection: 'speechCut', where: { and: [{ id: { equals: cut.id } }, { status: { equals: 'processing' } }] }, - data: { status: 'failed', step: null, error: 'O corte foi interrompido antes de terminar.' }, + data: { status: 'failed', step: null, error: SPEECH_CUT_FAILURE_INTERRUPTED }, // Intentional admin bypass: the reaper repairs a row the system owns. overrideAccess: true, }) diff --git a/src/utilities/speech/speechVodResolver.ts b/src/utilities/speech/speechVodResolver.ts index 1c1e7450..d2dd2cd6 100644 --- a/src/utilities/speech/speechVodResolver.ts +++ b/src/utilities/speech/speechVodResolver.ts @@ -4,38 +4,79 @@ import { buildVodUrl, CAMARA_USER_AGENT, parseVodStatus, + type CamaraVodStatus, type SpeechVodResolution, } from '@/lib/speechVod' /** - * C162 — interactive VOD resolution for the acervo detail. Deliberately NOT a - * reuse of `scripts/lib/camaraFetch.mjs` (whose `resolveVod` polls for ~200s - * and whose `probeLink` reports instead of deciding): the player's contract is - * a bounded click, so this module owns its own short retry, timeout and the - * media verification that decides what may reach the browser. + * C162/C169 — interactive VOD resolution for the acervo detail. Deliberately + * NOT a reuse of `scripts/lib/camaraFetch.mjs` (whose `resolveVod` polls for + * ~200s and whose `probeLink` reports instead of deciding): whoever calls this + * owns the wait budget through a `SpeechVodPolicy`, and the media verification + * (not the policy) decides what may reach the browser. + * + * The player keeps the bounded click of C162 (`SPEECH_VOD_PLAYER_POLICY`); the + * cut job (C169) needs the measured first-request window (~30s, then short + * polls) plus the stored links as a last verified candidate, and passes + * `SPEECH_VOD_CUT_POLICY` + `cachedUrls`. */ -const VOD_STATUS_ATTEMPTS = 2 -const VOD_STATUS_TIMEOUT_MS = 15_000 -const VOD_STATUS_RETRY_DELAY_MS = 1_000 +export type SpeechVodPolicy = { + /** Abort timeout of each `video-sob-demanda` read. */ + statusTimeoutMs: number + /** Extra reads after a transport failure (total attempts = 1 + retries). */ + statusRetries: number + /** Delay before retrying a transport failure. */ + retryDelayMs: number + /** Extra reads after a `GERANDO` answer (the Câmara only transcodes once). */ + pollAttempts: number + /** Delay between `GERANDO` polls. */ + pollDelayMs: number +} + +/** C162's player click: short, no poll, immediate answer. */ +export const SPEECH_VOD_PLAYER_POLICY: SpeechVodPolicy = { + statusTimeoutMs: 15_000, + statusRetries: 1, + retryDelayMs: 1_000, + pollAttempts: 0, + pollDelayMs: 1_000, +} + +/** C169's cut job: covers the measured ~30s first request and 1–2 short polls. */ +export const SPEECH_VOD_CUT_POLICY: SpeechVodPolicy = { + statusTimeoutMs: 45_000, + statusRetries: 1, + retryDelayMs: 1_000, + pollAttempts: 2, + pollDelayMs: 5_000, +} + +/** Stored links of the speech, tried (and probed) only after the API fails. */ +export type SpeechVodCachedUrls = { + playbackUrl?: string | null + downloadUrl?: string | null +} + const MEDIA_PROBE_TIMEOUT_MS = 10_000 const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) -/** One bounded fetch of the `video-sob-demanda` JSON; throws after the retries. */ -const fetchVodStatus = async (url: string): Promise => { +/** One bounded read of the `video-sob-demanda` JSON; throws after the retries. */ +const fetchVodStatus = async (url: string, policy: SpeechVodPolicy): Promise => { let lastError: unknown - for (let attempt = 1; attempt <= VOD_STATUS_ATTEMPTS; attempt += 1) { + const attempts = policy.statusRetries + 1 + for (let attempt = 1; attempt <= attempts; attempt += 1) { try { const response = await fetch(url, { headers: { 'User-Agent': CAMARA_USER_AGENT, Accept: 'application/json' }, - signal: AbortSignal.timeout(VOD_STATUS_TIMEOUT_MS), + signal: AbortSignal.timeout(policy.statusTimeoutMs), }) if (!response.ok) throw new Error(`HTTP ${response.status} em ${url}`) - return await response.json() + return parseVodStatus(await response.json()) } catch (error) { lastError = error - if (attempt < VOD_STATUS_ATTEMPTS) await sleep(VOD_STATUS_RETRY_DELAY_MS) + if (attempt < attempts) await sleep(policy.retryDelayMs) } } throw lastError @@ -75,28 +116,89 @@ const probeSpeechMedia = async (url: string | null): Promise => { } } -export const resolveSpeechVod = async ({ - eventId, - audioId, - excerptTms, -}: { +type SpeechVodCoordinates = { eventId: number audioId: number excerptTms: number -}): Promise => { - const status = parseVodStatus(await fetchVodStatus(buildVodUrl(eventId, audioId, excerptTms))) - - if (status.state === 'GERANDO') return { state: 'gerando' } - if (status.state !== 'PRONTO' || !status.video) return { state: 'indisponivel' } +} +/** + * Probes a pair of candidate URLs in parallel and keeps only the ones whose + * probe verified a real media body — a dead hash and the CDN's HTML error page + * both come out as null. + */ +const probeUrls = async ( + playbackUrl: string | null, + downloadUrl: string | null, +): Promise => { const [playback, download] = await Promise.all([ - probeSpeechMedia(status.video.playbackUrl), - probeSpeechMedia(status.video.downloadUrl), + probeSpeechMedia(playbackUrl), + probeSpeechMedia(downloadUrl), ]) return { state: 'pronto', - playbackUrl: playback ? status.video.playbackUrl : null, - downloadUrl: download ? status.video.downloadUrl : null, + playbackUrl: playback ? playbackUrl : null, + downloadUrl: download ? downloadUrl : null, } } + +const hasVerifiedUrl = (resolution: SpeechVodResolution): boolean => + resolution.state === 'pronto' && + (resolution.playbackUrl !== null || resolution.downloadUrl !== null) + +/** The stored links, probed like the API URLs; null when none verifies. */ +const fallbackToCachedUrls = async ( + cachedUrls: SpeechVodCachedUrls, +): Promise => { + const cached = await probeUrls(cachedUrls.playbackUrl ?? null, cachedUrls.downloadUrl ?? null) + return hasVerifiedUrl(cached) ? cached : null +} + +/** The API answer: polls `GERANDO` while the budget lasts, probes what is PRONTO. */ +const resolveFromVodApi = async ( + { eventId, audioId, excerptTms }: SpeechVodCoordinates, + policy: SpeechVodPolicy, +): Promise => { + const url = buildVodUrl(eventId, audioId, excerptTms) + let status = await fetchVodStatus(url, policy) + for (let poll = 0; status.state === 'GERANDO' && poll < policy.pollAttempts; poll += 1) { + await sleep(policy.pollDelayMs) + status = await fetchVodStatus(url, policy) + } + + if (status.state === 'GERANDO') return { state: 'gerando' } + if (status.state !== 'PRONTO' || !status.video) return { state: 'indisponivel' } + + return probeUrls(status.video.playbackUrl, status.video.downloadUrl) +} + +/** + * Resolves one excerpt: the Câmara API first (polls while it says `GERANDO`), + * then — when the caller passed `cachedUrls` and the API left no verified URL + * — the links already stored on the speech. Without `cachedUrls` the C162 + * behavior is preserved down to the transport error rethrown to the caller. + */ +export const resolveSpeechVod = async ( + coordinates: SpeechVodCoordinates, + options: { policy?: SpeechVodPolicy; cachedUrls?: SpeechVodCachedUrls } = {}, +): Promise => { + const { policy = SPEECH_VOD_PLAYER_POLICY, cachedUrls } = options + + let resolution: SpeechVodResolution + try { + resolution = await resolveFromVodApi(coordinates, policy) + } catch (error) { + if (!cachedUrls) throw error + const cached = await fallbackToCachedUrls(cachedUrls) + if (cached) return cached + throw error + } + + if (cachedUrls && !hasVerifiedUrl(resolution)) { + const cached = await fallbackToCachedUrls(cachedUrls) + if (cached) return cached + } + + return resolution +} diff --git a/tests/int/speechCut.int.spec.ts b/tests/int/speechCut.int.spec.ts index 8e869e5e..9044dac0 100644 --- a/tests/int/speechCut.int.spec.ts +++ b/tests/int/speechCut.int.spec.ts @@ -38,6 +38,7 @@ import { SPEECH_CUT_FORBIDDEN_MESSAGE, SPEECH_CUT_PUBLISH_NOT_READY_MESSAGE, } from '@/lib/schemas/speechCut' +import { SPEECH_CUT_FAILURE_INTERRUPTED, SPEECH_CUT_FAILURE_UNAVAILABLE } from '@/lib/speechCut' import type { CampaignUser } from '@/payload-types' import config from '@/payload.config' import { getCampaignActionContext } from '@/utilities/campaignActionContext' @@ -79,27 +80,50 @@ const campaignFixtures = installCampaignFixtures({ const mockedGetContext = vi.mocked(getCampaignActionContext) const mockedStartJob = vi.mocked(startSpeechCutJobInBackground) -const camaraFetchStub = (bytes: Buffer): typeof fetch => +const camaraVodStub = ({ + apiState = 'PRONTO', + apiPlaybackUrl = PLAYBACK_URL, + apiDownloadUrl = DOWNLOAD_URL, + mediaBytes = MP4_BYTES, + deadUrls = [], +}: { + apiState?: 'PRONTO' | 'INDISPONIVEL' | 'GERANDO' + apiPlaybackUrl?: string + apiDownloadUrl?: string + mediaBytes?: Buffer + deadUrls?: string[] +} = {}): typeof fetch => (async (input: RequestInfo | URL) => { const url = String(input) if (url.includes('video-sob-demanda')) { return new Response( JSON.stringify({ - estado: 'PRONTO', - video: { linkParaReproducao: PLAYBACK_URL, linkParaDownload: DOWNLOAD_URL }, + estado: apiState, + video: + apiState === 'PRONTO' + ? { linkParaReproducao: apiPlaybackUrl, linkParaDownload: apiDownloadUrl } + : null, }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ) } - if (url === PLAYBACK_URL || url === DOWNLOAD_URL) { - return new Response(new Uint8Array(bytes), { + if (deadUrls.includes(url)) return new Response('', { status: 403 }) + if ( + url === apiPlaybackUrl || + url === apiDownloadUrl || + url === PLAYBACK_URL || + url === DOWNLOAD_URL + ) { + return new Response(new Uint8Array(mediaBytes), { status: 200, - headers: { 'Content-Type': 'video/mp4', 'Content-Length': String(bytes.length) }, + headers: { 'Content-Type': 'video/mp4', 'Content-Length': String(mediaBytes.length) }, }) } throw new Error(`Unexpected fetch to ${url}`) }) as typeof fetch +const camaraFetchStub = (bytes: Buffer): typeof fetch => camaraVodStub({ mediaBytes: bytes }) + const createSpeech = async (overrides: Partial = {}): Promise => { const bundle = speechBundleFixture({ vodPlaybackUrl: PLAYBACK_URL, @@ -148,6 +172,22 @@ const createCut = async (data: { return cut.id } +/** Asserts the job published the cut with the fake ffmpeg's copy of the source. */ +const expectPublishedWithFakeFfmpeg = async (cutId: number) => { + const cut = await payload.findByID({ + collection: 'speechCut', + id: cutId, + depth: 1, + overrideAccess: true, + }) + expect(cut.status).toBe('published') + const media = typeof cut.media === 'object' && cut.media !== null ? cut.media : null + if (!media) throw new Error('media was not attached') + createdMediaIds.add(media.id) + expect(readFileSync(resolve(process.cwd(), 'media', media.filename as string))).toEqual(MP4_BYTES) + return { cut, media } +} + const createUsers = async () => { const fixtures = campaignFixtures() const [communicator, coordinator, candidate, advisor, leader] = await Promise.all([ @@ -384,6 +424,7 @@ describe('speech cuts (C167)', () => { const after = await getSpeechCutStatusForActor({ cutId }) expect(after.status).toBe('failed') + expect(after.failureMessage).toBe(SPEECH_CUT_FAILURE_INTERRUPTED) }) it('suggests the deterministic fallback when the AI is unavailable', async () => { @@ -416,24 +457,11 @@ describe('speech cuts (C167)', () => { try { await runSpeechCutJob(payload, cutId) - const cut = await payload.findByID({ - collection: 'speechCut', - id: cutId, - depth: 1, - overrideAccess: true, - }) - expect(cut.status).toBe('published') + const { cut, media } = await expectPublishedWithFakeFfmpeg(cutId) expect(cut.step).toBeNull() expect(cut.publishedAt).toBeTruthy() - const media = typeof cut.media === 'object' && cut.media !== null ? cut.media : null - expect(media).toBeTruthy() - if (!media) throw new Error('media was not attached') - createdMediaIds.add(media.id) expect(media.filename).toBe(`corte-${cutId}-43-118.mp4`) expect(media.alt).toBe(cut.title) - expect(readFileSync(resolve(process.cwd(), 'media', media.filename as string))).toEqual( - MP4_BYTES, - ) } finally { restoreFfmpeg() vi.unstubAllGlobals() @@ -461,6 +489,8 @@ describe('speech cuts (C167)', () => { expect(cut.media ?? null).toBeNull() expect(cut.publishedAt ?? null).toBeNull() expect(cut.error).toBeTruthy() + // C169 — the failing step survives so the dialog can say "cortar", not a generic line. + expect(cut.step).toBe('cutting') } finally { if (previousFail === undefined) delete process.env.FAKE_FFMPEG_FAIL else process.env.FAKE_FFMPEG_FAIL = previousFail @@ -469,6 +499,83 @@ describe('speech cuts (C167)', () => { } }) + it('publishes with the stored link when the API answers INDISPONIVEL', async () => { + const speech = await createSpeech() + const cutId = await createCut({ speech, startSeconds: 43, endSeconds: 118 }) + vi.stubGlobal('fetch', camaraVodStub({ apiState: 'INDISPONIVEL' })) + const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG) + + try { + await runSpeechCutJob(payload, cutId) + + await expectPublishedWithFakeFfmpeg(cutId) + } finally { + restoreFfmpeg() + vi.unstubAllGlobals() + } + }) + + it('publishes with the stored link when the API PRONTO hash is dead', async () => { + const speech = await createSpeech() + const cutId = await createCut({ speech, startSeconds: 43, endSeconds: 118 }) + const deadApiPlayback = 'https://cdn.camara.leg.br/hash-velho.mp4' + const deadApiDownload = 'https://cdn.camara.leg.br/hash-velho-download.mp4' + const fetched: string[] = [] + const stub = camaraVodStub({ + apiPlaybackUrl: deadApiPlayback, + apiDownloadUrl: deadApiDownload, + deadUrls: [deadApiPlayback, deadApiDownload], + }) + vi.stubGlobal('fetch', (async (input: RequestInfo | URL, init?: RequestInit) => { + fetched.push(String(input)) + return stub(input, init) + }) as typeof fetch) + const restoreFfmpeg = withFfmpegPath(FAKE_FFMPEG) + + try { + await runSpeechCutJob(payload, cutId) + + await expectPublishedWithFakeFfmpeg(cutId) + // The dead hash was tried and refused; the stored cache fed the cut. + expect(fetched).toContain(deadApiPlayback) + expect(fetched).toContain(PLAYBACK_URL) + } finally { + restoreFfmpeg() + vi.unstubAllGlobals() + } + }) + + it('fails with the honest cause and keeps the step when nothing is recoverable', async () => { + const speech = await createSpeech() + const cutId = await createCut({ speech, startSeconds: 0, endSeconds: 30 }) + vi.stubGlobal( + 'fetch', + camaraVodStub({ apiState: 'INDISPONIVEL', deadUrls: [PLAYBACK_URL, DOWNLOAD_URL] }), + ) + + try { + await runSpeechCutJob(payload, cutId) + + const cut = await payload.findByID({ + collection: 'speechCut', + id: cutId, + depth: 0, + overrideAccess: true, + }) + expect(cut.status).toBe('failed') + expect(cut.step).toBe('resolving') + expect(cut.error).toBe(SPEECH_CUT_FAILURE_UNAVAILABLE) + + const { communicator } = await createUsers() + asActor(communicator) + const poll = await getSpeechCutStatusForActor({ cutId }) + expect(poll.status).toBe('failed') + expect(poll.failureMessage).toBe('A Câmara não disponibiliza mais o arquivo deste trecho.') + } finally { + vi.unstubAllGlobals() + } + }) + describe.skipIf(!hasFfmpeg)('with the real ffmpeg (CI)', () => { it('cuts exactly the picked [start, end] window', async () => { const workdir = mkdtempSync(join(tmpdir(), 'speech-cut-real-')) diff --git a/tests/unit/speechCut.unit.spec.ts b/tests/unit/speechCut.unit.spec.ts index 985d44ae..dd44917a 100644 --- a/tests/unit/speechCut.unit.spec.ts +++ b/tests/unit/speechCut.unit.spec.ts @@ -1,10 +1,17 @@ import { describe, expect, it } from 'vitest' +import { SPEECH_VOD_INELIGIBLE_MESSAGE } from '@/lib/schemas/speechVod' import { formatSpeechDate } from '@/lib/speechClock' import { buildSpeechCutFallbackMetadata, buildSpeechCutFfmpegArgs, buildSpeechCutShare, + SPEECH_CUT_FAILURE_GENERATING, + SPEECH_CUT_FAILURE_INTERRUPTED, + SPEECH_CUT_FAILURE_SPEECH_GONE, + SPEECH_CUT_FAILURE_UNAVAILABLE, + SPEECH_CUT_FAILURE_UNPLAYABLE, + speechCutFailureMessage, speechCutPublicPath, speechCutStepStates, toSpeechCutViewModel, @@ -149,6 +156,72 @@ describe('toSpeechCutViewModel', () => { expect(unknown.mediaUrl).toBeNull() expect(unknown.youtubeVideoId).toBeNull() }) + + it('hands a failed cut its mapped cause and never the raw error', () => { + const failed = toSpeechCutViewModel({ + id: 9, + status: 'failed', + step: 'cutting', + title: 't', + description: 'd', + startSeconds: 0, + endSeconds: 20, + error: 'HTTP 403 em https://cdn.camara.leg.br/trecho.mp4', + }) + + expect(failed.failureMessage).toBe('Não foi possível cortar o trecho.') + expect(JSON.stringify(failed)).not.toContain('HTTP 403') + expect(view.failureMessage).toBeNull() + }) +}) + +describe('speechCutFailureMessage', () => { + it('maps the named causes of the first stage to honest pt-BR copy', () => { + expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_SPEECH_GONE })).toBe( + SPEECH_CUT_FAILURE_SPEECH_GONE, + ) + expect(speechCutFailureMessage({ error: SPEECH_VOD_INELIGIBLE_MESSAGE })).toBe( + 'Esta fala não tem trecho de vídeo para cortar.', + ) + expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_GENERATING })).toBe( + SPEECH_CUT_FAILURE_GENERATING, + ) + expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_UNAVAILABLE })).toBe( + 'A Câmara não disponibiliza mais o arquivo deste trecho.', + ) + expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_UNPLAYABLE })).toBe( + 'A Câmara não disponibilizou um arquivo válido deste trecho.', + ) + expect(speechCutFailureMessage({ error: SPEECH_CUT_FAILURE_INTERRUPTED })).toBe( + SPEECH_CUT_FAILURE_INTERRUPTED, + ) + }) + + it('maps an unnamed cause by the step where the job died', () => { + const raw = 'HTTP 403 em https://cdn.camara.leg.br/trecho.mp4' + expect(speechCutFailureMessage({ error: raw, step: 'resolving' })).toBe( + 'Não foi possível localizar o trecho na Câmara.', + ) + expect(speechCutFailureMessage({ error: 'ffmpeg failed', step: 'cutting' })).toBe( + 'Não foi possível cortar o trecho.', + ) + expect(speechCutFailureMessage({ error: 'storage down', step: 'metadata' })).toBe( + 'Não foi possível guardar o arquivo do corte.', + ) + expect(speechCutFailureMessage({ error: 'storage down', step: 'publishing' })).toBe( + 'Não foi possível guardar o arquivo do corte.', + ) + expect(speechCutFailureMessage({ error: 'boom', step: null })).toBe( + 'Não foi possível preparar o corte.', + ) + expect(speechCutFailureMessage({ error: raw, step: 'resolving' })).not.toContain('https://') + }) + + it('stays null when the row stored no cause', () => { + expect(speechCutFailureMessage({ error: null, step: 'resolving' })).toBeNull() + expect(speechCutFailureMessage({ error: ' ', step: 'resolving' })).toBeNull() + expect(speechCutFailureMessage({})).toBeNull() + }) }) describe('buildSpeechCutShare', () => { diff --git a/tests/unit/speechCutDialog.unit.spec.tsx b/tests/unit/speechCutDialog.unit.spec.tsx index ee8a991a..51b9c473 100644 --- a/tests/unit/speechCutDialog.unit.spec.tsx +++ b/tests/unit/speechCutDialog.unit.spec.tsx @@ -25,6 +25,7 @@ const cutView = (overrides: Partial = {}): SpeechCutViewMode mediaFilename: null, youtubeVideoId: null, publishedAt: null, + failureMessage: null, ...overrides, }) @@ -154,6 +155,43 @@ describe('SpeechCutDialog (C167)', () => { expect(onOpenChange).toHaveBeenCalledWith(false) }) + it('shows the cause the row mapped instead of the generic failure line', async () => { + fetchMock.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith(SUGGESTION_ENDPOINT)) { + return jsonResponse({ + status: 'success', + suggestion: { title: 'Fallback', description: 'Fallback', source: 'fallback' }, + }) + } + if (url.endsWith(SAVE_ENDPOINT)) { + return jsonResponse({ status: 'success', cut: cutView() }) + } + if (url.endsWith(STATUS_ENDPOINT)) { + return jsonResponse({ + status: 'success', + cut: cutView({ + status: 'failed', + step: null, + failureMessage: 'A Câmara ainda está gerando o vídeo deste trecho.', + }), + }) + } + throw new Error(`unexpected fetch: ${url}`) + }) + + renderDialog() + await screen.findByDisplayValue(/Trecho de BREVES COMUNICAÇÕES/) + fireEvent.click(screen.getByRole('button', { name: /Cortar e publicar/ })) + + await screen.findByText( + 'A Câmara ainda está gerando o vídeo deste trecho.', + {}, + { timeout: 4000 }, + ) + expect(screen.queryByText(/A Câmara não está entregando o vídeo desta fala agora/)).toBeNull() + }) + it('shows the honest failure and retries the same row (no duplicate create)', async () => { const retryBodies: unknown[] = [] fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { diff --git a/tests/unit/speechVodResolver.unit.spec.ts b/tests/unit/speechVodResolver.unit.spec.ts index 292218fd..5ce590f2 100644 --- a/tests/unit/speechVodResolver.unit.spec.ts +++ b/tests/unit/speechVodResolver.unit.spec.ts @@ -2,10 +2,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { resolveSpeechVod } from '@/utilities/speech/speechVodResolver' +import { + resolveSpeechVod, + SPEECH_VOD_CUT_POLICY, + SPEECH_VOD_PLAYER_POLICY, +} from '@/utilities/speech/speechVodResolver' const VOD_PLAYBACK = 'https://vod.camara.leg.br/trecho.mp4' const VOD_DOWNLOAD = 'https://vod.camara.leg.br/trecho-download.mp4' +const CACHED_PLAYBACK = 'https://cdn.camara.leg.br/fala-gravada.mp4' +const CACHED_DOWNLOAD = 'https://cdn.camara.leg.br/fala-gravada-download.mp4' const jsonResponse = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { @@ -146,11 +152,172 @@ describe('resolveSpeechVod', () => { const assertion = expect( resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 }), ).rejects.toThrow('connect ETIMEDOUT') - await vi.advanceTimersByTimeAsync(1_100) + await vi.advanceTimersByTimeAsync(SPEECH_VOD_PLAYER_POLICY.retryDelayMs + 100) await assertion expect(calls).toHaveLength(2) } finally { vi.useRealTimers() } }) + + it('keeps the C162 player click pinned to the short policy', () => { + expect(SPEECH_VOD_PLAYER_POLICY).toEqual({ + statusTimeoutMs: 15_000, + statusRetries: 1, + retryDelayMs: 1_000, + pollAttempts: 0, + pollDelayMs: 1_000, + }) + }) +}) + +describe('resolveSpeechVod under the C169 cut policy', () => { + const coordinates = { eventId: 1, audioId: 2, excerptTms: 3 } + + it('polls a GERANDO answer until PRONTO instead of failing on the first read', async () => { + vi.useFakeTimers() + try { + let statusReads = 0 + const calls = stubFetch(async (url) => { + if (url.includes('video-sob-demanda')) { + statusReads += 1 + return statusReads === 1 + ? jsonResponse({ estado: 'GERANDO', video: null }) + : jsonResponse(readyBody) + } + return mediaResponse(206, 'video/mp4') + }) + + const assertion = resolveSpeechVod(coordinates, { policy: SPEECH_VOD_CUT_POLICY }) + await vi.advanceTimersByTimeAsync(SPEECH_VOD_CUT_POLICY.pollDelayMs + 100) + + await expect(assertion).resolves.toEqual({ + state: 'pronto', + playbackUrl: VOD_PLAYBACK, + downloadUrl: VOD_DOWNLOAD, + }) + expect(statusReads).toBe(2) + expect(calls[0]?.url).toContain('trecho=3') + } finally { + vi.useRealTimers() + } + }) + + it('gives up on GERANDO only after the declared poll budget', async () => { + vi.useFakeTimers() + try { + const calls = stubFetch(async () => jsonResponse({ estado: 'GERANDO', video: null })) + + const assertion = resolveSpeechVod(coordinates, { policy: SPEECH_VOD_CUT_POLICY }) + await vi.advanceTimersByTimeAsync( + SPEECH_VOD_CUT_POLICY.pollDelayMs * SPEECH_VOD_CUT_POLICY.pollAttempts + 100, + ) + + await expect(assertion).resolves.toEqual({ state: 'gerando' }) + expect(calls).toHaveLength(1 + SPEECH_VOD_CUT_POLICY.pollAttempts) + } finally { + vi.useRealTimers() + } + }) + + it('resolves with the stored link when the API answers INDISPONIVEL', async () => { + stubFetch(async (url) => + url.includes('video-sob-demanda') + ? jsonResponse({ estado: 'INDISPONIVEL', video: null }) + : mediaResponse(206, 'video/mp4'), + ) + + await expect( + resolveSpeechVod(coordinates, { + policy: SPEECH_VOD_CUT_POLICY, + cachedUrls: { playbackUrl: CACHED_PLAYBACK, downloadUrl: CACHED_DOWNLOAD }, + }), + ).resolves.toEqual({ + state: 'pronto', + playbackUrl: CACHED_PLAYBACK, + downloadUrl: CACHED_DOWNLOAD, + }) + }) + + it('resolves with the stored link when the API transport keeps failing', async () => { + vi.useFakeTimers() + try { + stubFetch(async (url) => { + if (url.includes('video-sob-demanda')) throw new Error('connect ETIMEDOUT') + return url === CACHED_PLAYBACK ? mediaResponse(206, 'video/mp4') : mediaResponse(403, null) + }) + + const assertion = resolveSpeechVod(coordinates, { + policy: SPEECH_VOD_CUT_POLICY, + cachedUrls: { playbackUrl: CACHED_PLAYBACK }, + }) + await vi.advanceTimersByTimeAsync(SPEECH_VOD_CUT_POLICY.retryDelayMs + 100) + + await expect(assertion).resolves.toEqual({ + state: 'pronto', + playbackUrl: CACHED_PLAYBACK, + downloadUrl: null, + }) + } finally { + vi.useRealTimers() + } + }) + + it('prefers the live API link and never probes the stored cache', async () => { + const calls = stubFetch(async (url) => + url.includes('video-sob-demanda') ? jsonResponse(readyBody) : mediaResponse(206, 'video/mp4'), + ) + + await expect( + resolveSpeechVod(coordinates, { + policy: SPEECH_VOD_CUT_POLICY, + cachedUrls: { playbackUrl: CACHED_PLAYBACK, downloadUrl: CACHED_DOWNLOAD }, + }), + ).resolves.toEqual({ + state: 'pronto', + playbackUrl: VOD_PLAYBACK, + downloadUrl: VOD_DOWNLOAD, + }) + expect(calls.some((call) => call.url.startsWith('https://cdn.camara.leg.br/'))).toBe(false) + }) + + it('tries the stored link when the PRONTO hash is dead and refuses a dead cache', async () => { + stubFetch(async (url) => + url.includes('video-sob-demanda') + ? jsonResponse(readyBody) + : url === VOD_PLAYBACK || url === VOD_DOWNLOAD + ? mediaResponse(403, null) + : url === CACHED_PLAYBACK + ? mediaResponse(200, 'text/html; charset=utf-8') + : mediaResponse(200, 'video/mp4'), + ) + + await expect( + resolveSpeechVod(coordinates, { + policy: SPEECH_VOD_CUT_POLICY, + cachedUrls: { playbackUrl: CACHED_PLAYBACK }, + }), + ).resolves.toEqual({ state: 'pronto', playbackUrl: null, downloadUrl: null }) + }) + + it('still throws with cachedUrls when neither source has a verified link', async () => { + vi.useFakeTimers() + try { + stubFetch(async (url) => { + if (url.includes('video-sob-demanda')) throw new Error('connect ETIMEDOUT') + return mediaResponse(200, 'text/html; charset=utf-8') + }) + + const assertion = expect( + resolveSpeechVod(coordinates, { + policy: SPEECH_VOD_CUT_POLICY, + cachedUrls: { playbackUrl: CACHED_PLAYBACK, downloadUrl: CACHED_DOWNLOAD }, + }), + ).rejects.toThrow('connect ETIMEDOUT') + await vi.advanceTimersByTimeAsync(SPEECH_VOD_CUT_POLICY.retryDelayMs + 100) + await assertion + } finally { + vi.useRealTimers() + } + }) })