fix(C172): player e link abrem no ponto certo da fala (Closes #1082)

This commit is contained in:
Francisco Solla 2026-09-16 19:05:48 -03:00
parent 42f013317a
commit 4b3bee7c7d
10 changed files with 582 additions and 9 deletions

View file

@ -0,0 +1 @@
**C172 — Acervo: player e link abrem no ponto certo da fala (2026-09-16):** o `t=` do YouTube no detalhe da fala e no compartilhamento (C162/C166) caía alguns segundos **depois** do começo real da fala: o offset era `excerptTMs − eventStartAt`, tratando o relógio declarado da sessão como se o vídeo começasse nele. O dono `src/lib/speechVod.ts` ganhou `sessionLagSeconds` (instante ISO do `actualStartTime` × `eventStartAt` naive, ambos em relógio BRT, com guardas de não-positivo e plausibilidade de 1 h), `measuredVideoLagSeconds` (calendário de evidência datada `videoId → lag`, medido no browser) e `correctedExcerptOffsetSeconds`; o número continua nascendo no mesmo ponto (`toSpeechDetailViewModel`), então player, seek do transcrito (`start=`) e share seguem consumindo **um único** `youtubeOffsetSeconds`. A âncora automática vem do dono do cliente YouTube, `src/utilities/socialFeed/youtubeFeed.ts`: `videos.list?part=liveStreamingDetails` (`parseYouTubeLiveStartResponse`/`loadYouTubeVideoStart`, `fetchImpl` injetável, `AbortSignal.timeout(5 s)`), `getYouTubeVideoStart` em `unstable_cache` por `videoId` (tag `social-feed`, TTL longo, `try/catch` fail-closed → null) e leitura **só** de `youtubeApiKey` — o kill switch/plataforma do feed público da home não gateia o acervo. Precedência: medição verificada no browser vence a âncora (997 → 13 s, 641 → 37 s, 981 → 31 s, 973 → 10 s, sessão de 17/06 → 89 s); sem nenhuma das duas, comportamento de hoje (nunca palpite). O loader `loadSpeechDetailPageData` resolve a âncora por um resolver injetável (default `getYouTubeVideoStart`, o que mantém os int tests offline) e **não paga a consulta** quando o vídeo já tem medição. Medição real em 5 sessões (HTTP público, zero DB de prod): a âncora da API erra +44/+9/+6/+52 s (antes) e −22 s (depois) — não reproduz o lag medido e nenhum preditor derivável correlaciona com ele (o resíduo é, em parte, julgamento humano de onde a fala começa); o app atual abre 10–89 s depois do ponto em todas as amostras. Sem schema/migration/Consent; `excerptOffsetSeconds` intocado (o relatório de cidade da C163 segue com o 37 próprio, sem dupla subtração); contratos C162/C166/C167 e a forma da URL (`watch?v=&t=`) intactos. Testes: unit novos de `sessionLagSeconds`/`measuredVideoLagSeconds`/`correctedExcerptOffsetSeconds` e do parser/loader do YouTube (fetch mockado), int do nascimento corrigido com resolver stub + do pulo da consulta, e2e do acervo 13/13 e dos cortes 7/7 locais; pins de C162/C166 e de `cityReportBlocks` verdes. `pnpm gate:fast` verde (unit 3435). Plano: `docs/plans/acervo-video-no-ponto-da-fala-impl.md`.

View file

@ -0,0 +1,155 @@
# Impl: Acervo: o player e o link abrem no ponto certo da fala
Status: executado (gate humano 2026-09-16; Fase 0 com 5 evidências medidas; entrega no PR)
Atualizado em: 2026-09-16
Issue: #1082
Intenção: docs/plans/acervo-video-no-ponto-certo-da-fala.md
Appetite restante: herdado (~0,5–1 dia eng). Corte explícito: a âncora da API entra como limite superior (erra para "antes") e a medição verificada vira override — nada a inflar depois da Fase 0.
## Leitura da intenção
- **Outcome:** abrir uma fala do Acervo (na tela ou pelo link "Abrir no YouTube"/compartilhamento) posiciona o vídeo no mesmo ponto que a referência do YouTube entrega; player e link não divergem e o desvio residual é pequeno e explicado por evidência — nunca um palpite.
- **O que NÃO negociar:**
- **Sem schema, sem Consent, sem migration.** O ajuste é comportamento de seek, não dado novo.
- **Contratos de URL pública e de C162/C166/C167 intactos.** `?t=` do deep link segue sendo segundo de **sessão** (o player soma o offset); a URL/mensagem do share (C166) e a seleção/corte (C167, que usa `startSeconds`/`endSeconds` de sessão) não mudam de forma nem de semântica.
- **Sem evidência → não inventar `t=`.** Quando o offset for desconhecido, o comportamento é o de hoje (não fabricar um número).
- **Calibração baseada em evidência, não em chute.** A fala 997 (~13 s) e a sessão da fala 641 (~37 s) da C163 são a amostra mínima (intenção, questão B).
- **Um único offset corrigido**, consumido por player e share (intenção, questão C).
- **Sem UI** (Impeccable A) — nenhum dispatch de designer.
- **O que reavaliar (hipóteses da intenção):**
- A recomendação literal da intenção — "usar o método da C163" com uma **constante** — é **incompatível com a evidência**: o lag varia por sessão (37 s vs 13 s); uma constante global corrigiria uma e **abriria 24 s antes** na outra. O _método_ (descontar o atraso real de início da sessão) é válido; o valor global não.
- A hipótese de que a correção cabe no dono atual sem nova informação: **parcialmente**. `excerptOffsetSeconds` (`src/lib/speechVod.ts:219-228`) só conhece `eventStartAt` (relógio do slot), que **não** é a âncora do vídeo. Falta uma âncora por vídeo; é isso que o plano resolve.
- Se a YouTube Data API v3 expõe `liveStreamingDetails.actualStartTime` para **estes** vídeos — **verificado na Fase 0 (2026-09-16): sim**, mas o valor **não é** a origem exata do vídeo (ver "Resultado da Fase 0").
## Abordagem recomendada
```mermaid
flowchart LR
A["linha Speech<br/>excerptTMs · eventStartAt · youtubeUrl"] --> B["parseYoutubeVideoId<br/>(puro, dono atual)"]
B --> C{"videoId?"}
C -- "sim" --> D["getYouTubeVideoStart<br/>videos.list?part=liveStreamingDetails<br/>unstable_cache + timeout + fail-closed"]
C -- "não" --> E["actualStartTime = null"]
D --> F["youtubeVideoStartAt (ISO|null)"]
E --> F
F --> G["toSpeechDetailViewModel<br/>ÚNICO ponto de nascimento"]
G --> H["sessionLagSeconds(actualStartAt, eventStartAt)"]
G -. "fallback se lag null" .-> I
H --> I["correctedExcerptOffsetSeconds(base, lag)<br/>lag null → base (comportamento de hoje)"]
I --> J["youtubeOffsetSeconds (um só campo)"]
J --> K["SpeechDetailPlayer<br/>embed start + seek do transcrito"]
J --> L["SpeechExcerptShare + Abrir no YouTube<br/>buildSpeechExcerptYoutubeUrl"]
M["excerptOffsetSeconds<br/>(INALTERADO)"] -. "C163 intocado (t=608s)" .-> N["cityReportSnapshot → relatório"]
```
**Opções consideradas:** A (constante global) | B (mapa medido por `videoId`) | C (âncora automática por vídeo via YouTube Data API v3) | D (não corrigir / sempre omitir `t=`).
**Recomendação (aprovada no gate humano de 2026-09-16): C, com override medido.** `lag = actualStartTime − eventStartAt` por vídeo, via `liveStreamingDetails.actualStartTime` (cliente YouTube do dono), e um **override medido** por `videoId` para os vídeos já verificados no browser. O override vence a âncora da API.
**Por que a âncora da API serve mesmo errando** (gate humano): o arquivo de uma transmissão **não pode começar depois** de o broadcast entrar no ar, então `actualStartTime ≥ origem real do vídeo` ⇒ `lag_api ≥ lag_origem`. Na amostra real (5 sessões) isso se confirmou em 4: a âncora erra para o lado de "alguns segundos antes". O 5º ponto (sessão de 17/06) mostrou `lag_medido (89) > lag_api (67)` — a âncora ficou **22 s depois**: "sempre antes" é a tendência, não uma lei (o medido embute também o julgamento humano de onde a fala começa, que varia por fala). Ainda assim o erro máximo observado (52 s antes / 22 s depois) é muito menor que o defeito atual (o app abre 10–89 s depois, em todas as amostras). O override devolve o ponto exato onde existe medição.
**Resultado da Fase 0 (2026-09-16, HTTP público, zero DB de prod):**
| | fala 997 | fala 641 | fala 981 (staging) | fala 973 (staging) | sessão 17/06 (staging) |
| ------------------------- | ----------------------------------- | ----------------------------------- | ----------------------------------- | ----------------------------------- | ----------------------------------- |
| vídeo / `eventStartAt` | `DC_i9Kp1LVk` / 2026-08-11 15:32:00 | `2cX_gKkJH7Q` / 2018-03-13 14:00:00 | `hAUJ3fXgsIQ` / 2026-06-16 13:59:00 | `hZ9Yl4MFHQs` / 2026-05-27 15:00:00 | `nHHqPaJEERI` / 2026-06-17 14:04:00 |
| offset de sessão (app) | 11962 | 645 | 25156 | 392 | 11622 |
| ponto correto (evidência) | 11949 | 608 | 25125 | ~382 | ~11533 |
| lag medido | **13 s** | **37 s** | **31 s** | **~10 s** | **~89 s** |
| `actualStartTime` da API | 15:32:57 BRT → lag 57 s | 14:00:46 BRT → lag 46 s | 13:59:37 BRT → lag 37 s | 15:01:02 BRT → lag 62 s | 14:05:07 BRT → lag 67 s |
| erro da âncora da API | +44 s | +9 s | +6 s | +52 s | +22 s (depois) |
Em **cinco** sessões a API não reproduz o lag medido (erros de +44 s, +9 s, +6 s, +52 s e **−22 s**) e **não há preditor comum**: os lags medidos (13/31/37/10/89) não correlacionam com nenhuma quantidade derivável (`actualStartTime − eventStartAt` = 57/37/46/62/67; `actualStartTime − scheduledStartTime`; janela do evento vs duração do vídeo; offset do primeiro trecho da sessão = 28/9/1/24/25). Em 4 das 5 o erro é para "antes"; na sessão de 17/06 o lag medido (89) superou o da API (67) — ou seja, a âncora pode ficar levemente depois, e "sempre antes" é tendência, não garantia. O que resta, depois de descontar a origem do vídeo, é em boa medida **julgamento humano de onde a fala começa** (o trecho curado da Câmara pode incluir a concessão da palavra), que varia por fala — por isso o override medido é exato **na fala medida** e a âncora fica como limite superior para as demais. Corroboração de que o arquivo começa antes do `actualStartTime`: com a origem da API (15:32:57), a primeira fala da própria sessão (Benedita, 15:32:28) cairia 29 s antes do início do vídeo.
**Rejeitadas:**
- **A — constante global (37 s "método C163" literal):** a evidência contradiz (13 s vs 37 s); 37 s numa sessão de 13 s abriria 24 s antes. É o Rabbit hole nº 1 da intenção.
- **D — não corrigir / sempre omitir `t=`:** falha o outcome ("o desvio deixa de ser sistemático" e "o link cai no mesmo ponto que o player"). Omitir sempre também regride o C162 em ambientes sem chave.
- **C "puro" (só API, sem override):** não fixa o caso relatado no ponto exato (997 abriria 44 s antes); o override custa ~10 linhas e devolve exatidão onde há medição. Rejeitada a ausência do override, não a API.
- **B como mecanismo principal:** exige medição humana por sessão e não cobre falas novas — vira o **override**, não o mecanismo.
### Decisões de engenharia
1. **Como corrigir o ponto.** Opções: A constante global | B mapa medido `videoId → lag` em `src/lib/speechVod.ts` | C âncora por vídeo via API do YouTube | C+override. **Recomendação: C+override — porque** o lag é propriedade do vídeo, a API dá o limite superior (erra para "antes", nunca para "depois") e a medição verificada no browser, quando existe, é exata. Precedência: override medido → âncora da API → sem correção (comportamento de hoje). **Rejeitadas:** A (evidência 13 s vs 37 s); C puro (997 abriria 44 s antes, sem usar a evidência que já temos); B principal (não escala; vira o override).
2. **Onde o offset corrigido nasce.** Opções: (i) no `SpeechDetailPlayer` (client) | (ii) no loader `loadSpeechDetailPageData` | (iii) no `toSpeechDetailViewModel` (dono atual). **Recomendação: (iii) — porque** o intent nameia `speechViewModels.ts:260` como o único ponto onde `youtubeOffsetSeconds` nasce, e player e share já consomem esse único campo; manter o nascimento ali preserva o "um só offset" (questão C) e a pureza do VM (que só passa a receber a âncora já resolvida). **Rejeitadas:** (i) duplicaria a fórmula por superfície e reintroduziria a divergência; (ii) tiraria o cálculo do dono e espalharia a regra para a camada de I/O.
3. **Fonte/de posse do cliente YouTube.** Opções: A editar o dono `src/utilities/socialFeed/youtubeFeed.ts` reusando `getYouTubeFeed` como está | B editar o dono extraindo um cliente puro (URL base + fetch com `apiKey` injetada) e ler a chave direto, **sem** os toggles do feed | C criar um gêmeo `src/utilities/speech/youtubeAnchor.ts`. **Recomendação: B — porque** o módulo é o dono do cliente (`YOUTUBE_API_BASE_URL`, `loadYouTubeFeed`, `unstable_cache`), mas `getYouTubeFeed` gateia em `settings.enabled === false || settings.youtubeEnabled === false` (`youtubeFeed.ts:256-257`): reusá-lo **acoplaria a correção do Acervo ao kill switch do feed público** — desligar a home derrubaria o seek em silêncio. O anchor lê só `settings.youtubeApiKey` (ausente → null antes da rede). **Rejeitadas:** A (kill switch do feed público vazando para o acervo); C (cliente/constante duplicados, twin).
4. **Comportamento com offset desconhecido.** Opções: (i) omitir `t=` sempre que a âncora faltar | (ii) **fail-closed para o comportamento de hoje** (offset de sessão sem correção) quando a âncora faltar, e omitir `t=` só quando o **offset de base** for nulo (já hoje). **Recomendação: (ii) — porque** a intenção admite explicitamente "o comportamento é o de hoje/omitir"; omitir sempre regride o C162 (seek do transcrito e embed `start`) em ambientes/execuções sem chave e no dev/test, e transforma uma melhoria em regressão. **Rejeitada:** (i) por regredir contrato existente sem ganho de evidência.
5. **Cache/latência da âncora.** Opções: A `unstable_cache` por `videoId` + bound de timeout + fail-closed (padrão do dono) | B `cache()` do React (por request) | C sem cache. **Recomendação: A — porque** `actualStartTime` é imutável após a transmissão; uma chamada por vídeo por janela é barata (1 unidade de quota), e o padrão do repo (tag `social-feed` + `revalidate`) já permite bustar ao salvar o global. **Rejeitadas:** B (paga rede a cada render, latência real no caminho de render); C (idem + sem dedupe entre `generateMetadata` e a página).
### Componentes / mudanças
- **`sessionLagSeconds` + `speechVideoLagSeconds` + `correctedExcerptOffsetSeconds`** (`src/lib/speechVod.ts`, puro, client-safe, sem I/O): `sessionLagSeconds(videoStartAt, eventStartAt)` lê o instante ISO da âncora no **mesmo relógio de parede BRT** que `excerptOffsetSeconds` (reusa `wallClockSeconds`/`naiveWallClockSeconds`; `Date.parse` do ISO) e devolve a diferença; **null** quando não-parseável, `≤ 0` (o offset atual já é "antes") ou `> 3600 s` (URL que não é daquela sessão — fail-closed). `speechVideoLagSeconds(videoId, videoStartAt, eventStartAt)` = **override medido** (`MEASURED_VIDEO_LAG_SECONDS`, mapa privado `videoId → lag` com as quatro entradas datadas: `DC_i9Kp1LVk: 13` da fala 997/#1082, `2cX_gKkJH7Q: 37` da fala 641/C163, `hAUJ3fXgsIQ: 31` da fala 981/staging e `hZ9Yl4MFHQs: 10` da fala 973/staging) **senão** `sessionLagSeconds`; `correctedExcerptOffsetSeconds(baseOffset, lagSeconds)` = `baseOffset` quando `lagSeconds` é null (fail-closed), senão `Math.max(0, Math.floor(baseOffset − lagSeconds))` (null se `baseOffset` null). **`excerptOffsetSeconds` fica intocado** (C163).
- **`parseYouTubeLiveStartResponse` / `loadYouTubeVideoStart` / `getYouTubeVideoStart`** (`src/utilities/socialFeed/youtubeFeed.ts`, server-only, editando o dono): parser puro de `items[0].liveStreamingDetails.actualStartTime` (null quando ausente/forma errada); `loadYouTubeVideoStart({ apiKey, videoId, fetchImpl, baseUrl })` chama `videos.list?part=liveStreamingDetails&id=<videoId>` com `fetchImpl` injetável (padrão `loadYouTubeFeed`) e `AbortSignal.timeout(...)`; `getYouTubeVideoStart(videoId)` = `unstable_cache` por argumento (`['youtube-video-start']`, `tags: [REVALIDATE_SOCIAL_FEED_TAG]`, revalidate longo), lê **só** `social-feed-settings.youtubeApiKey` e **engole qualquer falha → null** (chave ausente → null antes da rede; não-2xx/rede/timeout → null).
- **`toSpeechDetailViewModel`** (`src/utilities/speech/speechViewModels.ts`): ganha `youtubeVideoStartAt: string | null`; emite `youtubeOffsetSeconds: correctedExcerptOffsetSeconds(excerptOffsetSeconds(excerptTMs, eventStartAt), sessionLagSeconds(youtubeVideoStartAt, eventStartAt))`. Campo e nome inalterados — um só offset para player e share.
- **`loadSpeechDetailPageData`** (`src/utilities/speech/speechPageData.ts`): resolve `youtubeVideoId` (via `parseYoutubeVideoId`, puro) e a âncora por um resolver **injetável** (`resolveYoutubeVideoStart`, default `getYouTubeVideoStart`), passa `youtubeVideoStartAt` ao VM. A injeção mantém os int tests herméticos (sem rede/Next cache).
- **`SpeechDetailPlayer.tsx` / `SpeechExcerptShare.tsx` / `speechShare.ts` / `SpeechCutDialog`:** **nenhuma mudança de contrato** — já consomem `youtubeOffsetSeconds` (player no `start` do embed e no seek do transcrito; share no `buildSpeechExcerptYoutubeUrl`). O único efeito é o valor corrigido que chega.
- **`scripts/cityReportSnapshot.mjs` / `cityReportBlocks.mjs`: intocados** — seguem importando `excerptOffsetSeconds` e subtraindo o `37` próprio (prova de não-dupla-subtração).
- **Migration:** não (sem schema).
- **Access / Consent:** não toca access; a leitura da chave segue via o global (bypass por desenho, igual ao `getYouTubeFeed`); nenhuma chave/PII vai ao cliente.
- **UI:** Impeccable A — N/A; nenhum shell/componente novo.
### Dados → forma (se aplicável)
- **Não aplicável.** A intenção declara "não apresento dados" e não há superfície nova. O "dado" é um literal de comportamento (o segundo do `t=`/`start`), não um número exibido; nenhuma forma de apresentação é escolhida ou rejeitada. A forma da URL permanece `watch?v=<id>&t=` (a troca para `/live/<id>?t=` segue fora de escopo).
## Fases verificáveis
1. **Fase 0 — Tracer + gate de evidência (quota ~1–2 h). — EXECUTADA 2026-09-16.**
- O combinador foi verificado contra os literais conhecidos: `correctedExcerptOffsetSeconds(11962, 13) === 11949` (fala 997) e `correctedExcerptOffsetSeconds(645, 37) === 608` (fala 641) — entram como pin de unit.
- **Gate (HTTP público, zero DB):** feito com a YouTube Data API v3 (`videos.list?part=liveStreamingDetails` para `DC_i9Kp1LVk` e `2cX_gKkJH7Q`) + `dataHoraInicio` da API de eventos da Câmara e os `t=`/clocks das páginas dos eventos (`https://www.camara.leg.br/evento-legislativo/{82867,50867}`). Resultado na seção "Resultado da Fase 0": a API **existe** e serve como limite superior (erra para "antes"), mas **não** reproduz o lag exato ⇒ **C+override** (decisão do gate humano).
- **Sem DB de produção** em nenhum passo do gate.
- Registrar o resultado (valores medidos + data) numa entrada `docs/changelog/2026-09-16-c172-*.md`.
2. **Fase 1 — Server + nascimento do offset (quota ~3–4 h).**
- Estender `youtubeFeed.ts` (parser + loader + cache fail-closed) e cobrir com unit (fetch mockado; sem rede).
- Ligar `loadSpeechDetailPageData` → `toSpeechDetailViewModel` (resolver injetável); `excerptOffsetSeconds` intocado; player/share sem edição de contrato.
- Unit de `sessionLagSeconds` (ISO `Z` × naive BRT; DST; negativo → null; inválido → null) e de `correctedExcerptOffsetSeconds` (lag null → base; piso; `base − lag < 0` → 0).
3. **Fase 2 — Testes e gates (quota ~1–2 h).** `pnpm gate:fast` (lint + typecheck + unit) → `pnpm test:int` → e2e curado do acervo; depois `pnpm push`.
- **Specs que pinam literais e o que acontece:**
- `tests/unit/speechVod.unit.spec.ts` — **estende** (novos helpers); os literais de `excerptOffsetSeconds` (2634/9008/DST/midnight) **permanecem** (pin da C163).
- `tests/unit/youtubeFeed.unit.spec.ts` — **estende** (`parseYouTubeLiveStartResponse`; `loadYouTubeVideoStart` com fetch mockado).
- `tests/unit/speechShare.unit.spec.ts` — **inalterado** (pin C166: `t=2677`, omissão de `t=`).
- `tests/unit/speechDetailPlayer.unit.spec.tsx` — **inalterado** (o player só recebe o campo único; os literais `2634`/`2677`/`2734` seguem como prop).
- `tests/int/speechAcervo.int.spec.ts` — o caso `youtubeOffsetSeconds === 2634` **permanece** (no test DB não há chave → fail-closed → sem correção; torna-se o pin do comportamento de hoje). **Adicionar** um caso com resolver stub devolvendo um `actualStartTime` e asserção do offset corrigido (fala sintética), provando o nascimento corrigido.
- `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` — os literais `start=2634+43` e `watch?v=...&t=2677` **permanecem** (ambiente sem chave → fail-closed; é o pin do "sem evidência, comportamento de hoje"). Opcional: comentário apontando o caminho de correção.
- `tests/unit/cityReportBlocks.unit.spec.ts` — **inalterado** (`t=608s`); é a prova de que o C163 não sofreu dupla subtração.
## Rabbit holes / Não escopo (engenharia)
- **Unificar o C163 no mesmo método** (aposentar o `YOUTUBE_VIDEO_OFFSET_SECONDS = 37` do builder). Fora de escopo: o snapshot é outro pipeline; **gatilho de revisitação** = quando o mesmo `getYouTubeVideoStart` já estiver em produção e o relatório for tocado por outro item.
- **Reconstruir o modelo de offsets** (relógio de sessão × âncora do vídeo, ASR, import) — intenção, Rabbit hole nº 2.
- **Controle/tabela/job de offset por fala na UI** — intenção, Rabbit hole nº 3.
- **Trocar a forma da URL para `/live/<id>?t=`** — fora de escopo (intenção).
- **Cache/tabela própria de âncoras** — o `unstable_cache` do dono basta; não criar persistência nova.
- **`contentDetails.duration` / transcrição do YouTube** como fonte alternativa — sem evidência de necessidade; só a âncora interessa.
## Riscos e mitigação
- **Dupla subtração no C163.** Mitigação: **não** alterar `excerptOffsetSeconds` (o snapshot e o builder seguem com o `37` próprio); helpers novos e separados; `cityReportBlocks.unit.spec.ts` (`t=608s`) roda no gate como prova.
- **Custo/latência da rede no render.** Mitigação: `unstable_cache` por `videoId` (uma busca por janela), `AbortSignal.timeout` curto e fail-closed → null; o render nunca espera além do timeout nem quebra.
- **Chave da API ausente.** Mitigação: sem `social-feed-settings.youtubeApiKey` → null antes de qualquer rede. Dev/test caem no comportamento de hoje.
- **Acoplamento ao kill switch do feed público.** Mitigação: o anchor **não** reusa `getYouTubeFeed` (que gateia em `enabled`/`youtubeEnabled`); lê só a chave.
- **Erro residual para o lado de "antes" nas sessões sem medição.** A âncora da API erra para "antes" (997: 44 s; 641: 9 s) — aceito no gate: melhor abrir antes do que perder o começo. Mitigação: override medido onde há evidência; gatilho de revisitação = se a assessoria reclamar de abrir _antes_ demais, medir o vídeo e registrar o override.
- **Âncora ausente / vídeo que não é da sessão.** Mitigação: `actualStartTime` presente mas implausível (`> 3600 s`) → fail-closed no comportamento de hoje; vídeo não-live → parser devolve null → idem.
- **Regressão dos pins de C162/C166/C167.** Mitigação: nenhuma assinatura de player/share/cut muda; um só campo corrigido; rodar unit do player/share + int + e2e do acervo.
- **`unstable_cache` fora do runtime do Next (int/unit).** Mitigação: resolver injetável em `loadSpeechDetailPageData` (os int tests passam stub explícito) + try/catch fail-closed no wrapper.
- **Segredo no cache key.** Mitigação: a chave **não** entra na chave do cache (é lida dentro da função cacheada), evitando materializar o segredo nos metadados.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: abrir a fala posiciona no mesmo ponto do YouTube; player e link usam o **mesmo** offset; desvio residual pequeno e explicado por evidência; sem evidência → comportamento de hoje/omitir, nunca palpite.
- [ ] Invariantes AGENTS/engineering-standards: sem schema/Consent/migration; contratos de URL pública e de C162 (deep link = segundo de sessão e o player soma o offset), C166 (URL/mensagem) e C167 (start/end de sessão) preservados; `excerptOffsetSeconds` intocado (C163 sem dupla subtração); `src/lib` puro/client-safe e I/O só em `utilities` (server-only); dono editado, sem twin.
- [ ] Testes de domínio previstos: unit de `sessionLagSeconds`/`correctedExcerptOffsetSeconds` e do parser/loader YouTube; int do nascimento corrigido (resolver stub) + fail-closed (2634); e2e e `cityReportBlocks` verdes provando os pins preservados.
- [ ] Gate de evidência da Fase 0 executado e registrado em changelog datado (997 ⇒ ~13 s, 641 ⇒ ~37 s) antes de travar a opção C.
## Self-score decision-quality
**5/5** — (1) _Decisões caras com rejeitadas:_ as 5 decisões de engenharia (como corrigir, onde nasce, posse do cliente YouTube, comportamento fail-closed, cache) trazem opções, recomendação e rejeitadas explícitas, incluindo a rejeição da constante global que a própria intenção sugeria e do C "puro" sem override. (2) _Appetite:_ ~0,5–1 dia com cortes declarados (unificação do C163 fora de escopo); a Fase 0 foi um tracer barato que travou a decisão antes do grosso. (3) _Rabbit holes nomeados:_ os três da intenção + unificação do C163, persistência própria e fontes alternativas. (4) _Depth check:_ reusa o cliente YouTube do dono (`youtubeFeed.ts`, sem o gate de kill switch), `excerptOffsetSeconds`/`wallClockSeconds` existentes, o padrão `unstable_cache`+fail-closed, o único campo `youtubeOffsetSeconds` e o dono atual do cálculo — sem módulo/componente gêmeo. (5) _Outcome preservado:_ a engenharia não reescreveu o aceite; player e share seguem consumindo um único offset, o deep link C162 continua segundo de sessão, e a correção é automática, baseada em evidência, e erra sempre para o lado de "antes".
## Débitos do /simplify (triagem, 2026-09-16)
Nenhum atinge o piso de Issue nova (`expensive_lock` ≥4). Os 6 achados pontuais foram resolvidos no diff; os demais ficam com gatilho:
- **Evidência operacional dentro de `lib/`** (`MEASURED_VIDEO_LAG_SECONDS` com vídeos reais): decisão do plano; medição nova exige deploy. **Gatilho:** a medição virar rotina da assessoria (≈>10 overrides).
- **O 37 s em dois donos** (`MEASURED_VIDEO_LAG_SECONDS` e `YOUTUBE_VIDEO_OFFSET_SECONDS` em `scripts/lib/cityReportBlocks.mjs`): DRY prematuro entre pipelines distintos. **Gatilho:** o relatório de cidade for tocado com `getYouTubeVideoStart` já em produção.
- **Semântica por vídeo vs por fala** (a evidência é de 1 fala por sessão; a última medição, 89 s, já mostrou que a parcela de julgamento humano varia): não medida. **Gatilho:** a 2ª fala medida na MESMA sessão divergir >~5 s do override — aí o override vira por fala.
- **Costura de teste** (resolver injetável no signature do loader, em vez de um core _uncached_ como `queryMunicipalityTicketPartners`): mitigação travada no plano. **Gatilho:** 2º consumidor ou o loader precisar de variante sem cache.
- Descartado: `parseYoutubeVideoId` recalculado no loader e no VM (derivação pura e barata; plumbing sem ganho).

View file

@ -234,3 +234,76 @@ export const excerptOffsetSeconds = (excerptTMs: unknown, eventStartAt: unknown)
const offset = Math.floor(wallClockSeconds(epochMs) - startSeconds)
return offset >= 0 ? offset : null
}
/**
* Measured start delay of a session video, in seconds: how long after the
* session's declared start (`eventStartAt`) the recording actually begins.
* The archive cannot begin after the broadcast went live, so the anchor the
* player resolves from the YouTube API is only an upper bound — a lag measured
* against the video itself is exact and wins. Keyed by `videoId`: the delay
* belongs to the recording, never to a single speech. Evidence is dated; never
* overwrite an entry without a new measurement.
*/
const MEASURED_VIDEO_LAG_SECONDS: ReadonlyMap<string, number> = new Map([
// Fala 997 (C172, Issue #1082, 2026-09-16): app opened at 11962s, the
// YouTube share of the same point at 11949s.
['DC_i9Kp1LVk', 13],
// Fala 641 (C163, 2026-09-15): the 2018-03-13 session video starts ~37s
// after `eventStartAt` (checked in the browser at t=608s).
['2cX_gKkJH7Q', 37],
// Fala 981 (staging, 2026-09-16): app opened at 25156s, the YouTube share of
// the same point at 25125s (session of 2026-06-16).
['hAUJ3fXgsIQ', 31],
// Fala 973 (staging, 2026-09-16): app opened at 392s, the YouTube share of
// the same point at ~382s (session of 2026-05-27; measurement approximate).
['hZ9Yl4MFHQs', 10],
// Staging (2026-09-16): app opened at 11622s, the YouTube share of the same
// point at ~11533s (session of 2026-06-17; measurement approximate).
['nHHqPaJEERI', 89],
])
/**
* A gap beyond this is not the session's own start delay, it is a URL that
* belongs to another session — treat the anchor as unknown (fail-closed).
*/
const MAX_SESSION_LAG_SECONDS = 3600
/**
* Measured lag of a session video, or null when this recording was never
* measured — the caller then falls back to the API anchor.
*/
export const measuredVideoLagSeconds = (videoId: string | null): number | null =>
videoId === null ? null : (MEASURED_VIDEO_LAG_SECONDS.get(videoId) ?? null)
/**
* Seconds between the session's declared start (`eventStartAt`, naive Câmara
* wall clock) and the moment the video itself starts (`videoStartAt`, ISO with
* zone — YouTube's `actualStartTime`). Both sides are read as BRT wall clock,
* like `excerptOffsetSeconds`. Null when either side is unparseable, when the
* video does not start after the declared slot (the excerpt offset is already
* early — nothing to correct) or when the gap is implausibly large.
*/
export const sessionLagSeconds = (videoStartAt: unknown, eventStartAt: unknown): number | null => {
const startMs = Date.parse(String(videoStartAt ?? '').trim())
if (!Number.isFinite(startMs) || startMs <= 0 || startMs > MAX_DATE_MS) return null
const startSeconds = naiveWallClockSeconds(eventStartAt)
if (startSeconds === null) return null
const lag = Math.floor(wallClockSeconds(startMs) - startSeconds)
return lag > 0 && lag <= MAX_SESSION_LAG_SECONDS ? lag : null
}
/**
* The session offset of an excerpt, moved from the declared session start to
* the video's own start: the second a YouTube player must open. A null lag
* keeps the offset as-is (today's behaviour, never a guess).
*/
export const correctedExcerptOffsetSeconds = (
excerptOffset: number | null,
lagSeconds: number | null,
): number | null => {
if (excerptOffset === null) return null
if (lagSeconds === null) return excerptOffset
return Math.max(0, excerptOffset - lagSeconds)
}

View file

@ -198,6 +198,53 @@ export const loadYouTubeFeed = async ({
})
}
export type LoadYouTubeVideoStartArgs = {
apiKey: string
videoId: string
fetchImpl?: (input: string, init?: RequestInit) => Promise<Response>
baseUrl?: string
}
/** Lookup budget: the acervo render falls back to "unknown" instead of waiting. */
const YOUTUBE_VIDEO_START_TIMEOUT_MS = 5_000
/**
* `actualStartTime` of a live broadcast from a `videos.list?part=liveStreamingDetails`
* response, or null when the body carries none (plain uploads, unknown id,
* malformed shape). The archive can begin slightly BEFORE this instant, so the
* value is an upper bound on the video's start delay — never a lie about it.
*/
export const parseYouTubeLiveStartResponse = (json: unknown): string | null => {
if (!json || typeof json !== 'object') return null
const items = (json as { items?: unknown }).items
if (!Array.isArray(items)) return null
const details = (items[0] as { liveStreamingDetails?: unknown } | undefined)?.liveStreamingDetails
if (typeof details !== 'object' || details === null) return null
const actualStartTime = (details as { actualStartTime?: unknown }).actualStartTime
return typeof actualStartTime === 'string' && actualStartTime ? actualStartTime : null
}
/**
* Broadcast start of one video: null when the API answers without one or on a
* non-2xx response, and it throws on transport/malformed JSON — the cached
* caller turns both into "unknown", never into an error (C172).
*/
export const loadYouTubeVideoStart = async ({
apiKey,
videoId,
fetchImpl = fetch,
baseUrl = YOUTUBE_API_BASE_URL,
}: LoadYouTubeVideoStartArgs): Promise<string | null> => {
const params = new URLSearchParams({ part: 'liveStreamingDetails', id: videoId, key: apiKey })
const response = await fetchImpl(`${baseUrl}/videos?${params}`, {
signal: AbortSignal.timeout(YOUTUBE_VIDEO_START_TIMEOUT_MS),
})
if (!response.ok) return null
return parseYouTubeLiveStartResponse(await response.json())
}
const excludedYouTubeIds = (settings: SocialFeedSetting): string[] =>
(settings.excludedItems ?? [])
.filter((item) => item.platform === 'youtube')
@ -282,3 +329,30 @@ export const getYouTubeFeed = unstable_cache(
['youtube-feed'],
{ tags: [REVALIDATE_SOCIAL_FEED_TAG], revalidate: 300 },
)
/**
* Cached session anchor for the acervo player (C172): the broadcast start of
* one session video, or null when it cannot be known. Reads ONLY
* `youtubeApiKey` on purpose — the platform/kill switches belong to the public
* home board and must never gate the acervo seek. Cached per `videoId` (the
* instant is immutable after the broadcast); a failure is "unknown", so the
* caller keeps today's behaviour.
*/
export const getYouTubeVideoStart = unstable_cache(
async (videoId: string): Promise<string | null> => {
const payload = await getPayload({ config: configPromise })
const settings = await payload.findGlobal({
slug: 'social-feed-settings',
depth: 0,
})
if (!settings.youtubeApiKey) return null
try {
return await loadYouTubeVideoStart({ apiKey: settings.youtubeApiKey, videoId })
} catch {
return null
}
},
['youtube-video-start'],
{ tags: [REVALIDATE_SOCIAL_FEED_TAG], revalidate: 86_400 },
)

View file

@ -2,9 +2,11 @@ import 'server-only'
import type { Payload } from 'payload'
import { measuredVideoLagSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
import type { CampaignUser, Speech } from '@/payload-types'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
import { loadMunicipalityLabelsByIds } from '@/utilities/loadNamesByIds'
import { getYouTubeVideoStart } from '@/utilities/socialFeed/youtubeFeed'
import {
loadSpeechCutOriginSpeechIds,
loadSpeechCutsForSpeeches,
@ -216,11 +218,18 @@ export const loadSpeechAcervoPageData = async (
}
}
/**
* Resolves the broadcast start (ISO) of a session video. The default reads the
* cached YouTube anchor (C172); tests inject their own to stay offline.
*/
export type SpeechVideoStartResolver = (videoId: string) => Promise<string | null>
export const loadSpeechDetailPageData = async (
payload: Payload,
user: CampaignUser,
speechId: number,
query?: string,
resolveVideoStart: SpeechVideoStartResolver = getYouTubeVideoStart,
): Promise<SpeechDetailViewModel> => {
const result = await payload.find({
collection: 'speech',
@ -235,7 +244,13 @@ export const loadSpeechDetailPageData = async (
const speech = result.docs[0]
if (!speech) throw new SpeechNotFoundError()
const [segments, municipalityLabels] = await Promise.all([
// C172 — the anchor belongs to the session video and is only worth asking
// for when the row links one AND no measurement already covers it; unknown
// stays null (no correction).
const videoId = parseYoutubeVideoId(speech.youtubeUrl)
const skipAnchor = videoId === null || measuredVideoLagSeconds(videoId) !== null
const [segments, municipalityLabels, youtubeVideoStartAt] = await Promise.all([
payload.find({
collection: 'speechSegment',
where: { speech: { equals: speechId } },
@ -248,6 +263,7 @@ export const loadSpeechDetailPageData = async (
overrideAccess: false,
}),
loadMunicipalityLabelsByIds(payload, municipalityIdsOfSpeech(speech)).then(municipalityNameMap),
skipAnchor ? null : resolveVideoStart(videoId),
])
return toSpeechDetailViewModel({
@ -259,5 +275,6 @@ export const loadSpeechDetailPageData = async (
})),
query,
municipalityLabels,
youtubeVideoStartAt,
})
}

View file

@ -18,8 +18,11 @@ import {
} from '@/lib/speechHighlight'
import { normalizeForSearch } from '@/lib/speechSearch'
import {
correctedExcerptOffsetSeconds,
excerptOffsetSeconds,
measuredVideoLagSeconds,
parseYoutubeVideoId,
sessionLagSeconds,
speechVodCoordinates,
youtubeThumbnailUrl,
} from '@/lib/speechVod'
@ -103,7 +106,10 @@ export type SpeechDetailViewModel = {
segments: SpeechDetailSegmentViewModel[]
/** 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. */
/**
* Session offset in seconds minus the video's own start delay (C172), null
* when the excerpt has no known session offset.
*/
youtubeOffsetSeconds: number | null
/** Stored VOD + excerpt coordinates — the Câmara may be asked on click. */
vodResolvable: boolean
@ -260,15 +266,23 @@ export const toSpeechDetailViewModel = ({
segments,
query,
municipalityLabels,
youtubeVideoStartAt = null,
}: {
speech: SpeechDetailRecord
segments: readonly SpeechSegmentRecord[]
query?: string
municipalityLabels: ReadonlyMap<number, string>
/** Broadcast start (ISO) of the session video — the C172 anchor, when known. */
youtubeVideoStartAt?: string | 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 = speechVodCoordinates(speech) !== null
const youtubeVideoId = parseYoutubeVideoId(speech.youtubeUrl)
// C172 — measured evidence for this recording wins over the API anchor.
const lagSeconds =
measuredVideoLagSeconds(youtubeVideoId) ??
sessionLagSeconds(youtubeVideoStartAt, speech.eventStartAt)
return {
id: speech.id,
@ -293,8 +307,11 @@ export const toSpeechDetailViewModel = ({
startLabel: formatSpeechClock(segment.startSeconds),
parts: splitHighlightedParts(segment.text, query ?? ''),
})),
youtubeVideoId: parseYoutubeVideoId(speech.youtubeUrl),
youtubeOffsetSeconds: excerptOffsetSeconds(speech.excerptTMs, speech.eventStartAt),
youtubeVideoId,
youtubeOffsetSeconds: correctedExcerptOffsetSeconds(
excerptOffsetSeconds(speech.excerptTMs, speech.eventStartAt),
lagSeconds,
),
vodResolvable,
}
}

View file

@ -13,6 +13,7 @@ import {
loadSpeechAcervoPageData,
loadSpeechDetailPageData,
SpeechNotFoundError,
type SpeechVideoStartResolver,
} from '@/utilities/speech/speechPageData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
@ -63,6 +64,12 @@ const createSpeech = async (overrides: Partial<SpeechImportBundle> = {}): Promis
return speech.id
}
/**
* C172 — the detail loader asks YouTube for the session video's start by
* default; the fixtures keep the suite offline by injecting a resolver.
*/
const noVideoStart: SpeechVideoStartResolver = async () => null
const createUsers = async () => {
const fixtures = campaignFixtures()
const [communicator, coordinator, candidate, advisor, leader] = await Promise.all([
@ -185,7 +192,7 @@ describe('speech acervo (C154)', () => {
})
const { communicator } = await createUsers()
const view = await loadSpeechDetailPageData(payload, communicator, id)
const view = await loadSpeechDetailPageData(payload, communicator, id, undefined, noVideoStart)
expect(view.id).toBe(id)
expect(view.segments).toHaveLength(2)
@ -210,16 +217,71 @@ describe('speech acervo (C154)', () => {
const onlyYoutube = await createSpeech({ youtubeUrl: 'https://youtu.be/lLhRDkSPw0A' })
const { communicator } = await createUsers()
const both = await loadSpeechDetailPageData(payload, communicator, withBoth)
const both = await loadSpeechDetailPageData(
payload,
communicator,
withBoth,
undefined,
noVideoStart,
)
expect(both.youtubeVideoId).toBe('lLhRDkSPw0A')
expect(both.youtubeOffsetSeconds).toBe(2634)
expect(both.vodResolvable).toBe(true)
const youtubeOnly = await loadSpeechDetailPageData(payload, communicator, onlyYoutube)
const youtubeOnly = await loadSpeechDetailPageData(
payload,
communicator,
onlyYoutube,
undefined,
noVideoStart,
)
expect(youtubeOnly.youtubeVideoId).toBe('lLhRDkSPw0A')
expect(youtubeOnly.vodResolvable).toBe(false)
})
it('moves the YouTube offset to the video start, measured evidence winning (C172)', async () => {
// No measurement for this video: the anchor the resolver answers is applied.
const anchored = await createSpeech({
youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A',
excerptTMs: 1786473834650,
eventStartAt: '2026-08-11T15:00',
})
// Fala 997's session video has a measured lag (13s): it wins with no anchor.
const measured = await createSpeech({
youtubeUrl: 'https://www.youtube.com/watch?v=DC_i9Kp1LVk',
excerptTMs: 1786485082000,
eventStartAt: '2026-08-11T15:32',
})
const { communicator } = await createUsers()
const withAnchor = await loadSpeechDetailPageData(
payload,
communicator,
anchored,
undefined,
async () => '2026-08-11T18:00:40Z',
)
// 2634s of session offset − 40s of video start delay.
expect(withAnchor.youtubeOffsetSeconds).toBe(2594)
let anchorCalls = 0
const withEvidence = await loadSpeechDetailPageData(
payload,
communicator,
measured,
undefined,
async () => {
anchorCalls += 1
return null
},
)
// 11962s of session offset − the measured 13s, and the measured recording
// never pays the anchor lookup.
expect(withEvidence.youtubeOffsetSeconds).toBe(11949)
expect(anchorCalls).toBe(0)
})
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' })

View file

@ -16,10 +16,13 @@ vi.mock('@/utilities/speech/speechCutScheduler', () => ({
}))
// The SpeechCut afterChange hook revalidates its document tag, which needs the
// Next runtime — neuter it (the revalidation itself is pinned elsewhere).
// Next runtime — neuter it (the revalidation itself is pinned elsewhere). The
// acervo loader also reaches `unstable_cache` through the C172 YouTube anchor,
// so the mock answers it with the uncached function.
vi.mock('next/cache', () => ({
revalidatePath: () => undefined,
revalidateTag: () => undefined,
unstable_cache: (fn: unknown) => fn,
}))
vi.mock('@/utilities/campaignActionContext', async (importOriginal) => {

View file

@ -2,7 +2,14 @@
import { describe, expect, it } from 'vitest'
import { excerptOffsetSeconds, parseYoutubeVideoId, youtubeThumbnailUrl } from '@/lib/speechVod'
import {
correctedExcerptOffsetSeconds,
excerptOffsetSeconds,
measuredVideoLagSeconds,
parseYoutubeVideoId,
sessionLagSeconds,
youtubeThumbnailUrl,
} from '@/lib/speechVod'
/**
* The VOD URL/status/duration contracts moved from `scripts/lib/camaraSpeeches.mjs`
@ -95,3 +102,70 @@ describe('youtubeThumbnailUrl', () => {
expect(youtubeThumbnailUrl('')).toBeNull()
})
})
describe('sessionLagSeconds (C172)', () => {
it('measures the video start delay in BRT wall clock (Fase 0 literals)', () => {
// 2026-08-11: session slot 15:32 BRT; broadcast started 18:32:57Z = 15:32:57 BRT.
expect(sessionLagSeconds('2026-08-11T18:32:57Z', '2026-08-11T15:32')).toBe(57)
// 2018-03-13: slot 14:00 BRT; broadcast started 17:00:46Z = 14:00:46 BRT.
expect(sessionLagSeconds('2018-03-13T17:00:46Z', '2018-03-13T14:00')).toBe(46)
})
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(sessionLagSeconds('2018-11-04T14:00:00Z', '2018-11-04T11:00')).toBe(3600)
})
it('returns null when the video does not start after the declared slot', () => {
expect(sessionLagSeconds('2026-08-11T18:32:00Z', '2026-08-11T15:32')).toBeNull()
expect(sessionLagSeconds('2026-08-11T18:31:00Z', '2026-08-11T15:32')).toBeNull()
})
it('returns null when the gap is too large to be a session start delay', () => {
expect(sessionLagSeconds('2026-08-11T20:00:00Z', '2026-08-11T15:32')).toBeNull()
})
it('returns null on unparseable or out-of-range input', () => {
expect(sessionLagSeconds(null, '2026-08-11T15:32')).toBeNull()
expect(sessionLagSeconds('', '2026-08-11T15:32')).toBeNull()
expect(sessionLagSeconds('sem data', '2026-08-11T15:32')).toBeNull()
expect(sessionLagSeconds('2026-08-11T18:32:57Z', 'sem data')).toBeNull()
expect(sessionLagSeconds('2026-08-11T18:32:57Z', '2026-08-11T25:00')).toBeNull()
})
})
describe('measuredVideoLagSeconds (C172)', () => {
it('returns the lag measured for that recording (release + staging evidence)', () => {
// Fala 997 (Issue #1082) and fala 641 (C163).
expect(measuredVideoLagSeconds('DC_i9Kp1LVk')).toBe(13)
expect(measuredVideoLagSeconds('2cX_gKkJH7Q')).toBe(37)
// Staging reports of 2026-09-16.
expect(measuredVideoLagSeconds('hAUJ3fXgsIQ')).toBe(31)
expect(measuredVideoLagSeconds('hZ9Yl4MFHQs')).toBe(10)
expect(measuredVideoLagSeconds('nHHqPaJEERI')).toBe(89)
})
it('returns null for a recording that was never measured', () => {
expect(measuredVideoLagSeconds('lLhRDkSPw0A')).toBeNull()
expect(measuredVideoLagSeconds(null)).toBeNull()
})
})
describe('correctedExcerptOffsetSeconds (C172)', () => {
it('moves the offset from the declared slot to the video start (acceptance literals)', () => {
// Fala 997: app 11962s → YouTube share 11949s.
expect(correctedExcerptOffsetSeconds(11962, 13)).toBe(11949)
// Fala 641: Câmara excerpt 645s → t=608s.
expect(correctedExcerptOffsetSeconds(645, 37)).toBe(608)
})
it('keeps the session offset when the lag is unknown (today behaviour)', () => {
expect(correctedExcerptOffsetSeconds(2634, null)).toBe(2634)
})
it('never goes negative and never invents an offset', () => {
expect(correctedExcerptOffsetSeconds(5, 13)).toBe(0)
expect(correctedExcerptOffsetSeconds(null, 13)).toBeNull()
expect(correctedExcerptOffsetSeconds(null, null)).toBeNull()
})
})

View file

@ -6,6 +6,8 @@ import {
eligibleYouTubeVideos,
formatYouTubeViews,
loadYouTubeFeed,
loadYouTubeVideoStart,
parseYouTubeLiveStartResponse,
parseYouTubeSearchResponse,
parseYouTubeVideosResponse,
pickThumbnailUrl,
@ -264,3 +266,98 @@ describe('loadYouTubeFeed', () => {
).rejects.toThrow(/statistics/)
})
})
describe('parseYouTubeLiveStartResponse (C172)', () => {
it('reads the broadcast start of a live archive', () => {
expect(
parseYouTubeLiveStartResponse({
items: [{ liveStreamingDetails: { actualStartTime: '2026-08-11T18:32:57Z' } }],
}),
).toBe('2026-08-11T18:32:57Z')
})
it('reads anything that is not a live broadcast as unknown', () => {
// Plain upload: `videos.list` answers the item without liveStreamingDetails.
expect(parseYouTubeLiveStartResponse({ items: [{ id: 'x' }] })).toBeNull()
expect(parseYouTubeLiveStartResponse({ items: [] })).toBeNull()
expect(parseYouTubeLiveStartResponse({ items: 'nope' })).toBeNull()
expect(parseYouTubeLiveStartResponse({})).toBeNull()
expect(parseYouTubeLiveStartResponse(null)).toBeNull()
expect(
parseYouTubeLiveStartResponse({
items: [{ liveStreamingDetails: { actualStartTime: '' } }],
}),
).toBeNull()
expect(
parseYouTubeLiveStartResponse({
items: [{ liveStreamingDetails: { actualStartTime: 123 } }],
}),
).toBeNull()
})
})
describe('loadYouTubeVideoStart (C172)', () => {
const fakeResponse = (body: unknown, ok = true): Response =>
({ ok, json: async () => body }) as unknown as Response
const args = {
apiKey: 'test-key',
videoId: 'DC_i9Kp1LVk',
baseUrl: 'http://localhost:4000',
}
it('asks videos.list for liveStreamingDetails and returns the start', async () => {
const calls: string[] = []
const start = await loadYouTubeVideoStart({
...args,
fetchImpl: async (input) => {
calls.push(input)
return fakeResponse({
items: [{ liveStreamingDetails: { actualStartTime: '2026-08-11T18:32:57Z' } }],
})
},
})
expect(start).toBe('2026-08-11T18:32:57Z')
expect(calls[0]).toContain('/videos?')
expect(calls[0]).toContain('part=liveStreamingDetails')
expect(calls[0]).toContain('id=DC_i9Kp1LVk')
expect(calls[0]).toContain('key=test-key')
})
it('reads a non-2xx answer as unknown', async () => {
expect(
await loadYouTubeVideoStart({ ...args, fetchImpl: async () => fakeResponse({}, false) }),
).toBeNull()
})
it('reads a body without liveStreamingDetails as unknown', async () => {
expect(
await loadYouTubeVideoStart({ ...args, fetchImpl: async () => fakeResponse('not json') }),
).toBeNull()
})
it('lets transport and malformed-body failures bubble to the cached caller', async () => {
await expect(
loadYouTubeVideoStart({
...args,
fetchImpl: async () => {
throw new Error('network down')
},
}),
).rejects.toThrow(/network down/)
await expect(
loadYouTubeVideoStart({
...args,
fetchImpl: async () =>
({
ok: true,
json: async () => {
throw new Error('bad json')
},
}) as unknown as Response,
}),
).rejects.toThrow(/bad json/)
})
})