refactor(C179): helper único da URL do thumbnail do YouTube (Closes #1093)

This commit is contained in:
Francisco Solla 2026-09-17 01:07:38 -03:00
parent bdd2b0dbf0
commit f70c5e2d0b
6 changed files with 61 additions and 9 deletions

View file

@ -0,0 +1 @@
**C179 — Helper único da URL do thumbnail do YouTube (2026-09-17):** o literal `https://i.ytimg.com/vi/<id>/hqdefault.jpg` estava repetido em 4 módulos (`SpeechDetailPlayer.tsx`, `SpeechCutPlayer.tsx`, `SpeechCutResultCard.tsx` e `src/app/(frontend)/corte/[id]/page.tsx`), o mesmo conhecimento (host + formato + sufixo) copiado 4× com risco de drift silencioso. O plano de intenção manda extrair o helper em `src/lib/speechShare.ts`, mas `src/lib/speechVod.ts` já é o dono do par id→cover: o C175 (#1085, `2026-09-16 12:36`) criou ali `youtubeThumbnailUrl` e o C182 (#1104) compôs `speechCoverUrl(youtubeUrl)` (parse + cover) por cima. Criar uma segunda implementação seria o twin que a própria Issue quer eliminar, então o delivery reusa os donos existentes ("edit the owner, don't twin"): os três consumidores que têm o video id passam a chamar `youtubeThumbnailUrl`, e a página do corte — que só tem a URL crua — passa a chamar `speechCoverUrl`, o mesmo helper do view model da listagem e da rota de poster. Nenhuma mudança de código em `speechVod.ts` (helper e unit do C175 ficam como estão). Sem UI nova, migration, Consent, URL pública ou escrita — a crítica visual do `designer` não se aplica (port mecânico de literal, non-trigger do §Design). Pins intocados: os testes que assertam a URL final (`speechVod.unit.spec.ts`, `speechViewModels.unit.spec.ts`, `speechAcervo.int.spec.ts`, e2e de acervo/corte) passam verbatim. `pnpm gate:fast` verde (334 arquivos / 3476 testes).

View file

@ -0,0 +1,52 @@
# Impl: Helper único da URL do thumbnail do YouTube (hqdefault)
Status: aprovado (modo `--auto`)
Atualizado em: 2026-09-17
Issue: #1093
Intenção: docs/plans/escala-dry-pos-speech-thumbnail.md
Appetite restante: herdado (~1 h eng)
## Leitura da intenção
- **Outcome:** eliminar a duplicação do literal `https://i.ytimg.com/vi/<id>/hqdefault.jpg` nos 4 módulos, consumindo um helper único.
- **O que NÃO negociar:** manter o comportamento atual (host/formato/sufixo); sem mudança visual.
- **O que reavaliar:** o plano de intenção nomeia `src/lib/speechShare.ts` como dono. **Esse pressuposto ficou stale**: o C175 (#1085, 2026-09-16 12:36) já criou `youtubeThumbnailUrl` em `src/lib/speechVod.ts` — o módulo que já expõe `parseYoutubeVideoId`, logo o dono natural do par id→cover.
## Abordagem recomendada
- **A (Recomendada, adotada):** reusar o `youtubeThumbnailUrl` existente em `src/lib/speechVod.ts` e consumi-lo nos 4 pontos duplicados.
- **B (Rejeitada):** criar `youtubeThumbnailUrl` também em `src/lib/speechShare.ts` (como escrito no plano de intenção). Rejeitada por AGENTS/engineering-standards — "edit the owner, don't twin": seria uma segunda implementação idêntica do mesmo conhecimento, exatamente o drift que a Issue quer eliminar.
- **C (Rejeitada):** criar um arquivo novo `src/lib/youtube-helpers.ts`. Rejeitada pelo mesmo motivo (twin de `speechVod.ts`).
- **D (Rejeitada):** manter a duplicação e documentar. Rejeitada: risco de drift silencioso (host/CSP).
### Componentes / mudanças
- `src/lib/speechVod.ts`: **nenhuma mudança de código** — `youtubeThumbnailUrl(videoId)` (C175) e `speechCoverUrl(youtubeUrl)` (C182, composição id→cover) já existem e são os donos únicos.
- `SpeechDetailPlayer.tsx`, `SpeechCutPlayer.tsx`, `SpeechCutResultCard.tsx` (têm o video id): consumir `youtubeThumbnailUrl`.
- `src/app/(frontend)/corte/[id]/page.tsx` (tem a URL crua): consumir `speechCoverUrl` — o mesmo helper que o view model da listagem e a rota de poster do C182 já usam.
- **Migration:** nenhuma (código puro).
- **Access / Consent:** não aplicável.
- **UI:** Impeccable A — sem superfície visual nova; crítica do `designer` (trigger c) não se aplica (non-trigger: port mecânico do literal).
## Fases verificáveis
1. **Consumir** — substituir os 4 literais por `youtubeThumbnailUrl(...)`.
2. **Gates** — `pnpm gate:fast`; entrega via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Refatorar o markup duplicado do bloco de saída (`renderYoutubeExit`, 2 ramos) — defer até 3ª variante/superfície.
- Unificar imagem de capa/posters além do helper.
- Migrar o host do thumbnail ou adicionar fallback de imagem.
- Criar um `youtubeThumbnailUrlFromUrl` novo: o C182 já resolveu essa composição com `speechCoverUrl` (2 call sites + o corte/[id]); reusar, não duplicar.
## Riscos e mitigação
- **Risco:** conflito de merge com C172. **Status:** C172 já está em `main` (merge 4b3bee7c); risco consumido.
- **Risco:** quebra de pins que assertam a URL. **Mitigação:** verificado — os pins existentes (`speechVod.unit.spec.ts`, `speechViewModels.unit.spec.ts`, `speechAcervo.int.spec.ts`, e2e de acervo/corte) assertam o literal final, que não muda.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (literal único nos 4 pontos)
- [x] Invariantes AGENTS/engineering-standards (dono único; sem twin)
- [x] Testes de domínio: helper puro já coberto no unit do C175; consumidores cobertos por int/e2e da mesma superfície

View file

@ -7,7 +7,7 @@ import {
} from '@/components/SpeechCutShareActions'
import { formatSpeechClock, formatSpeechDate } from '@/lib/speechClock'
import { speechCutPublicPath } from '@/lib/speechCut'
import { parseYoutubeVideoId } from '@/lib/speechVod'
import { speechCoverUrl } from '@/lib/speechVod'
import type { Media, Speech, SpeechCut } from '@/payload-types'
import { getCachedDocumentById } from '@/utilities/documentReads'
import { getCachedGlobal } from '@/utilities/globalReads'
@ -41,8 +41,7 @@ const speechOf = (cut: SpeechCut): Speech | null =>
const youtubeCoverUrl = (cut: SpeechCut): string | null => {
const speech = speechOf(cut)
const videoId = parseYoutubeVideoId(speech?.youtubeUrl)
return videoId ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : null
return speechCoverUrl(speech?.youtubeUrl)
}
const loadPublishedCut = async (id: string): Promise<SpeechCut | null> => {

View file

@ -1,4 +1,5 @@
import type { SpeechCutLibraryItemViewModel } from '@/lib/speechCut'
import { youtubeThumbnailUrl } from '@/lib/speechVod'
/**
* C168 — the cut is a plain MP4 (never the YouTube/VOD source): the same
@ -13,9 +14,7 @@ export const SpeechCutPlayer = ({ cut }: { cut: SpeechCutLibraryItemViewModel })
)
}
const poster = cut.youtubeVideoId
? `https://i.ytimg.com/vi/${cut.youtubeVideoId}/hqdefault.jpg`
: undefined
const poster = youtubeThumbnailUrl(cut.youtubeVideoId) ?? undefined
return (
<video

View file

@ -6,6 +6,7 @@ import { useEffect, useState } from 'react'
import { SpeechCutShareActions } from '@/components/SpeechCutShareActions'
import type { SpeechCutViewModel } from '@/lib/speechCut'
import { youtubeThumbnailUrl } from '@/lib/speechVod'
/**
* C167 — the acervo result of a published cut: the public link with the share
@ -67,7 +68,7 @@ export const SpeechCutResultCard = ({ cut }: { cut: SpeechCutViewModel }) => {
{cut.youtubeVideoId ? (
// eslint-disable-next-line @next/next/no-img-element -- YouTube cover is an external OG image.
<img
src={`https://i.ytimg.com/vi/${cut.youtubeVideoId}/hqdefault.jpg`}
src={youtubeThumbnailUrl(cut.youtubeVideoId) ?? undefined}
alt=""
className="h-20 w-32 rounded-lg border object-cover"
/>

View file

@ -33,7 +33,7 @@ import {
type ExcerptRange,
} from '@/lib/speechExcerptSelection'
import { buildSpeechExcerptYoutubeUrl } from '@/lib/speechShare'
import type { SpeechVodResolution } from '@/lib/speechVod'
import { youtubeThumbnailUrl, type SpeechVodResolution } from '@/lib/speechVod'
import { cn } from '@/lib/utils'
import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechViewModels'
@ -143,7 +143,7 @@ const YoutubeFacade = ({ videoId, href }: { videoId: string; href: string }) =>
<span
aria-hidden="true"
className="absolute inset-0 bg-cover bg-center opacity-60"
style={{ backgroundImage: `url(https://i.ytimg.com/vi/${videoId}/hqdefault.jpg)` }}
style={{ backgroundImage: `url(${youtubeThumbnailUrl(videoId)})` }}
/>
<span
aria-hidden="true"