mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C162): player do acervo resolve o trecho sob demanda e YouTube vira default (Closes #999)
This commit is contained in:
parent
302783dc18
commit
fbe67f0358
20 changed files with 1564 additions and 177 deletions
1
docs/changelog/2026-09-14-c162.md
Normal file
1
docs/changelog/2026-09-14-c162.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C162 — VOD do acervo: player resolve o trecho sob demanda e YouTube vira default quando existe (2026-09-14):** o detalhe da fala deixa de depender do MP4 efêmero gravado (`vod.camara.leg.br`, apagado pelo CDN) e escolhe a superfície pelas coordenadas da linha: **YouTube default** quando `youtubeUrl` parseia (embed `youtube-nocookie`, sem chamada à Câmara no render) e **capa "Assistir o trecho"** quando só há VOD armazenado, resolvendo no clique o trecho exato (`video-sob-demanda?idAudio=&trecho=<excerptTMs>`) com links **verificados antes de tocar/baixar**. Dono novo das regras puras: `src/lib/speechVod.ts` recebe `CAMARA_USER_AGENT`/`buildVodUrl`/`parseVodStatus`/`parseDurationToSeconds` (movidos de `scripts/lib/camaraSpeeches.mjs`, que passa a re-exportar; `camara:pilot` ganha o mesmo loader tsx do `camara:import`) e adiciona `parseYoutubeVideoId` (watch/`youtu.be`/embed/live/shorts) e `excerptOffsetSeconds` (epoch ms → BRT `Intl` menos o naive `eventStartAt`, literal da intenção 15:43:54−15:00=2634). Resolver server-only `speechVodResolver` faz **1 chamada bounded** (2 tentativas/15s, UA da Câmara) e probe Range `bytes=0-1023` em paralelo no playback e no download (fallback HEAD em 405/501; `text/html` = hash morto), devolvendo `pronto`/`gerando`/`indisponivel`; a action `resolveSpeechVodForActor` gateia `canReadSpeechCatalog` antes da leitura (`overrideAccess: false`, select só dos campos de elegibilidade) e recusa fala sem VOD armazenado sem tocar a rede; a rota `POST /campanha/comunicacao/acervo/resolver-vod` usa `campaignJsonMutationRoute` (mesma origem) com mensagens constantes em `src/lib/schemas/speechVod.ts`. O view model perde `downloadUrl`/`vodDownloadUrl` da lista e do detalhe (o link armazenado nunca é renderizado — é só sinal de elegibilidade) e ganha `youtubeVideoId`/`youtubeOffsetSeconds`/`vodResolvable`; `SpeechDetailPlayer` reescrito com superfície única por quadrante (iframe com seek por reload `start=offset+segmento` — degradação C admitida no gate, gatilho registrado —, `<video>` nativo 0-based pós-PRONTO, painéis honestos de `GERANDO`/falha/indisponível, `aria-busy`+live region no pending) e "Baixar vídeo (MP4)" abre aba síncrona e só aponta para URL verificada (nunca arquivo morto); o "Baixar" direto do card da lista sai (com o campo morto do view model). Sem migration/collection/`Consent`/S3. Testes: unit `speechVod` (11), `speechVodResolver` (6, fetch mockado), `speechDetailPlayer` (9, jsdom: quadrantes, pending, retry, seek) + `camaraSpeeches` verde pela re-exportação; int `speechAcervo` estendido (offset/vodResolvable, lista sem download); e2e `campaignSpeechAcervo` renegociado (11 verdes: card sem download e sem link VOD, ambos→YouTube com `start` somado, só VOD→capa, sem vídeo→indisponível sem retry, rota 401/403/400 sem rede). `gate:fast` (3061 unit) + e2e local verdes.
|
||||
165
docs/plans/acervo-vod-sob-demanda-impl.md
Normal file
165
docs/plans/acervo-vod-sob-demanda-impl.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# Impl: Acervo de falas: player resolve o trecho sob demanda e YouTube vira default quando existe
|
||||
|
||||
Status: em execução
|
||||
Atualizado em: 2026-09-14
|
||||
Issue: #999
|
||||
Intenção: docs/plans/acervo-vod-sob-demanda.md
|
||||
Appetite restante: herdado (~1,5–2 dias eng; sem corte proposto — a fatia cabe porque não há schema, persistência nem IFrame API)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** com o CDN da Câmara vazio, abrir uma fala ainda toca (YouTube quando existir; VOD do trecho exato resolvido no clique quando só houver VOD) e "Baixar" nunca entrega arquivo inexistente — o MP4 é resolvido e verificado na Câmara no clique.
|
||||
- **O que NÃO negociar:** render/RSC não chama a Câmara (resolução só no clique); `excerptTMs` verbatim (nunca vizinho); link do banco é cache (pode ser ignorado; nunca requisito); sem S3/espelho/transcode; sem segundo player/editor; sem migration/collection/`Consent` novos; crédito "Fonte: Câmara dos Deputados · CC BY 4.0" mantido; gate `canReadSpeechCatalog` vale para a resolução; URLs públicas do acervo intocadas; "Baixar" direto do card da lista sai; `PRONTO` com hash morto vira estado honesto, nunca arquivo morto.
|
||||
- **O que reavaliar (hipóteses da intenção):**
|
||||
- Os helpers de `scripts/lib/camara*.mjs` não são importáveis de `src/` (`.mjs`, `node:crypto`, pilot em node puro) → decisão 1 extrai as regras puras para `src/lib/speechVod.ts`.
|
||||
- `youtubeUrl` é `event.urlRegistro` do open data (formato variável) → parser defensivo novo (`parseYoutubeVideoId`), não um `slice` de querystring.
|
||||
- A intenção **recomenda** IFrame API para o seek na transcrição, mas admite a degradação C (recarregar o iframe com `start=`) se não couber no appetite. Este plano **escolhe a degradação C** (decisão 4) e registra o gatilho de revisitação — divergência de engenharia permitida pela própria intenção, a destacar no gate.
|
||||
- O draft usa "Não foi possível carregar o vídeo deste trecho" (falha) e a intenção usa "vídeo indisponível neste momento" (quadrante nenhum). O plano preserva as duas: quadrante sem oferta = indisponível sem "Tentar novamente"; falha/GERANDO após oferta = cena 4 com "Tentar novamente".
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Detalhe da fala] --> B{youtubeVideoId?}
|
||||
B -- sim --> Y[iframe youtube-nocookie<br/>start = offset + t]
|
||||
B -- não --> C{VOD armazenado +<br/>eventId/audioId/excerptTMs?}
|
||||
C -- não --> N[Bloco indisponível<br/>+ Abrir fonte]
|
||||
C -- sim --> P[Capa só VOD<br/>'Assistir o trecho']
|
||||
Y --> D[Baixar vídeo MP4]
|
||||
P --> R[POST resolver-vod<br/>só no clique]
|
||||
D --> R
|
||||
R --> G{Câmara video-sob-demanda}
|
||||
G -- PRONTO --> V[probe Range 206 video/mp4<br/>playback e download]
|
||||
V -- verificado --> S[video nativo no trecho 0-based<br/>+ download na aba aberta]
|
||||
V -- falha --> F[Estado honesto<br/>+ Tentar novamente]
|
||||
G -- GERANDO --> E[Pendência visível<br/>+ Tentar novamente]
|
||||
G -- INDISPONIVEL/erro --> F
|
||||
```
|
||||
|
||||
**Opções consideradas (arquitetura):** A) helpers puros em `src/lib/speechVod.ts` (dono único) + resolver server-only na vertical + rota JSON; B) duplicar helpers em `src/` e deixar `scripts/` intocado; C) `src/` importar `scripts/lib/camaraSpeeches.mjs` em runtime.
|
||||
**Recomendação:** A — o app fica com o dono das regras de contrato (URL de trecho, parse do estado, offset BRT), os scripts re-exportam via tsx (precedente `camara:import`) e a resolução interativa nasce server-only com política própria de latência.
|
||||
**Rejeitadas:** B porque é o twin que a engineering-standards proíbe (drift no contrato `trecho`/`estado`); C porque inverte a fronteira (runtime de produção dependendo de dev tooling), arrasta `node:crypto` para o bundle e não é o dono natural das regras.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**1. Onde vive a resolução VOD (extração dos helpers).**
|
||||
Opções: A) extrair as regras puras de VOD (`CAMARA_USER_AGENT`, `buildVodUrl`, `parseVodStatus`, `parseDurationToSeconds`) para `src/lib/speechVod.ts`; `scripts/lib/camaraSpeeches.mjs` passa a importar/re-exportar de lá (define só o que é de script) e `camara:pilot` ganha o mesmo loader tsx do `camara:import`; B) duplicar em `src/lib/speechVod.ts` e congelar os scripts; C) `src/utilities/speech/` importar `scripts/lib/camaraSpeeches.mjs` por caminho relativo.
|
||||
Recomendação: **A** — dono único, sem tocar no contrato dos scripts; o pilot é o único ponto de atrito e migra para `cross-env NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/pilot-camara-speeches.mjs` (o `camara:import` já roda assim; nenhum módulo do pilot importa `server-only`, então não precisa do seed-loader).
|
||||
Rejeitadas: B porque duplica o contrato mais sensível do item (qualquer mudança de `estado`/`trecho` passaria a ter dois donos); C porque o Docker copia `scripts/`, mas produção passaria a depender de tooling de dev, o alias `@/*` não cobre `scripts/` e `node:crypto` entraria no bundle do servidor.
|
||||
Nota: a política HTTP **não** é unificada — `probeLink`/`getJson` dos scripts têm contrato de relatório em lote (retry 3×/2s, nunca lança); o resolver do app tem contrato interativo (bounded, mapeia falha para estado honesto). Fork de política deliberado, com gatilho de revisitação: se um terceiro call-site interativo surgir, extrair `probeLink` para um módulo compartilhado.
|
||||
|
||||
**2. Rota de resolução.**
|
||||
Opções: A) `POST` JSON em `src/app/(campaign)/campanha/(app)/comunicacao/acervo/resolver-vod/route.ts` via `campaignJsonMutationRoute`, corpo `{ speechId }` (segmento estático convive com `acervo/[id]`; o wrapper não entrega params de rota, então o id vai no corpo, como `municipios/political-trend`); B) server action + `useActionState` no player; C) rota sob `campanha/api/**`.
|
||||
Recomendação: **A** — a convenção do repo (teste `codebaseConventions` recusa `POST` fora do wrapper) e a proteção estrutural de mesma origem já existem; o `(app)/comunicacao` está no manifesto de e2e; a rota é fine-grained (`dynamic = 'force-dynamic'`), autentica por cookie de campanha e responde JSON com estados discriminados.
|
||||
Rejeitadas: B porque o ladder de actions devolve `CampaignFormErrorState` (shape de formulário) para um resultado rico (URLs verificadas), e a fronteira de CSRF do wrapper é o mecanismo canônico para mutação JSON sob `/campanha`; C porque fica fora do prefixo do manifesto e exigiria entrada de allowlist no guard de convenções.
|
||||
|
||||
**3. Player default com ambos (4 quadrantes sem segundo player).**
|
||||
Opções: A) um único `SpeechDetailPlayer` client com máquina de estados e **uma** superfície por vez (`youtube` quando `youtubeVideoId`; `prompt`/`native` só quando não há YouTube; `unavailable` quando não há nenhum caminho); B) dois componentes irmãos (YouTube + VOD) com um orquestrador; C) manter `<video>` e iframe soltos no page/actions server.
|
||||
Recomendação: **A** — YouTube é default quando existe (decisão de produto sinalizada na intenção), o MP4 do "ambos" não troca a superfície (só o `Baixar` resolve), e o "só VOD" mostra capa → resolvendo → `<video>` nativo (0-based, como hoje). Baixar/Abrir fonte passam para dentro do player porque o pending e a URL verificada são acoplados ao estado dele (`sourceUrl` entra por prop); a página segue RSC.
|
||||
Rejeitadas: B porque multiplica arquivos e reintroduz o orquestrador que o player único elimina; C porque pending/seleção de URL são interativos (client boundary).
|
||||
|
||||
**4. Seek na transcrição com YouTube.**
|
||||
Opções: A) IFrame API (`enablejsapi`, `postMessage`/`seekTo`); B) recarregar o iframe com `start = offset + segment.startSeconds` (degradação C da intenção); C) link `?t=` abrindo o YouTube; D) desabilitar o clique.
|
||||
Recomendação: **B** — determinística, sem script de terceiro, o contrato vive no `src` do iframe (asserção de e2e por HTTP), e o clique continua posicionando no orador certo; o reload interrompe a reprodução, degradação explicitamente aceitável pela intenção.
|
||||
Rejeitadas: A porque o plumbing de script/mensagens não cabe com folga no appetite e não tem como ser provado no e2e browserless atual; C porque perde o posicionamento na página (regressão do C154); D porque a intenção marca como regressão.
|
||||
Com offset (`youtubeOffsetSeconds`) nulo (dados ausentes), a transcrição no YouTube não vira affordance de clique (sem "clique para posicionar") — melhor inerte que pular para outro orador. Gatilho de revisitação: se a assessoria relatar o "pisca/volta ao início" do reload, promover para A em item próprio (o view model já carrega o offset; nenhum dado muda).
|
||||
|
||||
**5. Persistência do link resolvido.**
|
||||
Opções: A) não persistir (estado só no client); B) persistir best-effort quando o ator tem `canUpdateSpeech` (coordinator/candidate), engolindo falha; C) persistir com `overrideAccess: true`.
|
||||
Recomendação: **A** — o link é cache declaradamente não confiável; a resolução por clique é o fluxo desenhado; nenhuma escrita nova, nenhum teste de write path, nenhum risco de ouroboros (link morto regravado).
|
||||
Rejeitadas: B porque amplia superfície de escrita e cria semântica "cache às vezes" sem ganho de produto medido; C porque amplia a escrita (proibido pela intenção).
|
||||
Gatilho de revisitação: latência p50 medida da Câmara tornar o clique repetidamente penoso → item próprio com `canUpdateSpeech` e invalidação explícita.
|
||||
|
||||
**6. "Baixar" do card da lista.**
|
||||
Opções: A) remover `downloadUrl` do `SpeechListItemViewModel`/`SpeechResultCard` (e do `speechListSelect`), preservando `sourceUrl`; B) manter o card baixando o link cru; C) trocar o botão do card por link para o detalhe.
|
||||
Recomendação: **A** — a intenção manda sair (link efêmero direto); `sourceUrl` continua no card; o detalhe já é o dono do "Ver fala/Assistir no trecho"; menos select na lista.
|
||||
Rejeitadas: B porque entrega arquivo possivelmente morto; C porque o card já tem "Assistir no trecho" como CTA primário.
|
||||
|
||||
**7. Como o estado `GERANDO` é tratado.**
|
||||
Opções: A) uma chamada `getJson` limitada (2 tentativas, timeout ~15s, backoff curto) que devolve `gerando` e a UI mostra pendência + "Tentar novamente" sem poll; B) poll curto no servidor (ex.: 3×5s) antes de responder; C) poll automático no client.
|
||||
Recomendação: **A** — o clique tem orçamento de latência; a regeneração observada vai de segundos a minutos, e o convite a tentar de novo é o desenho da intenção ("limite + convite, sem travar a página").
|
||||
Rejeitadas: B porque segura a request por 15s mesmo quando a Câmara está presa (o `resolveVod` de 200s dos scripts é inadequado aqui); C porque adiciona timers/rate e um retry escondido que a intenção não pede.
|
||||
|
||||
**8. Verificação do MP4 (probe) e entrega do download.**
|
||||
Opções: A) `PRONTO` → probe em paralelo de `linkParaReproducao` e `linkParaDownload` (GET `Range: bytes=0-1023`, fallback HEAD em 405/501, ~10s, nunca lança; verificado = HTTP ok e content-type presente ≠ `text/html`); `<video>` só com playback verificado; "Baixar" só entrega download verificado; B) confiar no `PRONTO` sem probe; C) verificar só no download.
|
||||
Recomendação: **A** — "Baixar só entrega arquivo verificado" e o player nativo não aponta para hash morto (o `PRONTO` velho medido devolve 403/hash morto). Se playback falha mas download passa, não há `<video>` (estado honesto com retry) mas o download verificado continua ofertado. Na entrega, o clique em "Baixar" abre uma aba em branco **sincronamente** (evita popup blocker) e a aponta para `downloadUrl` só depois de verificado; falha → fecha a aba e mostra o estado honesto. No quadrante ambos o YouTube continua tocando: a falha vira aviso inline ao lado das ações, **não** troca a superfície.
|
||||
Rejeitadas: B porque viola o aceite (arquivo morto entregue); C porque deixaria o player nativo servir hash morto (draft cena 5 diz "verificado antes de tocar").
|
||||
Rejeitadas na entrega: `window.open(downloadUrl)` depois do await (popup blocker) e `location.assign` (perde a página da campanha/perde o retry).
|
||||
|
||||
**9. Cache/estado no client.**
|
||||
Opções: A) resultado da resolução só em `useState` do player; reload volta ao default (YouTube no "ambos", capa no "só VOD"); B) `sessionStorage` com o link resolvido; C) revalidar/gravar no servidor.
|
||||
Recomendação: **A** — sem link morto cacheado, sem estado compartilhado entre abas/sessões; "Tentar novamente" limpa o erro e re-POSTa.
|
||||
Rejeitadas: B porque ressuscita link efêmero (o problema que o item resolve) e duplica a fonte da verdade; C porque colide com a decisão 5 (sem escrita) e com o render sem Câmara.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/lib/speechVod.ts`** (novo, puro, client-safe, sem imports): `CAMARA_USER_AGENT`, `buildVodUrl`, `parseVodStatus`, `parseDurationToSeconds` (movidos, dono novo), `parseYoutubeVideoId` (watch/`youtu.be`/embed/live/shorts; id `[A-Za-z0-9_-]{6,20}`; domínios YouTube), `excerptOffsetSeconds(excerptTMs, eventStartAt)` (epoch → hora BRT via `Intl` `America/Sao_Paulo` com `hour12:false`, menos o naive parseado como frame UTC; null se não parseável ou negativo; piso inteiro).
|
||||
- **`scripts/lib/camaraSpeeches.mjs`**: remove as definições movidas e as re-exporta de `../../src/lib/speechVod.ts`; `scripts/lib/camaraFetch.mjs` segue importando de `camaraSpeeches.mjs` (sem mudança).
|
||||
- **`package.json`**: `camara:pilot` ganha `cross-env NODE_OPTIONS="--no-deprecation --import=tsx/esm"` (espelha `camara:import`).
|
||||
- **`src/lib/schemas/speechVod.ts`** (novo): `speechVodRequestSchema = { speechId: positiveRelationshipId }` + constantes de mensagem (`SPEECH_VOD_INELIGIBLE_MESSAGE`, `SPEECH_VOD_FORBIDDEN_MESSAGE`, `SPEECH_VOD_NOT_FOUND_MESSAGE`, `SPEECH_VOD_GENERIC_ERROR_MESSAGE`) — exigidas pelo guard de convenções (sem literais em `safeMessages`/`throw new Error` sob `(campaign)`).
|
||||
- **`src/utilities/speech/speechVodResolver.ts`** (novo, `server-only`): `resolveSpeechVod({ eventId, audioId, excerptTms })` — `buildVodUrl` + uma chamada `getJson`-like bounded (2×, timeout 15s, UA da Câmara) + probe A em paralelo; devolve `{ state: 'pronto', playbackUrl, downloadUrl }` (URLs nulas quando o probe falha), `{ state: 'gerando' }`, `{ state: 'indisponivel' }`; lança só em falha de transporte/parse (o route colapsa no genérico).
|
||||
- **`src/app/(campaign)/campanha/actions/speech.ts`** (novo, ladder): `resolveSpeechVodForActor({ speechId })` — `getCampaignActionContext`, fast-fail `canReadSpeechCatalog(actor.role)`, `payload.find` da fala com `overrideAccess: false` e `select: { eventId, audioId, excerptTMs, vodPlaybackUrl, vodDownloadUrl }`; sem os campos/`vod` armazenado lança `SPEECH_VOD_INELIGIBLE_MESSAGE`; chama o resolver.
|
||||
- **`src/app/(campaign)/campanha/(app)/comunicacao/acervo/resolver-vod/route.ts`** (novo) + **`types.ts`**: `export const dynamic = 'force-dynamic'`; `POST = campaignJsonMutationRoute({ bodySchema, safeMessages, genericMessage }, handler)`; resposta `{ status: 'success', state, playbackUrl?, downloadUrl? }`.
|
||||
- **`src/utilities/speech/speechViewModels.ts`**: lista perde `downloadUrl`; detalhe perde `vodDownloadUrl` e ganha `youtubeVideoId`, `youtubeOffsetSeconds`, `vodResolvable` (vod armazenado **e** `eventId`/`audioId`/`excerptTMs`), calculados com os helpers puros.
|
||||
- **`src/utilities/speech/speechPageData.ts`**: `speechListSelect` perde `vodDownloadUrl`; `speechDetailSelect` ganha `vodDownloadUrl`, `eventId`, `audioId`, `excerptTMs`, `eventStartAt` (nenhum campo novo no banco — todos já existem).
|
||||
- **`src/components/campaign/speech/SpeechDetailPlayer.tsx`**: reescrito (client) com props `{ speechId, youtubeVideoId, youtubeOffsetSeconds, vodResolvable, segments, initialSeconds, sourceUrl }` e estados `idle | resolving | generating | ready | unavailable`; ações dentro do player.
|
||||
- **`src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx`**: passa as props novas e remove o bloco `<a>` de MP4 (o "Abrir fonte" passa ao player); asides/transcrição oficial intocados.
|
||||
- **`src/components/campaign/speech/SpeechResultCard.tsx`**: remove o `ActionLink` de download (e o import `DownloadIcon` morto).
|
||||
- **Migration:** nenhuma — sem schema change, sem `payload-types` novo, `push: false` intocado.
|
||||
- **Access / Consent:** nenhum `Consent`; leitura da fala com `overrideAccess: false` + gate `canReadSpeechCatalog` na action; nenhuma escrita; sem S3.
|
||||
- **UI:** Impeccable B — reusar `Button`/`Badge`, `aria-busy` + live region polite no pending (campanha-action-feedback), copy do draft; shape→craft→critique→polish no detalhe.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
**Não — não se aplica (feedback de ação).** Nenhum KPI/gráfico novo: "resolvendo/gerando/indisponível" é o estado da ação no player (draft cenas 3–4), não dado de acervo; a única forma nova é a superfície do player, já coberta pela direção de UI acima.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Fase 1 — Tracer puro + extração (dono único).** `src/lib/speechVod.ts` + re-exports em `scripts/lib/camaraSpeeches.mjs` + loader tsx do pilot.
|
||||
- Testes: `tests/unit/speechVod.unit.spec.ts` (node) — `buildVodUrl`/`parseVodStatus`/`parseDurationToSeconds` preservados, `excerptOffsetSeconds` com o literal da intenção (`1786473834650` + 15:00 → 2634), o fixture do import (`1675801808560` + `2023-02-07T15:00` → 9008), caso DST antigo, negativo/não-parseável → null; `parseYoutubeVideoId` (watch/`youtu.be`/embed/lixo). `tests/unit/camaraSpeeches.unit.spec.ts` segue verde **pela re-exportação** (prova que a extração preservou o contrato).
|
||||
- Verificação: `pnpm test:unit`, `pnpm typecheck`, `node --import=tsx/esm scripts/pilot-camara-speeches.mjs --help`.
|
||||
2. **Fase 2 — Server (view fields + resolver + rota).** Selects/view model, resolver server-only, schema/mensagens, action e rota.
|
||||
- Testes: `tests/unit/speechVodResolver.unit.spec.ts` (fetch mockado: PRONTO+206 → URLs verificadas; PRONTO+403 → sem URL; GERANDO; INDISPONIVEL; timeout lança); `tests/int/speechAcervo.int.spec.ts` estendido (detalhe expõe `youtubeVideoId`, `youtubeOffsetSeconds` calculado, `vodResolvable` true/false; lista sem `downloadUrl`).
|
||||
- Verificação: `pnpm test:unit`, `pnpm test:int`, `pnpm typecheck`, `pnpm build` (convivência `acervo/resolver-vod` × `acervo/[id]`).
|
||||
3. **Fase 3 — Player.** Máquina de estados, iframe com `start=` e capa só VOD, `<video>` nativo pós-PRONTO, aviso inline de falha no quadrante ambos.
|
||||
- Testes: `tests/unit/speechDetailPlayer.unit.spec.tsx` (jsdom + testing-library, fetch mockado) — só VOD: capa → clique → pending/`aria-busy` → `<video src>` no trecho; falha → bloco honesto + "Tentar novamente" re-POSTa; ambos: iframe com `start=offset+t`, Baixar pendente/desabilitado e depois entrega, transcrição atualiza o `src`; sem YouTube sem VOD → bloco sem retry.
|
||||
- Verificação: `pnpm test:unit`.
|
||||
4. **Fase 4 — Card/lista.** Remoção do download do card/view model e do select da lista.
|
||||
- Testes: unit/componente do card + int da lista.
|
||||
- Verificação: `pnpm test:unit`, `pnpm test:int`, `pnpm exec knip` (import órfão do `DownloadIcon` morto).
|
||||
5. **Fase 5 — E2E renegociado + gates.** `tests/e2e/campaignSpeechAcervo.e2e.spec.ts`:
|
||||
- fixtures novas com `youtubeUrl` + `eventStartAt`/`eventId`/`audioId`/`excerptTMs` (ambos e só VOD) e sem vídeo;
|
||||
- ambos: `youtube-nocookie.com/embed/<id>` com `start=<offset>` e "Baixar vídeo (MP4)" e `data-start-seconds`, sem `<video>` no load;
|
||||
- só VOD: capa "Assistir o trecho", sem iframe/`<video>` no load, "Baixar vídeo (MP4)" presente (e não contém o link `vod.camara.leg.br` na lista do card);
|
||||
- sem vídeo: "Vídeo indisponível neste momento" + "Abrir fonte", sem "Tentar novamente"; falha pós-clique coberta no unit da fase 3;
|
||||
- rota: POST sem sessão → 401; advisor → mensagem de acesso (400); origem estranha → 403; fala inelegível → 400 (sem rede).
|
||||
- Verificação: `pnpm gate:fast`, `pnpm test:e2e:affected`, `pnpm push`; changelog `docs/changelog/2026-09-14-c162-vod-sob-demanda.md`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- IFrame API do YouTube (seek sem reload) — degradação C aceita; gatilho na decisão 4.
|
||||
- Persistir/aquecer o link resolvido, re-verificação em background/cron, "gerar novamente" manual, regenerar os 924 em lote.
|
||||
- Espelhar/transcodificar MP4 (S3/Garage), download em lote, editor/corte.
|
||||
- Poll de `GERANDO` (servidor ou client) e qualquer chamada à Câmara no render/RSC.
|
||||
- Segundo player, redesign da lista/detalhe além da saída do "Baixar" do card, "Abrir no YouTube" no lugar do MP4.
|
||||
- Migration/collection/`Consent` novos; unificar a política de `probeLink` dos scripts com a do app (fork deliberado, decisão 1).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **`youtubeUrl` fora do formato esperado** (`urlRegistro` varia) → `parseYoutubeVideoId` defensivo com unit tests; null cai no caminho só VOD/indisponível (nunca iframe quebrado).
|
||||
- **Latência/anti-bot da Câmara** (400 "Acesso via bot", timeouts) → UA compartilhado, 2 tentativas com timeout curto, estado honesto com "Tentar novamente"; nenhuma página depende da Câmara para renderizar.
|
||||
- **Migração do pilot para tsx** (script dormente) → smoke `--help` + unit tests da re-exportação; rollback é uma linha de `package.json`.
|
||||
- **E2E browserless não clica** → interação (resolvendo → `<video>`, falha, retry, seek) provada no unit jsdom; e2e prova contrato HTTP, gates e o HTML inicial.
|
||||
- **Colisão de rota** `acervo/resolver-vod` × `acervo/[id]` → ids são numéricos e `pnpm build` valida; e2e cobre o POST.
|
||||
- **Manifesto de e2e**: `(app)/comunicacao`, `src/components/campaign/speech`, `src/utilities/speech` e `src/lib/speech*` já mapeiam para `campaignSpeechAcervo`; `src/lib/schemas/speechVod.ts` cai no prefixo `src/lib/speech`.
|
||||
- **Knip `exports:error`**: re-exports dos scripts cobertos pelas entradas `scripts/*.mjs`; símbolos novos usados por action/route/player; rodar `pnpm exec knip` na fase 4.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (quadrantes, `excerptTMs` verbatim, Baixar verificado, estado honesto, crédito CC BY, gate do acervo).
|
||||
- [ ] Invariantes AGENTS/engineering-standards (dono único, sem twin, sem migration/collection/Consent, sem escrita nova, `overrideAccess: false`, client boundary, zero warnings, dead code).
|
||||
- [ ] Testes de domínio previstos (unit/resolver/player/int/e2e) onde o access/write path e os contratos de URL mudam.
|
||||
|
||||
## Self-score (decision-quality, gate ≥4)
|
||||
|
||||
5/5 — (1) todas as decisões caras (extração, rota, player único, persistência, verificação) têm opções + rejeitadas explícitas; (2) cabe no appetite herdado porque corta IFrame API, persistência e poll; (3) rabbit holes nomeados com corte; (4) depth check: reusa `campaignJsonMutationRoute`, ladder de actions, `getCampaignActionContext`, `speechPageData`/`speechViewModels` e o padrão de feedback de ação — nada de shell/abstração paralela; (5) o outcome da intenção permanece intacto, com a degradação 4-C explicitamente permitida e sinalizada ao gate humano.
|
||||
|
|
@ -81,7 +81,7 @@
|
|||
"test:unit": "cross-env NODE_OPTIONS=--no-deprecation DATABASE_URL=postgresql://invalid:invalid@127.0.0.1:1/unit_tests_must_not_connect vitest run --config ./vitest.unit.config.mts",
|
||||
"images:resize": "node scripts/resize-images.mjs",
|
||||
"images:fill": "node scripts/fill-image.mjs",
|
||||
"camara:pilot": "node scripts/pilot-camara-speeches.mjs",
|
||||
"camara:pilot": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/pilot-camara-speeches.mjs",
|
||||
"camara:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-camara-speeches.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:tsc6": "tsc6 --noEmit",
|
||||
|
|
|
|||
|
|
@ -11,13 +11,18 @@
|
|||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
// The VOD/YouTube contract moved to its production owner (C162) and is
|
||||
// re-exported here so the C152/C153 scripts keep the same import surface.
|
||||
export {
|
||||
buildVodUrl,
|
||||
CAMARA_USER_AGENT,
|
||||
parseDurationToSeconds,
|
||||
parseVodStatus,
|
||||
} from '../../src/lib/speechVod.ts'
|
||||
|
||||
/** Câmara's open-data deputy id for Jorge Solla (used by the pilot). */
|
||||
export const SOLLA_DEPUTY_ID = 178857
|
||||
|
||||
/** Browser-like UA — the trecho/evento pages answer 400 "Acesso via bot" to curl. */
|
||||
export const CAMARA_USER_AGENT =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
||||
|
||||
/** Deep Infra OpenAI-compatible transcription endpoint (returns segments with `verbose_json`). */
|
||||
export const DEEPINFRA_TRANSCRIBE_URL = 'https://api.deepinfra.com/v1/openai/audio/transcriptions'
|
||||
|
||||
|
|
@ -139,40 +144,6 @@ export const legislatureForDate = (iso) => {
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Câmara duration spellings into seconds: `0h04'03"` (excerpt card)
|
||||
* and `0:04:07` / `4:07` (VOD status). Null when unparseable.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {number | null}
|
||||
*/
|
||||
export const parseDurationToSeconds = (value) => {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (raw === '') return null
|
||||
|
||||
const card = /^(\d+)h(\d{1,2})'(\d{1,2})"?$/.exec(raw)
|
||||
if (card) {
|
||||
const minutes = Number(card[2])
|
||||
const seconds = Number(card[3])
|
||||
if (minutes > 59 || seconds > 59) return null
|
||||
return Number(card[1]) * 3600 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
const parts = raw.split(':')
|
||||
if (!parts.every((part) => /^\d{1,2}$/.test(part))) return null
|
||||
if (parts.length === 3) {
|
||||
const [hours, minutes, seconds] = parts.map(Number)
|
||||
if (minutes > 59 || seconds > 59) return null
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
const [minutes, seconds] = parts.map(Number)
|
||||
if (seconds > 59) return null
|
||||
return minutes * 60 + seconds
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const integerOrNull = (value) => {
|
||||
const n = Number(value)
|
||||
return Number.isInteger(n) && n > 0 ? n : null
|
||||
|
|
@ -222,38 +193,6 @@ export const parseEventExcerpts = (html, eventId) => {
|
|||
return excerpts
|
||||
}
|
||||
|
||||
/** The async VOD-generation status URL for one excerpt. */
|
||||
export const buildVodUrl = (eventId, audioId, tMs) =>
|
||||
`https://www.camara.leg.br/evento-legislativo/${eventId}/video-sob-demanda?idAudio=${audioId}&trecho=${tMs}`
|
||||
|
||||
/**
|
||||
* Normalizes the `video-sob-demanda` JSON (`{ estado, video }`). `state` is
|
||||
* `GERANDO` while the server transcodes, `PRONTO` when the MP4 exists,
|
||||
* `INDISPONIVEL` when there is none.
|
||||
*
|
||||
* @param {unknown} json
|
||||
* @returns {{ state: string, video: { title: string | null, subtitle: string | null, duration: string | null, clock: string | null, downloadUrl: string | null, playbackUrl: string | null } | null }}
|
||||
*/
|
||||
export const parseVodStatus = (json) => {
|
||||
const data = json && typeof json === 'object' ? json : {}
|
||||
const estado = typeof data.estado === 'string' ? data.estado : 'DESCONHECIDO'
|
||||
const raw = data.video && typeof data.video === 'object' ? data.video : null
|
||||
const pick = (key) => (typeof raw?.[key] === 'string' ? raw[key] : null)
|
||||
return {
|
||||
state: estado,
|
||||
video: raw
|
||||
? {
|
||||
title: pick('titulo'),
|
||||
subtitle: pick('subtitulo'),
|
||||
duration: pick('duracao'),
|
||||
clock: pick('horario'),
|
||||
downloadUrl: pick('linkParaDownload'),
|
||||
playbackUrl: pick('linkParaReproducao'),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Events of the speech's date, nearest-first by start time. The open-data
|
||||
* `uriEvento` comes empty, so the event is inferred from the date/time.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import config from '@payload-config'
|
||||
import { ArrowLeftIcon, DownloadIcon, ExternalLinkIcon } from 'lucide-react'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
|
@ -103,31 +103,16 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD
|
|||
|
||||
<div className="mt-4">
|
||||
<SpeechDetailPlayer
|
||||
vodPlaybackUrl={view.vodPlaybackUrl}
|
||||
speechId={view.id}
|
||||
youtubeVideoId={view.youtubeVideoId}
|
||||
youtubeOffsetSeconds={view.youtubeOffsetSeconds}
|
||||
vodResolvable={view.vodResolvable}
|
||||
segments={view.segments}
|
||||
initialSeconds={initialSeconds}
|
||||
sourceUrl={sourceUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{view.vodDownloadUrl ? (
|
||||
<Button asChild className="min-h-10">
|
||||
<a href={view.vodDownloadUrl} target="_blank" rel="noreferrer">
|
||||
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Baixar vídeo (MP4)
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{sourceUrl ? (
|
||||
<Button asChild variant="outline" className="min-h-10">
|
||||
<a href={sourceUrl} target="_blank" rel="noreferrer">
|
||||
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir fonte
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{view.officialTranscript ? (
|
||||
<details className="mt-5 rounded-lg border p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { resolveSpeechVodForActor } from '@/app/(campaign)/campanha/actions/speech'
|
||||
import {
|
||||
SPEECH_VOD_FORBIDDEN_MESSAGE,
|
||||
SPEECH_VOD_GENERIC_ERROR_MESSAGE,
|
||||
SPEECH_VOD_INELIGIBLE_MESSAGE,
|
||||
SPEECH_VOD_NOT_FOUND_MESSAGE,
|
||||
speechVodRequestSchema,
|
||||
} from '@/lib/schemas/speechVod'
|
||||
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
|
||||
|
||||
import type { SpeechVodResolveResponse } from './types'
|
||||
|
||||
export type { SpeechVodResolveResponse } from './types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const POST = campaignJsonMutationRoute(
|
||||
{
|
||||
bodySchema: speechVodRequestSchema,
|
||||
safeMessages: [
|
||||
SPEECH_VOD_FORBIDDEN_MESSAGE,
|
||||
SPEECH_VOD_INELIGIBLE_MESSAGE,
|
||||
SPEECH_VOD_NOT_FOUND_MESSAGE,
|
||||
],
|
||||
genericMessage: SPEECH_VOD_GENERIC_ERROR_MESSAGE,
|
||||
},
|
||||
async ({ speechId }) => {
|
||||
const resolution = await resolveSpeechVodForActor({ speechId })
|
||||
|
||||
return NextResponse.json<SpeechVodResolveResponse>({ status: 'success', resolution })
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { SpeechVodResolution } from '@/lib/speechVod'
|
||||
|
||||
/** Wire contract of `POST /campanha/comunicacao/acervo/resolver-vod` (C162). */
|
||||
export type SpeechVodResolveResponse =
|
||||
| { status: 'success'; resolution: SpeechVodResolution }
|
||||
| { status: 'error'; message: string }
|
||||
56
src/app/(campaign)/campanha/actions/speech.ts
Normal file
56
src/app/(campaign)/campanha/actions/speech.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
'use server'
|
||||
|
||||
import { canReadSpeechCatalog } from '@/lib/campaignRoles'
|
||||
import {
|
||||
SPEECH_VOD_FORBIDDEN_MESSAGE,
|
||||
SPEECH_VOD_INELIGIBLE_MESSAGE,
|
||||
SPEECH_VOD_NOT_FOUND_MESSAGE,
|
||||
speechVodRequestSchema,
|
||||
} from '@/lib/schemas/speechVod'
|
||||
import type { SpeechVodResolution } from '@/lib/speechVod'
|
||||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
import { resolveSpeechVod } from '@/utilities/speech/speechVodResolver'
|
||||
|
||||
/**
|
||||
* C162 — resolves the exact excerpt MP4 for one speech through the Câmara
|
||||
* `video-sob-demanda` endpoint. The catalog gate is checked BEFORE the read so
|
||||
* a denied actor gets the domain message (the read itself runs with
|
||||
* `overrideAccess: false` and would only yield "not found"). The stored VOD
|
||||
* link is an eligibility signal, never a URL handed to the client: the
|
||||
* resolution owns the truth, and the response only carries probed links.
|
||||
*/
|
||||
export const resolveSpeechVodForActor = async (input: {
|
||||
speechId: number
|
||||
}): Promise<SpeechVodResolution> => {
|
||||
const { speechId } = speechVodRequestSchema.parse(input)
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
||||
if (!canReadSpeechCatalog(actor.role)) throw new Error(SPEECH_VOD_FORBIDDEN_MESSAGE)
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'speech',
|
||||
where: { id: { equals: speechId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: {
|
||||
eventId: true,
|
||||
audioId: true,
|
||||
excerptTMs: true,
|
||||
vodPlaybackUrl: true,
|
||||
vodDownloadUrl: true,
|
||||
},
|
||||
user: actor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
const speech = result.docs[0]
|
||||
if (!speech) throw new Error(SPEECH_VOD_NOT_FOUND_MESSAGE)
|
||||
|
||||
const hasStoredVod = Boolean(speech.vodPlaybackUrl || speech.vodDownloadUrl)
|
||||
const { eventId, audioId, excerptTMs } = speech
|
||||
if (!hasStoredVod || !eventId || !audioId || !excerptTMs) {
|
||||
throw new Error(SPEECH_VOD_INELIGIBLE_MESSAGE)
|
||||
}
|
||||
|
||||
return resolveSpeechVod({ eventId, audioId, excerptTms: excerptTMs })
|
||||
}
|
||||
|
|
@ -1,29 +1,108 @@
|
|||
'use client'
|
||||
|
||||
import { FilmIcon } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { DownloadIcon, ExternalLinkIcon, FilmIcon, PlayIcon } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
|
||||
import type { SpeechVodResolveResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/resolver-vod/types'
|
||||
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
import type { SpeechVodResolution } from '@/lib/speechVod'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechViewModels'
|
||||
|
||||
/**
|
||||
* C154 — native `<video>` plus the clickable ASR transcript. The VOD is the
|
||||
* Câmara excerpt MP4 and the segment `startSeconds` are relative to it, so a
|
||||
* click is a plain `currentTime` seek. No autoplay (browsers block it): the
|
||||
* user presses play and the video starts at the requested fragment.
|
||||
*/
|
||||
export const SpeechDetailPlayer = ({
|
||||
vodPlaybackUrl,
|
||||
segments,
|
||||
initialSeconds,
|
||||
}: {
|
||||
vodPlaybackUrl: string | null
|
||||
const RESOLVE_ENDPOINT = '/campanha/comunicacao/acervo/resolver-vod'
|
||||
|
||||
type ResolutionState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'resolving' }
|
||||
| { kind: 'generating' }
|
||||
| { kind: 'resolved'; playbackUrl: string | null; downloadUrl: string | null }
|
||||
| { kind: 'failed'; message: string | null }
|
||||
|
||||
const toResolutionState = (resolution: SpeechVodResolution): ResolutionState =>
|
||||
resolution.state === 'pronto'
|
||||
? {
|
||||
kind: 'resolved',
|
||||
playbackUrl: resolution.playbackUrl,
|
||||
downloadUrl: resolution.downloadUrl,
|
||||
}
|
||||
: resolution.state === 'gerando'
|
||||
? { kind: 'generating' }
|
||||
: { kind: 'failed', message: null }
|
||||
|
||||
type SpeechDetailPlayerProps = {
|
||||
speechId: number
|
||||
/** C162 — YouTube default when the session link parses; null falls back to the VOD. */
|
||||
youtubeVideoId: string | null
|
||||
/** Seconds from the session start to the excerpt; null makes the YouTube seek inert. */
|
||||
youtubeOffsetSeconds: number | null
|
||||
/** Stored VOD + excerpt coordinates: the Câmara may be asked on click. */
|
||||
vodResolvable: boolean
|
||||
segments: readonly SpeechDetailSegmentViewModel[]
|
||||
initialSeconds: number | null
|
||||
}) => {
|
||||
sourceUrl: string | null
|
||||
}
|
||||
|
||||
const StatusPanel = ({
|
||||
title,
|
||||
detail,
|
||||
busy = false,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
detail: string
|
||||
busy?: boolean
|
||||
children?: ReactNode
|
||||
}) => (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex aspect-video w-full flex-col items-center justify-center gap-2 rounded-lg border bg-muted/40 px-6 text-center text-muted-foreground"
|
||||
>
|
||||
{busy ? (
|
||||
<Spinner
|
||||
className="size-6 text-muted-foreground"
|
||||
aria-label="Resolvendo o trecho na Câmara"
|
||||
/>
|
||||
) : (
|
||||
<FilmIcon className="size-8" aria-hidden="true" />
|
||||
)}
|
||||
<p className="text-sm font-medium text-foreground/90">{title}</p>
|
||||
<p className="max-w-md text-xs">{detail}</p>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* C162 — one player for the four acervo quadrants: YouTube default when the
|
||||
* session link exists (no Câmara call on render), the stored VOD otherwise
|
||||
* resolved on click (`video-sob-demanda`, exact `excerptTMs`, links probed
|
||||
* before use), and honest states when neither path has a playable file.
|
||||
* Transcript clicks seek the current surface: native seconds on the MP4,
|
||||
* session-offset seconds on the YouTube embed.
|
||||
*/
|
||||
export const SpeechDetailPlayer = ({
|
||||
speechId,
|
||||
youtubeVideoId,
|
||||
youtubeOffsetSeconds,
|
||||
vodResolvable,
|
||||
segments,
|
||||
initialSeconds,
|
||||
sourceUrl,
|
||||
}: SpeechDetailPlayerProps) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [resolution, setResolution] = useState<ResolutionState>({ kind: 'idle' })
|
||||
const [activeStart, setActiveStart] = useState<number | null>(null)
|
||||
const [youtubeStart, setYoutubeStart] = useState<number | null>(() =>
|
||||
youtubeVideoId && youtubeOffsetSeconds !== null && initialSeconds !== null
|
||||
? youtubeOffsetSeconds + initialSeconds
|
||||
: null,
|
||||
)
|
||||
|
||||
const resolving = resolution.kind === 'resolving'
|
||||
const playbackUrl = resolution.kind === 'resolved' ? resolution.playbackUrl : null
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
|
|
@ -38,9 +117,50 @@ export const SpeechDetailPlayer = ({
|
|||
}
|
||||
video.addEventListener('loadedmetadata', seek, { once: true })
|
||||
return () => video.removeEventListener('loadedmetadata', seek)
|
||||
}, [initialSeconds, vodPlaybackUrl])
|
||||
}, [initialSeconds, playbackUrl])
|
||||
|
||||
const requestResolution = async (deliverDownload: boolean) => {
|
||||
// Opened synchronously inside the click gesture so the popup blocker sees
|
||||
// a user-initiated tab; pointed at the verified URL only after the probe.
|
||||
const pendingTab = deliverDownload ? window.open('', '_blank') : null
|
||||
setResolution({ kind: 'resolving' })
|
||||
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<SpeechVodResolveResponse>(RESOLVE_ENDPOINT, {
|
||||
speechId,
|
||||
})
|
||||
if (!ok || payload.status !== 'success') {
|
||||
pendingTab?.close()
|
||||
setResolution({
|
||||
kind: 'failed',
|
||||
message: payload.status === 'error' ? payload.message : null,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const next = toResolutionState(payload.resolution)
|
||||
setResolution(next)
|
||||
if (!deliverDownload) return
|
||||
|
||||
if (next.kind === 'resolved' && next.downloadUrl) {
|
||||
if (pendingTab) pendingTab.location.href = next.downloadUrl
|
||||
else window.open(next.downloadUrl, '_blank')
|
||||
return
|
||||
}
|
||||
pendingTab?.close()
|
||||
} catch {
|
||||
pendingTab?.close()
|
||||
setResolution({ kind: 'failed', message: null })
|
||||
}
|
||||
}
|
||||
|
||||
const seekTo = (seconds: number) => {
|
||||
if (youtubeVideoId) {
|
||||
if (youtubeOffsetSeconds === null) return
|
||||
setYoutubeStart(youtubeOffsetSeconds + seconds)
|
||||
setActiveStart(seconds)
|
||||
return
|
||||
}
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
video.currentTime = seconds
|
||||
|
|
@ -57,30 +177,150 @@ export const SpeechDetailPlayer = ({
|
|||
setActiveStart(active?.startSeconds ?? null)
|
||||
}
|
||||
|
||||
const youtubeSrc = useMemo(() => {
|
||||
const params = new URLSearchParams({ playsinline: '1', rel: '0' })
|
||||
if (youtubeStart !== null && youtubeStart > 0)
|
||||
params.set('start', String(Math.floor(youtubeStart)))
|
||||
return `https://www.youtube-nocookie.com/embed/${youtubeVideoId}?${params.toString()}`
|
||||
}, [youtubeVideoId, youtubeStart])
|
||||
|
||||
const seekable = youtubeVideoId ? youtubeOffsetSeconds !== null : Boolean(playbackUrl)
|
||||
|
||||
const downloadAvailable = vodResolvable
|
||||
|
||||
const media = youtubeVideoId ? (
|
||||
<iframe
|
||||
src={youtubeSrc}
|
||||
title="Vídeo da sessão no YouTube"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; compute-pressure"
|
||||
allowFullScreen
|
||||
className="aspect-video w-full rounded-lg border bg-black"
|
||||
/>
|
||||
) : playbackUrl ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={playbackUrl}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
className="aspect-video w-full rounded-lg border bg-black"
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
) : resolution.kind === 'resolving' ? (
|
||||
<StatusPanel
|
||||
busy
|
||||
title="Resolvendo o trecho na Câmara…"
|
||||
detail="Isso pode levar alguns segundos. Se demorar, tente novamente em instantes — a página não trava."
|
||||
/>
|
||||
) : resolution.kind === 'generating' ? (
|
||||
<StatusPanel
|
||||
title="A Câmara está gerando o trecho deste vídeo."
|
||||
detail="A geração pode levar alguns instantes. Tente novamente em um momento."
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 min-h-10"
|
||||
onClick={() => void requestResolution(false)}
|
||||
>
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</StatusPanel>
|
||||
) : resolution.kind === 'failed' ||
|
||||
(resolution.kind === 'resolved' && !resolution.playbackUrl) ? (
|
||||
<StatusPanel
|
||||
title="Não foi possível carregar o vídeo deste trecho."
|
||||
detail={
|
||||
resolution.kind === 'failed' && resolution.message
|
||||
? resolution.message
|
||||
: 'A Câmara não entregou o arquivo agora. A transcrição e a fonte oficial continuam disponíveis.'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-1 min-h-10"
|
||||
onClick={() => void requestResolution(false)}
|
||||
>
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</StatusPanel>
|
||||
) : vodResolvable ? (
|
||||
<StatusPanel
|
||||
title="O trecho deste vídeo é gerado pela Câmara dos Deputados."
|
||||
detail="Clique para resolver o arquivo exato desta fala — ele é verificado antes de tocar."
|
||||
>
|
||||
<Button className="mt-1 min-h-10" onClick={() => void requestResolution(false)}>
|
||||
<PlayIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Assistir o trecho
|
||||
</Button>
|
||||
</StatusPanel>
|
||||
) : (
|
||||
<StatusPanel
|
||||
title="Vídeo indisponível neste momento."
|
||||
detail="Esta fala não tem vídeo no YouTube nem trecho gerado pela Câmara disponível agora."
|
||||
/>
|
||||
)
|
||||
|
||||
const inlineNotice = youtubeVideoId ? (
|
||||
resolution.kind === 'generating' ? (
|
||||
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
|
||||
A Câmara está gerando o trecho deste vídeo. Tente novamente em um momento.
|
||||
</p>
|
||||
) : resolution.kind === 'failed' ? (
|
||||
<p className="text-xs text-destructive" role="status" aria-live="polite">
|
||||
{resolution.message ??
|
||||
'Não foi possível resolver o arquivo deste trecho na Câmara. Tente novamente.'}
|
||||
</p>
|
||||
) : resolution.kind === 'resolved' && !resolution.downloadUrl ? (
|
||||
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
|
||||
O arquivo deste trecho não pôde ser verificado na Câmara agora.
|
||||
</p>
|
||||
) : null
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div data-slot="speech-player">
|
||||
{vodPlaybackUrl ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={vodPlaybackUrl}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
className="aspect-video w-full rounded-lg border bg-black"
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
) : (
|
||||
<div className="flex aspect-video w-full flex-col items-center justify-center gap-2 rounded-lg border bg-muted/40 text-muted-foreground">
|
||||
<FilmIcon className="size-8" aria-hidden="true" />
|
||||
<p className="text-sm">VOD indisponível para esta fala.</p>
|
||||
</div>
|
||||
)}
|
||||
<div data-slot="speech-player" aria-busy={resolving || undefined}>
|
||||
{media}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{downloadAvailable ? (
|
||||
<Button
|
||||
className="min-h-10"
|
||||
disabled={resolving}
|
||||
aria-busy={resolving || undefined}
|
||||
onClick={() => {
|
||||
if (resolution.kind === 'resolved' && resolution.downloadUrl) {
|
||||
window.open(resolution.downloadUrl, '_blank')
|
||||
return
|
||||
}
|
||||
void requestResolution(true)
|
||||
}}
|
||||
>
|
||||
{resolving ? (
|
||||
<Spinner data-icon="inline-start" aria-label="Resolvendo o trecho" />
|
||||
) : (
|
||||
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
|
||||
)}
|
||||
Baixar vídeo (MP4)
|
||||
</Button>
|
||||
) : null}
|
||||
{sourceUrl ? (
|
||||
<Button asChild variant="outline" className="min-h-10">
|
||||
<a href={sourceUrl} target="_blank" rel="noreferrer">
|
||||
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Abrir fonte
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{inlineNotice}
|
||||
|
||||
{segments.length > 0 ? (
|
||||
<section className="mt-5" aria-label="Transcrição">
|
||||
<p className="text-xs tracking-wide text-muted-foreground uppercase">
|
||||
Transcrição (clique para posicionar)
|
||||
{seekable ? 'Transcrição (clique para posicionar)' : 'Transcrição'}
|
||||
</p>
|
||||
<ol className="mt-2 space-y-0.5">
|
||||
{segments.map((segment) => {
|
||||
|
|
@ -91,8 +331,11 @@ export const SpeechDetailPlayer = ({
|
|||
type="button"
|
||||
data-start-seconds={segment.startSeconds}
|
||||
onClick={() => seekTo(segment.startSeconds)}
|
||||
disabled={!seekable}
|
||||
className={cn(
|
||||
'flex w-full gap-3 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'flex w-full gap-3 rounded-lg px-2 py-1.5 text-left transition-colors',
|
||||
seekable && 'hover:bg-muted',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { DownloadIcon, ExternalLinkIcon, PlayIcon } from 'lucide-react'
|
||||
import { ExternalLinkIcon, PlayIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
|
|
@ -116,12 +116,6 @@ export const SpeechResultCard = ({ speech }: { speech: SpeechListItemViewModel }
|
|||
{speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'}
|
||||
</Link>
|
||||
</Button>
|
||||
{speech.downloadUrl ? (
|
||||
<ActionLink href={speech.downloadUrl} variant="outline">
|
||||
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Baixar
|
||||
</ActionLink>
|
||||
) : null}
|
||||
{speech.sourceUrl ? (
|
||||
<ActionLink href={speech.sourceUrl} variant="ghost">
|
||||
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
|
||||
|
|
|
|||
28
src/lib/schemas/speechVod.ts
Normal file
28
src/lib/schemas/speechVod.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { positiveRelationshipId } from '@/lib/schemas/primitives'
|
||||
|
||||
/** The detail page asks the server to resolve one speech's excerpt VOD. */
|
||||
export const speechVodRequestSchema = z.object({
|
||||
speechId: positiveRelationshipId,
|
||||
})
|
||||
|
||||
/**
|
||||
* Safe messages of the resolution route — shared constants on purpose:
|
||||
* `mapCampaignFormActionError` matches by exact string, so a literal at one
|
||||
* end only would silently collapse a real refusal into the generic error.
|
||||
*/
|
||||
|
||||
/** The catalog gate (`canReadSpeechCatalog`) refused the actor. */
|
||||
export const SPEECH_VOD_FORBIDDEN_MESSAGE = 'Você não tem acesso ao acervo de falas.'
|
||||
|
||||
/** The speech does not exist (or the actor cannot read it). */
|
||||
export const SPEECH_VOD_NOT_FOUND_MESSAGE = 'Fala não encontrada.'
|
||||
|
||||
/** No stored VOD or missing `eventId`/`audioId`/`excerptTMs` — never call the Câmara. */
|
||||
export const SPEECH_VOD_INELIGIBLE_MESSAGE =
|
||||
'Esta fala não tem trecho de vídeo para resolver na Câmara.'
|
||||
|
||||
/** Transport/parse failure at the Câmara (or anything unmapped). */
|
||||
export const SPEECH_VOD_GENERIC_ERROR_MESSAGE =
|
||||
'Não foi possível resolver o trecho. Verifique seu acesso e tente novamente.'
|
||||
193
src/lib/speechVod.ts
Normal file
193
src/lib/speechVod.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* Pure VOD rules shared by the Câmara speech scripts (C152 pilot/C153 import)
|
||||
* and the acervo player (C162): the `video-sob-demanda` URL/status contract,
|
||||
* the crawl UA, the session-offset math and the YouTube video id.
|
||||
*
|
||||
* No I/O here — the scripts and the server resolver own the fetch/timeout
|
||||
* policy, so the fragile rules stay unit-testable without network.
|
||||
*/
|
||||
|
||||
/** Browser-like UA — the trecho/evento pages answer 400 "Acesso via bot" to curl. */
|
||||
export const CAMARA_USER_AGENT =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
||||
|
||||
/** The async VOD-generation status URL for one excerpt. */
|
||||
export const buildVodUrl = (eventId: number, audioId: number, tMs: number): string =>
|
||||
`https://www.camara.leg.br/evento-legislativo/${eventId}/video-sob-demanda?idAudio=${audioId}&trecho=${tMs}`
|
||||
|
||||
export type CamaraVodVideo = {
|
||||
title: string | null
|
||||
subtitle: string | null
|
||||
duration: string | null
|
||||
clock: string | null
|
||||
downloadUrl: string | null
|
||||
playbackUrl: string | null
|
||||
}
|
||||
|
||||
export type CamaraVodStatus = {
|
||||
state: string
|
||||
video: CamaraVodVideo | null
|
||||
}
|
||||
|
||||
/**
|
||||
* What the acervo player can do after asking the Câmara about one excerpt:
|
||||
* `pronto` carries only URLs that passed the media probe (either can still be
|
||||
* null when its own probe failed), `gerando` means the server is transcoding,
|
||||
* `indisponivel` means there is nothing usable.
|
||||
*/
|
||||
export type SpeechVodResolution =
|
||||
| { state: 'pronto'; playbackUrl: string | null; downloadUrl: string | null }
|
||||
| { state: 'gerando' }
|
||||
| { state: 'indisponivel' }
|
||||
|
||||
/**
|
||||
* Normalizes the `video-sob-demanda` JSON (`{ estado, video }`). `state` is
|
||||
* `GERANDO` while the server transcodes, `PRONTO` when the MP4 exists,
|
||||
* `INDISPONIVEL` when there is none.
|
||||
*/
|
||||
export const parseVodStatus = (json: unknown): CamaraVodStatus => {
|
||||
const data = json && typeof json === 'object' ? (json as Record<string, unknown>) : {}
|
||||
const estado = typeof data.estado === 'string' ? data.estado : 'DESCONHECIDO'
|
||||
const raw =
|
||||
data.video && typeof data.video === 'object' ? (data.video as Record<string, unknown>) : null
|
||||
const pick = (key: string): string | null =>
|
||||
typeof raw?.[key] === 'string' ? (raw[key] as string) : null
|
||||
return {
|
||||
state: estado,
|
||||
video: raw
|
||||
? {
|
||||
title: pick('titulo'),
|
||||
subtitle: pick('subtitulo'),
|
||||
duration: pick('duracao'),
|
||||
clock: pick('horario'),
|
||||
downloadUrl: pick('linkParaDownload'),
|
||||
playbackUrl: pick('linkParaReproducao'),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Câmara duration spellings into seconds: `0h04'03"` (excerpt card)
|
||||
* and `0:04:07` / `4:07` (VOD status). Null when unparseable.
|
||||
*/
|
||||
export const parseDurationToSeconds = (value: unknown): number | null => {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (raw === '') return null
|
||||
|
||||
const card = /^(\d+)h(\d{1,2})'(\d{1,2})"?$/.exec(raw)
|
||||
if (card) {
|
||||
const minutes = Number(card[2])
|
||||
const seconds = Number(card[3])
|
||||
if (minutes > 59 || seconds > 59) return null
|
||||
return Number(card[1]) * 3600 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
const parts = raw.split(':')
|
||||
if (!parts.every((part) => /^\d{1,2}$/.test(part))) return null
|
||||
if (parts.length === 3) {
|
||||
const [hours, minutes, seconds] = parts.map(Number)
|
||||
if (minutes > 59 || seconds > 59) return null
|
||||
return hours * 3600 + minutes * 60 + seconds
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
const [minutes, seconds] = parts.map(Number)
|
||||
if (seconds > 59) return null
|
||||
return minutes * 60 + seconds
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const YOUTUBE_VIDEO_ID = /^[A-Za-z0-9_-]{6,20}$/
|
||||
const YOUTUBE_HOSTS = new Set(['youtube.com', 'youtube-nocookie.com', 'youtu.be'])
|
||||
const YOUTUBE_ID_PATH_PREFIXES = new Set(['embed', 'live', 'shorts', 'v'])
|
||||
|
||||
/**
|
||||
* YouTube video id of an `urlRegistro` link, or null when the URL is not a
|
||||
* recognized YouTube form (`watch?v=`, `youtu.be/<id>`, `embed`/`live`/
|
||||
* `shorts`/`v`) or the id does not look like one. Defensive on purpose: the
|
||||
* open-data field varies, and a null simply falls back to the VOD path.
|
||||
*/
|
||||
export const parseYoutubeVideoId = (raw: unknown): string | null => {
|
||||
const value = String(raw ?? '').trim()
|
||||
if (value === '') return null
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
|
||||
|
||||
const host = url.hostname.toLowerCase().replace(/^(www|m|music)\./, '')
|
||||
if (!YOUTUBE_HOSTS.has(host)) return null
|
||||
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
let candidate: string | null = null
|
||||
if (host === 'youtu.be') {
|
||||
candidate = segments[0] ?? null
|
||||
} else if (segments[0] === 'watch') {
|
||||
candidate = url.searchParams.get('v')
|
||||
} else if (segments[0] && YOUTUBE_ID_PATH_PREFIXES.has(segments[0])) {
|
||||
candidate = segments[1] ?? null
|
||||
}
|
||||
|
||||
return candidate && YOUTUBE_VIDEO_ID.test(candidate) ? candidate : null
|
||||
}
|
||||
|
||||
const SAO_PAULO_TIME_ZONE = 'America/Sao_Paulo'
|
||||
const NAIVE_DATETIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?/
|
||||
const brtWallClock = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: SAO_PAULO_TIME_ZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
/** BRT wall-clock of an epoch instant, as seconds since epoch-treated-as-UTC. */
|
||||
const wallClockSeconds = (epochMs: number): number | null => {
|
||||
const parts = brtWallClock.formatToParts(new Date(epochMs))
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): number =>
|
||||
Number(parts.find((part) => part.type === type)?.value)
|
||||
const [year, month, day, hour, minute, second] = [
|
||||
get('year'),
|
||||
get('month'),
|
||||
get('day'),
|
||||
get('hour'),
|
||||
get('minute'),
|
||||
get('second'),
|
||||
]
|
||||
if (![year, month, day, hour, minute, second].every(Number.isFinite)) return null
|
||||
return Date.UTC(year, month - 1, day, hour % 24, minute, second) / 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds from the session start (`eventStartAt`, naive Câmara wall clock) to
|
||||
* the excerpt (`excerptTMs`, epoch ms). Both sides are read as BRT wall clock,
|
||||
* so a session crossing midnight still measures the right distance. Null when
|
||||
* either side is unparseable or the excerpt precedes the session start.
|
||||
*/
|
||||
export const excerptOffsetSeconds = (excerptTMs: unknown, eventStartAt: unknown): number | null => {
|
||||
const epochMs = Number(excerptTMs)
|
||||
if (!Number.isFinite(epochMs) || epochMs <= 0) return null
|
||||
|
||||
const match = NAIVE_DATETIME.exec(String(eventStartAt ?? '').trim())
|
||||
if (!match) return null
|
||||
const [, year, month, day, hour, minute, second = '0'] = match
|
||||
const [y, mo, d, h, mi, s] = [year, month, day, hour, minute, second].map(Number)
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) {
|
||||
return null
|
||||
}
|
||||
const startSeconds = Date.UTC(y, mo - 1, d, h, mi, s) / 1000
|
||||
|
||||
const excerptSeconds = wallClockSeconds(epochMs)
|
||||
if (excerptSeconds === null) return null
|
||||
|
||||
const offset = Math.floor(excerptSeconds - startSeconds)
|
||||
return offset >= 0 ? offset : null
|
||||
}
|
||||
|
|
@ -37,7 +37,6 @@ const speechListSelect = {
|
|||
scopes: true,
|
||||
mentionedMunicipalities: true,
|
||||
presidingOfficer: true,
|
||||
vodDownloadUrl: true,
|
||||
officialTextUrl: true,
|
||||
youtubeUrl: true,
|
||||
} as const
|
||||
|
|
@ -46,6 +45,11 @@ const speechDetailSelect = {
|
|||
...speechListSelect,
|
||||
phase: true,
|
||||
vodPlaybackUrl: true,
|
||||
vodDownloadUrl: true,
|
||||
eventId: true,
|
||||
audioId: true,
|
||||
excerptTMs: true,
|
||||
eventStartAt: true,
|
||||
} as const
|
||||
|
||||
const segmentSelect = {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
type SpeechHighlightPart,
|
||||
} from '@/lib/speechHighlight'
|
||||
import { normalizeForSearch } from '@/lib/speechSearch'
|
||||
import { excerptOffsetSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
|
||||
import type { Municipality } from '@/payload-types'
|
||||
import { speechScopeLabels, speechTopicLabels } from '@/utilities/speech/speechListUrl'
|
||||
|
||||
|
|
@ -33,7 +34,6 @@ export type SpeechListRecord = {
|
|||
scopes?: SpeechScope[] | null
|
||||
mentionedMunicipalities?: (number | Municipality)[] | null
|
||||
presidingOfficer?: string | null
|
||||
vodDownloadUrl?: string | null
|
||||
officialTextUrl?: string | null
|
||||
youtubeUrl?: string | null
|
||||
}
|
||||
|
|
@ -53,7 +53,6 @@ export type SpeechListItemViewModel = {
|
|||
keywords: string[]
|
||||
municipalities: { id: number; name: string }[]
|
||||
watchHref: string
|
||||
downloadUrl: string | null
|
||||
sourceUrl: string | null
|
||||
}
|
||||
|
||||
|
|
@ -80,8 +79,12 @@ export type SpeechDetailViewModel = {
|
|||
scopes: { value: SpeechScope; label: string }[]
|
||||
municipalities: { id: number; name: string }[]
|
||||
segments: SpeechDetailSegmentViewModel[]
|
||||
vodPlaybackUrl: string | null
|
||||
vodDownloadUrl: string | null
|
||||
/** YouTube default source (C162): id when the session link parses, else null. */
|
||||
youtubeVideoId: string | null
|
||||
/** Session offset in seconds (excerpt epoch − session start), null when unknowable. */
|
||||
youtubeOffsetSeconds: number | null
|
||||
/** Stored VOD + excerpt coordinates — the Câmara may be asked on click. */
|
||||
vodResolvable: boolean
|
||||
}
|
||||
|
||||
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||
|
|
@ -215,7 +218,6 @@ export const toSpeechListItemViewModel = ({
|
|||
keywords: speech.keywords ?? [],
|
||||
municipalities: municipalityViewModels(speech, municipalityLabels),
|
||||
watchHref: buildWatchHref(speech.id, matchedSegment, q),
|
||||
downloadUrl: speech.vodDownloadUrl ?? null,
|
||||
sourceUrl: speech.officialTextUrl ?? speech.youtubeUrl ?? null,
|
||||
}
|
||||
}
|
||||
|
|
@ -223,6 +225,11 @@ export const toSpeechListItemViewModel = ({
|
|||
export type SpeechDetailRecord = SpeechListRecord & {
|
||||
phase?: string | null
|
||||
vodPlaybackUrl?: string | null
|
||||
vodDownloadUrl?: string | null
|
||||
eventId?: number | null
|
||||
audioId?: number | null
|
||||
excerptTMs?: number | null
|
||||
eventStartAt?: string | null
|
||||
}
|
||||
|
||||
export const toSpeechDetailViewModel = ({
|
||||
|
|
@ -235,27 +242,39 @@ export const toSpeechDetailViewModel = ({
|
|||
segments: readonly SpeechSegmentRecord[]
|
||||
query?: string
|
||||
municipalityLabels: ReadonlyMap<number, string>
|
||||
}): SpeechDetailViewModel => ({
|
||||
id: speech.id,
|
||||
speechAtLabel: formatSpeechAt(speech.speechAt),
|
||||
type: speech.type ?? null,
|
||||
phase: speech.phase ?? null,
|
||||
durationLabel: formatSpeechDuration(speech.durationSeconds),
|
||||
presidingOfficer: speech.presidingOfficer ?? null,
|
||||
summary: speech.summary ?? null,
|
||||
officialTranscript: speech.officialTranscript ?? null,
|
||||
officialTextUrl: speech.officialTextUrl ?? null,
|
||||
youtubeUrl: speech.youtubeUrl ?? null,
|
||||
keywords: speech.keywords ?? [],
|
||||
topics: topicViewModels(speech),
|
||||
scopes: scopeViewModels(speech),
|
||||
municipalities: municipalityViewModels(speech, municipalityLabels),
|
||||
segments: segments.map((segment) => ({
|
||||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
startLabel: formatSpeechClock(segment.startSeconds),
|
||||
parts: splitHighlightedParts(segment.text, query ?? ''),
|
||||
})),
|
||||
vodPlaybackUrl: speech.vodPlaybackUrl ?? null,
|
||||
vodDownloadUrl: speech.vodDownloadUrl ?? null,
|
||||
})
|
||||
}): SpeechDetailViewModel => {
|
||||
// The stored VOD link is a cache and never a URL handed to the client; it
|
||||
// only signals that this record had a generated excerpt to re-resolve.
|
||||
const vodResolvable = Boolean(
|
||||
(speech.vodPlaybackUrl || speech.vodDownloadUrl) &&
|
||||
speech.eventId &&
|
||||
speech.audioId &&
|
||||
speech.excerptTMs,
|
||||
)
|
||||
|
||||
return {
|
||||
id: speech.id,
|
||||
speechAtLabel: formatSpeechAt(speech.speechAt),
|
||||
type: speech.type ?? null,
|
||||
phase: speech.phase ?? null,
|
||||
durationLabel: formatSpeechDuration(speech.durationSeconds),
|
||||
presidingOfficer: speech.presidingOfficer ?? null,
|
||||
summary: speech.summary ?? null,
|
||||
officialTranscript: speech.officialTranscript ?? null,
|
||||
officialTextUrl: speech.officialTextUrl ?? null,
|
||||
youtubeUrl: speech.youtubeUrl ?? null,
|
||||
keywords: speech.keywords ?? [],
|
||||
topics: topicViewModels(speech),
|
||||
scopes: scopeViewModels(speech),
|
||||
municipalities: municipalityViewModels(speech, municipalityLabels),
|
||||
segments: segments.map((segment) => ({
|
||||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
startLabel: formatSpeechClock(segment.startSeconds),
|
||||
parts: splitHighlightedParts(segment.text, query ?? ''),
|
||||
})),
|
||||
youtubeVideoId: parseYoutubeVideoId(speech.youtubeUrl),
|
||||
youtubeOffsetSeconds: excerptOffsetSeconds(speech.excerptTMs, speech.eventStartAt),
|
||||
vodResolvable,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
102
src/utilities/speech/speechVodResolver.ts
Normal file
102
src/utilities/speech/speechVodResolver.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import 'server-only'
|
||||
|
||||
import {
|
||||
buildVodUrl,
|
||||
CAMARA_USER_AGENT,
|
||||
parseVodStatus,
|
||||
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.
|
||||
*/
|
||||
|
||||
const VOD_STATUS_ATTEMPTS = 2
|
||||
const VOD_STATUS_TIMEOUT_MS = 15_000
|
||||
const VOD_STATUS_RETRY_DELAY_MS = 1_000
|
||||
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> => {
|
||||
let lastError: unknown
|
||||
for (let attempt = 1; attempt <= VOD_STATUS_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),
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status} em ${url}`)
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt < VOD_STATUS_ATTEMPTS) await sleep(VOD_STATUS_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
type MediaProbe = { verified: boolean; retryWithHead: boolean }
|
||||
|
||||
/**
|
||||
* A media link counts as verified when the ranged GET answers 2xx with a real
|
||||
* content type — a `text/html` body is the CDN's error page wearing a 200.
|
||||
*/
|
||||
const probeMediaOnce = async (url: string, method: 'GET' | 'HEAD'): Promise<MediaProbe> => {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'User-Agent': CAMARA_USER_AGENT,
|
||||
...(method === 'GET' ? { Range: 'bytes=0-1023' } : {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(MEDIA_PROBE_TIMEOUT_MS),
|
||||
})
|
||||
const contentType = response.headers.get('content-type')
|
||||
const verified = response.ok && contentType !== null && !contentType.includes('text/html')
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
return { verified, retryWithHead: response.status === 405 || response.status === 501 }
|
||||
}
|
||||
|
||||
/** Never throws: a transport failure is just an unverified link. */
|
||||
const probeSpeechMedia = async (url: string | null): Promise<boolean> => {
|
||||
if (!url) return false
|
||||
try {
|
||||
const first = await probeMediaOnce(url, 'GET')
|
||||
if (first.verified) return true
|
||||
if (first.retryWithHead) return (await probeMediaOnce(url, 'HEAD')).verified
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveSpeechVod = async ({
|
||||
eventId,
|
||||
audioId,
|
||||
excerptTms,
|
||||
}: {
|
||||
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' }
|
||||
|
||||
const [playback, download] = await Promise.all([
|
||||
probeSpeechMedia(status.video.playbackUrl),
|
||||
probeSpeechMedia(status.video.downloadUrl),
|
||||
])
|
||||
|
||||
return {
|
||||
state: 'pronto',
|
||||
playbackUrl: playback ? status.video.playbackUrl : null,
|
||||
downloadUrl: download ? status.video.downloadUrl : null,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,27 @@
|
|||
import { request as playwrightRequest } from '@playwright/test'
|
||||
|
||||
import type { SpeechScope, SpeechTopic } from '@/lib/speechFacets'
|
||||
|
||||
import type { CampaignE2EOwnership } from './fixtures/campaignE2EFixtures.js'
|
||||
import { assertCampaignRedirect, expect, rendered, test } from './fixtures/campaignHttpTest.js'
|
||||
|
||||
/**
|
||||
* C154 — the communication vertical over real HTTP (browserless, OPS35/OPS87):
|
||||
* role gates, the acervo search rendering (highlight + excerpt), the filter
|
||||
* URL contract and the detail's player/transcript/actions. Playback itself is
|
||||
* browser territory and is not asserted here — the HTML carries the contract
|
||||
* (`<video src>`, `data-start-seconds`).
|
||||
* C154/C162 — the communication vertical over real HTTP (browserless,
|
||||
* OPS35/OPS87): role gates, the acervo search rendering (highlight + excerpt),
|
||||
* the filter URL contract and the detail's player/transcript/actions. The
|
||||
* player picks the surface from the row coordinates — YouTube default when the
|
||||
* session link parses, stored-VOD capa resolved on click otherwise, honest
|
||||
* unavailable state when neither exists — and the stored VOD link itself is
|
||||
* never rendered (C162). Playback is browser territory and is not asserted
|
||||
* here; the HTML carries the contract (`<video src>` only after a click,
|
||||
* `data-start-seconds`, the iframe `start`).
|
||||
*/
|
||||
|
||||
const YOUTUBE_VIDEO_ID = 'lLhRDkSPw0A'
|
||||
const EXCERPT_TMS = 1786473834650
|
||||
const EXCERPT_SESSION = '2026-08-11T15:00'
|
||||
const EXCERPT_OFFSET_SECONDS = 2634
|
||||
|
||||
const createSpeech = async (
|
||||
campaign: { fixtures: CampaignE2EOwnership },
|
||||
input: {
|
||||
|
|
@ -21,6 +32,8 @@ const createSpeech = async (
|
|||
keywords?: string[]
|
||||
durationSeconds?: number
|
||||
withVideo?: boolean
|
||||
youtubeUrl?: string | null
|
||||
eventStartAt?: string
|
||||
},
|
||||
) => {
|
||||
const marker = input.marker
|
||||
|
|
@ -42,6 +55,11 @@ const createSpeech = async (
|
|||
searchText: `a retomada da saude ${marker} na bahia`,
|
||||
presidingOfficer: 'Pompeo de Mattos',
|
||||
officialTextUrl: 'https://camara.leg.br/discurso',
|
||||
eventId: 67091,
|
||||
audioId: 558641,
|
||||
excerptTMs: EXCERPT_TMS,
|
||||
eventStartAt: input.eventStartAt ?? EXCERPT_SESSION,
|
||||
youtubeUrl: input.youtubeUrl ?? null,
|
||||
...(input.withVideo === false
|
||||
? {}
|
||||
: {
|
||||
|
|
@ -66,7 +84,7 @@ const createSpeech = async (
|
|||
return speech
|
||||
}
|
||||
|
||||
test.describe('communication vertical (C154)', () => {
|
||||
test.describe('communication vertical (C154/C162)', () => {
|
||||
test('communicator lands on the acervo and sees only the vertical nav, with her scoped Sollinha', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
|
|
@ -111,12 +129,15 @@ test.describe('communication vertical (C154)', () => {
|
|||
expect(rendered(await advisorHome.text())).not.toContain('href="/campanha/comunicacao"')
|
||||
})
|
||||
|
||||
test('search renders the highlighted excerpt and the detail carries the player', async ({
|
||||
test('search keeps the download off the card; the both-sources detail defaults to YouTube', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('falateste')
|
||||
const speech = await createSpeech(campaign, { marker })
|
||||
const speech = await createSpeech(campaign, {
|
||||
marker,
|
||||
youtubeUrl: `https://www.youtube.com/watch?v=${YOUTUBE_VIDEO_ID}`,
|
||||
})
|
||||
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
|
|
@ -128,16 +149,46 @@ test.describe('communication vertical (C154)', () => {
|
|||
expect(resultsHtml).toContain('<mark')
|
||||
expect(resultsHtml).toContain('Assistir no trecho')
|
||||
expect(resultsHtml).toContain('1 fala encontrada')
|
||||
// C162 — the direct download left the card, and the stored ephemeral link
|
||||
// never reaches any list HTML.
|
||||
expect(resultsHtml).not.toContain('Baixar')
|
||||
expect(resultsHtml).not.toContain('vod.camara.leg.br')
|
||||
|
||||
const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}?t=0&q=${marker}`)
|
||||
const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}?t=43&q=${marker}`)
|
||||
expect(detail.status()).toBe(200)
|
||||
const detailHtml = rendered(await detail.text())
|
||||
expect(detailHtml).toContain('data-slot="speech-player"')
|
||||
expect(detailHtml).toContain('<video')
|
||||
// Default surface is the YouTube embed, positioned by the session offset
|
||||
// (2634) plus the deep-linked transcript second (43).
|
||||
expect(detailHtml).toContain(`youtube-nocookie.com/embed/${YOUTUBE_VIDEO_ID}`)
|
||||
expect(detailHtml).toContain(`start=${EXCERPT_OFFSET_SECONDS + 43}`)
|
||||
expect(detailHtml).toContain('data-start-seconds="0"')
|
||||
expect(detailHtml).toContain('Baixar vídeo (MP4)')
|
||||
expect(detailHtml).toContain('Abrir fonte')
|
||||
expect(detailHtml).toContain('Fonte: Câmara dos Deputados')
|
||||
expect(detailHtml).not.toContain('<video')
|
||||
expect(detailHtml).not.toContain('vod.camara.leg.br')
|
||||
})
|
||||
|
||||
test('a VOD-only speech offers the on-click resolution instead of a player', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('sovod')
|
||||
const speech = await createSpeech(campaign, { marker })
|
||||
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
|
||||
const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}`)
|
||||
expect(detail.status()).toBe(200)
|
||||
const html = rendered(await detail.text())
|
||||
expect(html).toContain('Assistir o trecho')
|
||||
expect(html).toContain('Baixar vídeo (MP4)')
|
||||
expect(html).not.toContain('<video')
|
||||
expect(html).not.toContain('<iframe')
|
||||
// The stored link is an eligibility signal, never a rendered URL.
|
||||
expect(html).not.toContain('vod.camara.leg.br')
|
||||
})
|
||||
|
||||
test('facet filters narrow the results and the empty state suggests clearing', async ({
|
||||
|
|
@ -186,7 +237,7 @@ test.describe('communication vertical (C154)', () => {
|
|||
)
|
||||
})
|
||||
|
||||
test('a speech without VOD renders the unavailable block instead of a player', async ({
|
||||
test('a speech without video renders the unavailable block instead of a player', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
|
|
@ -198,7 +249,66 @@ test.describe('communication vertical (C154)', () => {
|
|||
|
||||
const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}`)
|
||||
const html = rendered(await detail.text())
|
||||
expect(html).toContain('VOD indisponível')
|
||||
expect(html).toContain('Vídeo indisponível neste momento')
|
||||
expect(html).toContain('Abrir fonte')
|
||||
// No stored VOD means nothing to resolve: no MP4 button and no retry.
|
||||
expect(html).not.toContain('Baixar vídeo (MP4)')
|
||||
expect(html).not.toContain('Tentar novamente')
|
||||
})
|
||||
|
||||
test.describe('POST /campanha/comunicacao/acervo/resolver-vod (C162)', () => {
|
||||
const endpoint = '/campanha/comunicacao/acervo/resolver-vod'
|
||||
|
||||
test('rejects a request without a campaign session', async ({ campaign }) => {
|
||||
const anonymous = await playwrightRequest.newContext({ baseURL: campaign.baseURL })
|
||||
try {
|
||||
const response = await anonymous.post(endpoint, { data: { speechId: 1 } })
|
||||
expect(response.status()).toBe(401)
|
||||
} finally {
|
||||
await anonymous.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects a cross-origin request before any read', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('origem')
|
||||
const speech = await createSpeech(campaign, { marker })
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
|
||||
const response = await request.post(endpoint, {
|
||||
data: { speechId: speech.id },
|
||||
headers: { Origin: 'https://evil.example' },
|
||||
})
|
||||
expect(response.status()).toBe(403)
|
||||
expect(await response.text()).toContain('Requisição inválida.')
|
||||
})
|
||||
|
||||
test('refuses an actor outside the catalog gate', async ({ campaign, campaignRequest }) => {
|
||||
const marker = campaign.fixtures.value('assessor')
|
||||
const speech = await createSpeech(campaign, { marker })
|
||||
const advisor = await campaign.fixtures.createCampaignUser('advisor')
|
||||
const request = await campaignRequest(advisor, advisor.password)
|
||||
|
||||
const response = await request.post(endpoint, { data: { speechId: speech.id } })
|
||||
expect(response.status()).toBe(400)
|
||||
expect(await response.text()).toContain('Você não tem acesso ao acervo de falas.')
|
||||
})
|
||||
|
||||
test('refuses a speech without a stored VOD without calling the Câmara', async ({
|
||||
campaign,
|
||||
campaignRequest,
|
||||
}) => {
|
||||
const marker = campaign.fixtures.value('inelegivel')
|
||||
const speech = await createSpeech(campaign, { marker, withVideo: false })
|
||||
const user = await campaign.fixtures.createCampaignUser('communicator')
|
||||
const request = await campaignRequest(user, user.password)
|
||||
|
||||
const response = await request.post(endpoint, { data: { speechId: speech.id } })
|
||||
expect(response.status()).toBe(400)
|
||||
expect(await response.text()).toContain('não tem trecho de vídeo')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -196,6 +196,38 @@ describe('speech acervo (C154)', () => {
|
|||
expect(view.presidingOfficer).toBe('Pompeo de Mattos')
|
||||
})
|
||||
|
||||
it('derives the YouTube default and the resoluble VOD from the detail row (C162)', async () => {
|
||||
const withBoth = await createSpeech({
|
||||
youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A',
|
||||
vodPlaybackUrl: 'https://vod.camara.leg.br/old.mp4',
|
||||
vodDownloadUrl: 'https://vod.camara.leg.br/old.mp4',
|
||||
excerptTMs: 1786473834650,
|
||||
eventStartAt: '2026-08-11T15:00',
|
||||
})
|
||||
const onlyYoutube = await createSpeech({ youtubeUrl: 'https://youtu.be/lLhRDkSPw0A' })
|
||||
|
||||
const { communicator } = await createUsers()
|
||||
const both = await loadSpeechDetailPageData(payload, communicator, withBoth)
|
||||
expect(both.youtubeVideoId).toBe('lLhRDkSPw0A')
|
||||
expect(both.youtubeOffsetSeconds).toBe(2634)
|
||||
expect(both.vodResolvable).toBe(true)
|
||||
|
||||
const youtubeOnly = await loadSpeechDetailPageData(payload, communicator, onlyYoutube)
|
||||
expect(youtubeOnly.youtubeVideoId).toBe('lLhRDkSPw0A')
|
||||
expect(youtubeOnly.vodResolvable).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the direct download link off the list view model (C162)', async () => {
|
||||
const id = await createSpeech({ vodDownloadUrl: 'https://vod.camara.leg.br/old.mp4' })
|
||||
|
||||
const { communicator } = await createUsers()
|
||||
const data = await loadSpeechAcervoPageData(payload, communicator, {})
|
||||
const row = data.rows.find((item) => item.id === id)
|
||||
|
||||
expect(row).toBeDefined()
|
||||
expect('downloadUrl' in (row as object)).toBe(false)
|
||||
})
|
||||
|
||||
it('allows communicator/coordinator/candidate and denies advisor/leader', async () => {
|
||||
const id = await createSpeech()
|
||||
const { communicator, coordinator, candidate, advisor, leader } = await createUsers()
|
||||
|
|
|
|||
246
tests/unit/speechDetailPlayer.unit.spec.tsx
Normal file
246
tests/unit/speechDetailPlayer.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SpeechDetailPlayer } from '@/components/campaign/speech/SpeechDetailPlayer'
|
||||
import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechViewModels'
|
||||
|
||||
const PLAYBACK_URL = 'https://vod.camara.leg.br/trecho.mp4'
|
||||
const DOWNLOAD_URL = 'https://vod.camara.leg.br/trecho-download.mp4'
|
||||
|
||||
const SEGMENTS: readonly SpeechDetailSegmentViewModel[] = [
|
||||
{
|
||||
startSeconds: 0,
|
||||
endSeconds: 5,
|
||||
startLabel: '00:00',
|
||||
parts: [{ text: 'Primeiro trecho', highlighted: false }],
|
||||
},
|
||||
{
|
||||
startSeconds: 43,
|
||||
endSeconds: 50,
|
||||
startLabel: '00:43',
|
||||
parts: [{ text: 'O trecho sobre a saúde', highlighted: false }],
|
||||
},
|
||||
]
|
||||
|
||||
const pronto = (playbackUrl: string | null, downloadUrl: string | null) => ({
|
||||
status: 'success' as const,
|
||||
resolution: { state: 'pronto' as const, playbackUrl, downloadUrl },
|
||||
})
|
||||
const gerando = { status: 'success' as const, resolution: { state: 'gerando' as const } }
|
||||
const indisponivel = { status: 'success' as const, resolution: { state: 'indisponivel' as const } }
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
const respondWith = (payload: unknown, ok = true) => {
|
||||
fetchMock.mockResolvedValue({ ok, json: async () => payload })
|
||||
}
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const renderPlayer = (props: Partial<Parameters<typeof SpeechDetailPlayer>[0]> = {}) =>
|
||||
render(
|
||||
<SpeechDetailPlayer
|
||||
speechId={42}
|
||||
youtubeVideoId={null}
|
||||
youtubeOffsetSeconds={null}
|
||||
vodResolvable
|
||||
segments={SEGMENTS}
|
||||
initialSeconds={null}
|
||||
sourceUrl={null}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
|
||||
const videoElement = () => document.querySelector('video')
|
||||
const iframeElement = () => document.querySelector('iframe')
|
||||
const playerRoot = () => document.querySelector('[data-slot="speech-player"]')
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom does not implement media playback; the seek contract is what matters.
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
|
||||
configurable: true,
|
||||
value: () => Promise.resolve(),
|
||||
})
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'currentTime', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 0,
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('SpeechDetailPlayer — VOD-only quadrant', () => {
|
||||
it('resolves on click, shows the pending state and plays the exact excerpt', async () => {
|
||||
const pending = deferred<{ ok: boolean; json: () => Promise<unknown> }>()
|
||||
fetchMock.mockReturnValue(pending.promise)
|
||||
|
||||
renderPlayer()
|
||||
|
||||
expect(screen.getByText(/O trecho deste vídeo é gerado pela Câmara/)).toBeDefined()
|
||||
expect(videoElement()).toBeNull()
|
||||
expect(iframeElement()).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /assistir o trecho/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(playerRoot()?.getAttribute('aria-busy')).toBe('true')
|
||||
})
|
||||
expect(screen.getByText(/Resolvendo o trecho na Câmara/)).toBeDefined()
|
||||
const busyDownload = screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i })
|
||||
expect((busyDownload as HTMLButtonElement).disabled).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve({ ok: true, json: async () => pronto(PLAYBACK_URL, DOWNLOAD_URL) })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(videoElement()?.getAttribute('src')).toBe(PLAYBACK_URL)
|
||||
})
|
||||
expect(playerRoot()?.getAttribute('aria-busy')).toBeNull()
|
||||
|
||||
fireEvent.click(document.querySelector('button[data-start-seconds="43"]')!)
|
||||
expect(videoElement()?.currentTime).toBe(43)
|
||||
})
|
||||
|
||||
it('shows the honest state after a failure and re-posts on retry', async () => {
|
||||
respondWith(indisponivel)
|
||||
renderPlayer()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /assistir o trecho/i }))
|
||||
expect(await screen.findByText('Não foi possível carregar o vídeo deste trecho.')).toBeDefined()
|
||||
|
||||
respondWith(pronto(PLAYBACK_URL, null))
|
||||
fireEvent.click(screen.getByRole('button', { name: /tentar novamente/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(videoElement()?.getAttribute('src')).toBe(PLAYBACK_URL)
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ speechId: 42 })
|
||||
})
|
||||
|
||||
it('shows the generating state with a retry and no player', async () => {
|
||||
respondWith(gerando)
|
||||
renderPlayer()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /assistir o trecho/i }))
|
||||
|
||||
expect(await screen.findByText(/A Câmara está gerando o trecho deste vídeo/)).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: /tentar novamente/i })).toBeDefined()
|
||||
expect(videoElement()).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the verified download offered when only the playback probe failed', async () => {
|
||||
respondWith(pronto(null, DOWNLOAD_URL))
|
||||
renderPlayer()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /assistir o trecho/i }))
|
||||
|
||||
expect(await screen.findByText('Não foi possível carregar o vídeo deste trecho.')).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i })).toBeDefined()
|
||||
expect(videoElement()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SpeechDetailPlayer — YouTube quadrant', () => {
|
||||
it('embeds the session at the offset and seeks the transcript by reloading start', async () => {
|
||||
renderPlayer({
|
||||
youtubeVideoId: 'lLhRDkSPw0A',
|
||||
youtubeOffsetSeconds: 2634,
|
||||
initialSeconds: 100,
|
||||
})
|
||||
|
||||
expect(videoElement()).toBeNull()
|
||||
expect(iframeElement()?.getAttribute('src')).toBe(
|
||||
'https://www.youtube-nocookie.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2734',
|
||||
)
|
||||
|
||||
fireEvent.click(document.querySelector('button[data-start-seconds="43"]')!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(iframeElement()?.getAttribute('src')).toContain('start=2677')
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the transcript inert when the session offset is unknown', () => {
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: null })
|
||||
|
||||
expect(screen.queryByText(/clique para posicionar/i)).toBeNull()
|
||||
const segment = document.querySelector('button[data-start-seconds="43"]') as HTMLButtonElement
|
||||
expect(segment.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves the MP4 on download and opens the verified URL in a pre-opened tab', async () => {
|
||||
const pending = deferred<{ ok: boolean; json: () => Promise<unknown> }>()
|
||||
fetchMock.mockReturnValue(pending.promise)
|
||||
const tab = { location: { href: '' }, close: vi.fn() }
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
|
||||
|
||||
renderPlayer({
|
||||
youtubeVideoId: 'lLhRDkSPw0A',
|
||||
youtubeOffsetSeconds: 2634,
|
||||
initialSeconds: null,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i }))
|
||||
expect(openSpy).toHaveBeenCalledWith('', '_blank')
|
||||
expect(
|
||||
(screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i }) as HTMLButtonElement).disabled,
|
||||
).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve({ ok: true, json: async () => pronto(PLAYBACK_URL, DOWNLOAD_URL) })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(tab.location.href).toBe(DOWNLOAD_URL)
|
||||
})
|
||||
expect(tab.close).not.toHaveBeenCalled()
|
||||
expect(iframeElement()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('closes the tab and shows the honest notice when the resolution fails', async () => {
|
||||
respondWith({ status: 'error', message: 'Não foi possível resolver o trecho.' }, false)
|
||||
const tab = { location: { href: '' }, close: vi.fn() }
|
||||
vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window)
|
||||
|
||||
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /baixar vídeo \(mp4\)/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(tab.close).toHaveBeenCalled()
|
||||
})
|
||||
expect(await screen.findByText('Não foi possível resolver o trecho.')).toBeDefined()
|
||||
expect(iframeElement()).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SpeechDetailPlayer — no-video quadrant', () => {
|
||||
it('renders the unavailable block without retry or download', () => {
|
||||
renderPlayer({ vodResolvable: false, sourceUrl: 'https://imagem.camara.leg.br/diario.pdf' })
|
||||
|
||||
expect(screen.getByText('Vídeo indisponível neste momento.')).toBeDefined()
|
||||
expect(screen.queryByRole('button', { name: /tentar novamente/i })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /baixar vídeo/i })).toBeNull()
|
||||
expect(screen.getByRole('link', { name: /abrir fonte/i })).toBeDefined()
|
||||
expect(videoElement()).toBeNull()
|
||||
expect(iframeElement()).toBeNull()
|
||||
})
|
||||
})
|
||||
84
tests/unit/speechVod.unit.spec.ts
Normal file
84
tests/unit/speechVod.unit.spec.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { excerptOffsetSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
|
||||
|
||||
/**
|
||||
* The VOD URL/status/duration contracts moved from `scripts/lib/camaraSpeeches.mjs`
|
||||
* to `@/lib/speechVod` (C162) stay pinned by `camaraSpeeches.unit.spec.ts`, which
|
||||
* imports them through the script's re-export — this file owns the new pure rules.
|
||||
*/
|
||||
|
||||
describe('excerptOffsetSeconds', () => {
|
||||
it('measures the offset between the excerpt and the session start (intent literal)', () => {
|
||||
expect(excerptOffsetSeconds(1786473834650, '2026-08-11T15:00')).toBe(2634)
|
||||
})
|
||||
|
||||
it('matches the C153 import fixture (import literal)', () => {
|
||||
expect(excerptOffsetSeconds(1675801808560, '2023-02-07T15:00')).toBe(9008)
|
||||
})
|
||||
|
||||
it('reads both sides as BRT wall clock across an old DST transition', () => {
|
||||
// 2018-11-04 00:00 BRT: clocks move to -02:00; 14:00Z reads as 12:00 BRT.
|
||||
expect(excerptOffsetSeconds(1541340000000, '2018-11-04T11:00')).toBe(3600)
|
||||
// The day before the transition still reads at -03:00: 14:00Z = 11:00 BRT.
|
||||
expect(excerptOffsetSeconds(1541253600000, '2018-11-03T10:00')).toBe(3600)
|
||||
})
|
||||
|
||||
it('crosses midnight without losing the date', () => {
|
||||
// 2024-05-15 02:30Z = 2024-05-14 23:30 BRT.
|
||||
expect(excerptOffsetSeconds(1715740200000, '2024-05-14T23:00')).toBe(1800)
|
||||
})
|
||||
|
||||
it('accepts the space spelling of the naive datetime', () => {
|
||||
expect(excerptOffsetSeconds(1786473834650, '2026-08-11 15:00')).toBe(2634)
|
||||
})
|
||||
|
||||
it('returns null when the excerpt precedes the session start', () => {
|
||||
expect(excerptOffsetSeconds(1786473834650, '2026-08-11T16:00')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null on unparseable or out-of-range input', () => {
|
||||
expect(excerptOffsetSeconds(null, '2026-08-11T15:00')).toBeNull()
|
||||
expect(excerptOffsetSeconds(0, '2026-08-11T15:00')).toBeNull()
|
||||
expect(excerptOffsetSeconds(1786473834650, null)).toBeNull()
|
||||
expect(excerptOffsetSeconds(1786473834650, 'sem data')).toBeNull()
|
||||
expect(excerptOffsetSeconds(1786473834650, '2026-13-11T15:00')).toBeNull()
|
||||
expect(excerptOffsetSeconds(1786473834650, '2026-08-11T25:00')).toBeNull()
|
||||
expect(excerptOffsetSeconds(1786473834650, '2026-08-11T15:75')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseYoutubeVideoId', () => {
|
||||
const videoId = 'lLhRDkSPw0A'
|
||||
|
||||
it('parses the open-data watch link', () => {
|
||||
expect(parseYoutubeVideoId(`https://www.youtube.com/watch?v=${videoId}`)).toBe(videoId)
|
||||
})
|
||||
|
||||
it('parses the shortened, embed, live, shorts and mobile forms', () => {
|
||||
expect(parseYoutubeVideoId(`https://youtu.be/${videoId}?t=30`)).toBe(videoId)
|
||||
expect(parseYoutubeVideoId(`https://www.youtube.com/embed/${videoId}`)).toBe(videoId)
|
||||
expect(parseYoutubeVideoId(`https://www.youtube-nocookie.com/embed/${videoId}`)).toBe(videoId)
|
||||
expect(parseYoutubeVideoId(`https://www.youtube.com/live/${videoId}?feature=share`)).toBe(
|
||||
videoId,
|
||||
)
|
||||
expect(parseYoutubeVideoId(`https://www.youtube.com/shorts/${videoId}`)).toBe(videoId)
|
||||
expect(parseYoutubeVideoId(`https://m.youtube.com/watch?v=${videoId}`)).toBe(videoId)
|
||||
})
|
||||
|
||||
it('rejects non-YouTube hosts, unparseable values and non-video links', () => {
|
||||
expect(parseYoutubeVideoId(`https://example.com/watch?v=${videoId}`)).toBeNull()
|
||||
expect(parseYoutubeVideoId(`youtube.com/watch?v=${videoId}`)).toBeNull()
|
||||
expect(parseYoutubeVideoId('https://www.youtube.com/playlist?list=PL123')).toBeNull()
|
||||
expect(parseYoutubeVideoId('https://www.youtube.com/')).toBeNull()
|
||||
expect(parseYoutubeVideoId('')).toBeNull()
|
||||
expect(parseYoutubeVideoId(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects an id that does not look like a YouTube id', () => {
|
||||
expect(parseYoutubeVideoId('https://www.youtube.com/watch?v=ab')).toBeNull()
|
||||
expect(parseYoutubeVideoId('https://www.youtube.com/watch?v=../etc/passwd')).toBeNull()
|
||||
})
|
||||
})
|
||||
146
tests/unit/speechVodResolver.unit.spec.ts
Normal file
146
tests/unit/speechVodResolver.unit.spec.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { resolveSpeechVod } 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 jsonResponse = (body: unknown, status = 200): Response =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
|
||||
const mediaResponse = (status: number, contentType: string | null): Response =>
|
||||
new Response(status >= 200 && status < 300 ? 'video-bytes' : '', {
|
||||
status,
|
||||
...(contentType ? { headers: { 'content-type': contentType } } : {}),
|
||||
})
|
||||
|
||||
type FetchCall = { url: string; method: string; range: string | null; userAgent: string | null }
|
||||
|
||||
const stubFetch = (
|
||||
handler: (url: string, init?: RequestInit) => Promise<Response>,
|
||||
): FetchCall[] => {
|
||||
const calls: FetchCall[] = []
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>
|
||||
calls.push({
|
||||
url: String(input),
|
||||
method: (init?.method ?? 'GET').toUpperCase(),
|
||||
range: headers.Range ?? null,
|
||||
userAgent: headers['User-Agent'] ?? null,
|
||||
})
|
||||
return handler(String(input), init)
|
||||
}),
|
||||
)
|
||||
return calls
|
||||
}
|
||||
|
||||
const readyBody = {
|
||||
estado: 'PRONTO',
|
||||
video: {
|
||||
titulo: 'Trecho',
|
||||
linkParaReproducao: VOD_PLAYBACK,
|
||||
linkParaDownload: VOD_DOWNLOAD,
|
||||
},
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('resolveSpeechVod', () => {
|
||||
it('returns both URLs only after the ranged probes verify them', async () => {
|
||||
const calls = stubFetch(async (url) =>
|
||||
url.includes('video-sob-demanda') ? jsonResponse(readyBody) : mediaResponse(206, 'video/mp4'),
|
||||
)
|
||||
|
||||
await expect(
|
||||
resolveSpeechVod({ eventId: 67091, audioId: 558641, excerptTms: 3 }),
|
||||
).resolves.toEqual({
|
||||
state: 'pronto',
|
||||
playbackUrl: VOD_PLAYBACK,
|
||||
downloadUrl: VOD_DOWNLOAD,
|
||||
})
|
||||
|
||||
const probes = calls.filter((call) => call.method === 'GET' && call.range === 'bytes=0-1023')
|
||||
expect(probes).toHaveLength(2)
|
||||
expect(probes.every((probe) => probe.userAgent?.includes('Mozilla'))).toBe(true)
|
||||
expect(calls[0]?.url).toContain('trecho=3')
|
||||
})
|
||||
|
||||
it('drops a playback link whose probe fails but keeps the verified download', async () => {
|
||||
stubFetch(async (url) =>
|
||||
url.includes('video-sob-demanda')
|
||||
? jsonResponse(readyBody)
|
||||
: url === VOD_PLAYBACK
|
||||
? mediaResponse(403, null)
|
||||
: mediaResponse(206, 'video/mp4'),
|
||||
)
|
||||
|
||||
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).resolves.toEqual({
|
||||
state: 'pronto',
|
||||
playbackUrl: null,
|
||||
downloadUrl: VOD_DOWNLOAD,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a link that answers 200 with an HTML error page', async () => {
|
||||
stubFetch(async (url) =>
|
||||
url.includes('video-sob-demanda')
|
||||
? jsonResponse(readyBody)
|
||||
: mediaResponse(200, 'text/html; charset=utf-8'),
|
||||
)
|
||||
|
||||
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).resolves.toEqual({
|
||||
state: 'pronto',
|
||||
playbackUrl: null,
|
||||
downloadUrl: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to HEAD when the ranged GET is refused', async () => {
|
||||
stubFetch(async (url, init) => {
|
||||
if (url.includes('video-sob-demanda')) return jsonResponse(readyBody)
|
||||
return (init?.method ?? 'GET').toUpperCase() === 'GET'
|
||||
? mediaResponse(405, null)
|
||||
: mediaResponse(200, 'video/mp4')
|
||||
})
|
||||
|
||||
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).resolves.toEqual({
|
||||
state: 'pronto',
|
||||
playbackUrl: VOD_PLAYBACK,
|
||||
downloadUrl: VOD_DOWNLOAD,
|
||||
})
|
||||
})
|
||||
|
||||
it('maps GERANDO and INDISPONIVEL without probing anything', async () => {
|
||||
const calls = stubFetch(async () => jsonResponse({ estado: 'GERANDO', video: null }))
|
||||
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).resolves.toEqual({
|
||||
state: 'gerando',
|
||||
})
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
stubFetch(async () => jsonResponse({ estado: 'INDISPONIVEL', video: null }))
|
||||
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).resolves.toEqual({
|
||||
state: 'indisponivel',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws after the bounded retries when the Câmara is unreachable', async () => {
|
||||
const calls = stubFetch(async () => {
|
||||
throw new Error('connect ETIMEDOUT')
|
||||
})
|
||||
|
||||
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).rejects.toThrow(
|
||||
'connect ETIMEDOUT',
|
||||
)
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue