mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
fix(C169): corte do acervo resolve o trecho com a Câmara instável e falha dizendo o porquê (Closes #1045)
This commit is contained in:
parent
83f30dfc82
commit
046b51e38b
10 changed files with 790 additions and 68 deletions
1
docs/changelog/2026-09-15-c169.md
Normal file
1
docs/changelog/2026-09-15-c169.md
Normal file
|
|
@ -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.
|
||||
145
docs/plans/c169-corte-vod-confiabilidade-impl.md
Normal file
145
docs/plans/c169-corte-vod-confiabilidade-impl.md
Normal file
|
|
@ -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 <url>`/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 <url>`/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/<id>` 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.
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<SpeechCutStep, string> = {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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<unknown> =>
|
||||
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<unknown> => updateCutSystem(payload, cutId, { status: 'failed', step, error: message })
|
||||
|
||||
const updateStep = (payload: Payload, cutId: number, step: SpeechCutStep): Promise<unknown> =>
|
||||
updateCutSystem(payload, cutId, { step })
|
||||
|
|
@ -167,39 +182,51 @@ const runFfmpeg = (args: string[], durationSeconds: number): Promise<void> =>
|
|||
*/
|
||||
export const runSpeechCutJob = async (payload: Payload, cutId: number): Promise<void> => {
|
||||
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<void> => {
|
||||
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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<unknown> => {
|
||||
/** One bounded read of the `video-sob-demanda` JSON; throws after the retries. */
|
||||
const fetchVodStatus = async (url: string, policy: SpeechVodPolicy): Promise<CamaraVodStatus> => {
|
||||
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<boolean> => {
|
|||
}
|
||||
}
|
||||
|
||||
export const resolveSpeechVod = async ({
|
||||
eventId,
|
||||
audioId,
|
||||
excerptTms,
|
||||
}: {
|
||||
type SpeechVodCoordinates = {
|
||||
eventId: number
|
||||
audioId: number
|
||||
excerptTms: number
|
||||
}): Promise<SpeechVodResolution> => {
|
||||
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<SpeechVodResolution> => {
|
||||
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<SpeechVodResolution | null> => {
|
||||
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<SpeechVodResolution> => {
|
||||
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<SpeechVodResolution> => {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SpeechImportBundle> = {}): Promise<number> => {
|
||||
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-'))
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const cutView = (overrides: Partial<SpeechCutViewModel> = {}): 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) => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue