# Impl: Acervo de falas: player resolve o trecho sob demanda e YouTube vira default quando existe Status: executado 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
start = offset + t] B -- não --> C{VOD armazenado +
eventId/audioId/excerptTMs?} C -- não --> N[Bloco indisponível
+ Abrir fonte] C -- sim --> P[Capa só VOD
'Assistir o trecho'] Y --> D[Baixar vídeo MP4] P --> R[POST resolver-vod
só no clique] D --> R R --> G{Câmara video-sob-demanda} G -- PRONTO --> V[probe Range 206 video/mp4
playback e download] V -- verificado --> S[video nativo no trecho 0-based
+ download na aba aberta] V -- falha --> F[Estado honesto
+ Tentar novamente] G -- GERANDO --> E[Pendência visível
+ 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 `