diff --git a/docs/changelog/2026-09-16-c172.md b/docs/changelog/2026-09-16-c172.md new file mode 100644 index 00000000..b57d45b9 --- /dev/null +++ b/docs/changelog/2026-09-16-c172.md @@ -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`. diff --git a/docs/plans/acervo-video-no-ponto-da-fala-impl.md b/docs/plans/acervo-video-no-ponto-da-fala-impl.md new file mode 100644 index 00000000..92d2424c --- /dev/null +++ b/docs/plans/acervo-video-no-ponto-da-fala-impl.md @@ -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
excerptTMs · eventStartAt · youtubeUrl"] --> B["parseYoutubeVideoId
(puro, dono atual)"] + B --> C{"videoId?"} + C -- "sim" --> D["getYouTubeVideoStart
videos.list?part=liveStreamingDetails
unstable_cache + timeout + fail-closed"] + C -- "não" --> E["actualStartTime = null"] + D --> F["youtubeVideoStartAt (ISO|null)"] + E --> F + F --> G["toSpeechDetailViewModel
ÚNICO ponto de nascimento"] + G --> H["sessionLagSeconds(actualStartAt, eventStartAt)"] + G -. "fallback se lag null" .-> I + H --> I["correctedExcerptOffsetSeconds(base, lag)
lag null → base (comportamento de hoje)"] + I --> J["youtubeOffsetSeconds (um só campo)"] + J --> K["SpeechDetailPlayer
embed start + seek do transcrito"] + J --> L["SpeechExcerptShare + Abrir no YouTube
buildSpeechExcerptYoutubeUrl"] + M["excerptOffsetSeconds
(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=` 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=&t=` (a troca para `/live/?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/?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). diff --git a/src/lib/speechVod.ts b/src/lib/speechVod.ts index ccba3f10..d9d0c3dd 100644 --- a/src/lib/speechVod.ts +++ b/src/lib/speechVod.ts @@ -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 = 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) +} diff --git a/src/utilities/socialFeed/youtubeFeed.ts b/src/utilities/socialFeed/youtubeFeed.ts index 46cac3cf..62adfce1 100644 --- a/src/utilities/socialFeed/youtubeFeed.ts +++ b/src/utilities/socialFeed/youtubeFeed.ts @@ -198,6 +198,53 @@ export const loadYouTubeFeed = async ({ }) } +export type LoadYouTubeVideoStartArgs = { + apiKey: string + videoId: string + fetchImpl?: (input: string, init?: RequestInit) => Promise + 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 => { + 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 => { + 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 }, +) diff --git a/src/utilities/speech/speechPageData.ts b/src/utilities/speech/speechPageData.ts index de11cf99..d9248d8d 100644 --- a/src/utilities/speech/speechPageData.ts +++ b/src/utilities/speech/speechPageData.ts @@ -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 + export const loadSpeechDetailPageData = async ( payload: Payload, user: CampaignUser, speechId: number, query?: string, + resolveVideoStart: SpeechVideoStartResolver = getYouTubeVideoStart, ): Promise => { 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, }) } diff --git a/src/utilities/speech/speechViewModels.ts b/src/utilities/speech/speechViewModels.ts index 181f5bee..4d5ace4a 100644 --- a/src/utilities/speech/speechViewModels.ts +++ b/src/utilities/speech/speechViewModels.ts @@ -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 + /** 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, } } diff --git a/tests/int/speechAcervo.int.spec.ts b/tests/int/speechAcervo.int.spec.ts index a78b9722..dbc51b5c 100644 --- a/tests/int/speechAcervo.int.spec.ts +++ b/tests/int/speechAcervo.int.spec.ts @@ -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 = {}): 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' }) diff --git a/tests/int/speechCut.int.spec.ts b/tests/int/speechCut.int.spec.ts index f82e0a57..cb3e16a5 100644 --- a/tests/int/speechCut.int.spec.ts +++ b/tests/int/speechCut.int.spec.ts @@ -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) => { diff --git a/tests/unit/speechVod.unit.spec.ts b/tests/unit/speechVod.unit.spec.ts index d1e59eaf..fcfb3501 100644 --- a/tests/unit/speechVod.unit.spec.ts +++ b/tests/unit/speechVod.unit.spec.ts @@ -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() + }) +}) diff --git a/tests/unit/youtubeFeed.unit.spec.ts b/tests/unit/youtubeFeed.unit.spec.ts index 4e2451de..1d4a2e34 100644 --- a/tests/unit/youtubeFeed.unit.spec.ts +++ b/tests/unit/youtubeFeed.unit.spec.ts @@ -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/) + }) +})