mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
fix(C172): player e link abrem no ponto certo da fala (Closes #1082)
This commit is contained in:
parent
17ad33f0f8
commit
bf73352846
10 changed files with 582 additions and 9 deletions
1
docs/changelog/2026-09-16-c172.md
Normal file
1
docs/changelog/2026-09-16-c172.md
Normal 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`.
|
||||
155
docs/plans/acervo-video-no-ponto-da-fala-impl.md
Normal file
155
docs/plans/acervo-video-no-ponto-da-fala-impl.md
Normal 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).
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue