refactor(C162): dono único da elegibilidade VOD, start inicial do YouTube e mídia em early-returns (simplify)

This commit is contained in:
Francisco Solla 2026-09-14 06:48:31 -03:00
parent 2ff6107545
commit eabc9dcbd5
8 changed files with 200 additions and 133 deletions

View file

@ -1,6 +1,6 @@
# Impl: Acervo de falas: player resolve o trecho sob demanda e YouTube vira default quando existe
Status: em execução
Status: executado
Atualizado em: 2026-09-14
Issue: #999
Intenção: docs/plans/acervo-vod-sob-demanda.md
@ -156,9 +156,9 @@ Rejeitadas: B porque ressuscita link efêmero (o problema que o item resolve) e
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (quadrantes, `excerptTMs` verbatim, Baixar verificado, estado honesto, crédito CC BY, gate do acervo).
- [ ] Invariantes AGENTS/engineering-standards (dono único, sem twin, sem migration/collection/Consent, sem escrita nova, `overrideAccess: false`, client boundary, zero warnings, dead code).
- [ ] Testes de domínio previstos (unit/resolver/player/int/e2e) onde o access/write path e os contratos de URL mudam.
- [x] Aceite de produto da intenção ainda coberto (quadrantes, `excerptTMs` verbatim, Baixar verificado, estado honesto, crédito CC BY, gate do acervo).
- [x] Invariantes AGENTS/engineering-standards (dono único, sem twin, sem migration/collection/Consent, sem escrita nova, `overrideAccess: false`, client boundary, zero warnings, dead code).
- [x] Testes de domínio previstos (unit/resolver/player/int/e2e) onde o access/write path e os contratos de URL mudam.
## Self-score (decision-quality, gate ≥4)

View file

@ -7,7 +7,7 @@ import {
SPEECH_VOD_NOT_FOUND_MESSAGE,
speechVodRequestSchema,
} from '@/lib/schemas/speechVod'
import type { SpeechVodResolution } from '@/lib/speechVod'
import { speechVodCoordinates, type SpeechVodResolution } from '@/lib/speechVod'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { resolveSpeechVod } from '@/utilities/speech/speechVodResolver'
@ -46,11 +46,8 @@ export const resolveSpeechVodForActor = async (input: {
const speech = result.docs[0]
if (!speech) throw new Error(SPEECH_VOD_NOT_FOUND_MESSAGE)
const hasStoredVod = Boolean(speech.vodPlaybackUrl || speech.vodDownloadUrl)
const { eventId, audioId, excerptTMs } = speech
if (!hasStoredVod || !eventId || !audioId || !excerptTMs) {
throw new Error(SPEECH_VOD_INELIGIBLE_MESSAGE)
}
const coordinates = speechVodCoordinates(speech)
if (!coordinates) throw new Error(SPEECH_VOD_INELIGIBLE_MESSAGE)
return resolveSpeechVod({ eventId, audioId, excerptTms: excerptTMs })
return resolveSpeechVod(coordinates)
}

View file

@ -1,7 +1,7 @@
'use client'
import { DownloadIcon, ExternalLinkIcon, FilmIcon, PlayIcon } from 'lucide-react'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { SpeechVodResolveResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/resolver-vod/types'
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
@ -13,6 +13,15 @@ import { cn } from '@/lib/utils'
import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechViewModels'
const RESOLVE_ENDPOINT = '/campanha/comunicacao/acervo/resolver-vod'
const GENERATING_TITLE = 'A Câmara está gerando o trecho deste vídeo.'
const buildYoutubeSrc = (videoId: string, startSeconds: number | null): string => {
const params = new URLSearchParams({ playsinline: '1', rel: '0' })
if (startSeconds !== null && startSeconds > 0) {
params.set('start', String(Math.floor(startSeconds)))
}
return `https://www.youtube-nocookie.com/embed/${videoId}?${params.toString()}`
}
type ResolutionState =
| { kind: 'idle' }
@ -96,8 +105,8 @@ export const SpeechDetailPlayer = ({
const [resolution, setResolution] = useState<ResolutionState>({ kind: 'idle' })
const [activeStart, setActiveStart] = useState<number | null>(null)
const [youtubeStart, setYoutubeStart] = useState<number | null>(() =>
youtubeVideoId && youtubeOffsetSeconds !== null && initialSeconds !== null
? youtubeOffsetSeconds + initialSeconds
youtubeVideoId && youtubeOffsetSeconds !== null
? youtubeOffsetSeconds + (initialSeconds ?? 0)
: null,
)
@ -177,95 +186,105 @@ export const SpeechDetailPlayer = ({
setActiveStart(active?.startSeconds ?? null)
}
const youtubeSrc = useMemo(() => {
const params = new URLSearchParams({ playsinline: '1', rel: '0' })
if (youtubeStart !== null && youtubeStart > 0)
params.set('start', String(Math.floor(youtubeStart)))
return `https://www.youtube-nocookie.com/embed/${youtubeVideoId}?${params.toString()}`
}, [youtubeVideoId, youtubeStart])
const seekable = youtubeVideoId ? youtubeOffsetSeconds !== null : Boolean(playbackUrl)
const downloadAvailable = vodResolvable
const media = youtubeVideoId ? (
<iframe
src={youtubeSrc}
title="Vídeo da sessão no YouTube"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; compute-pressure"
allowFullScreen
className="aspect-video w-full rounded-lg border bg-black"
/>
) : playbackUrl ? (
<video
ref={videoRef}
controls
preload="metadata"
src={playbackUrl}
onTimeUpdate={onTimeUpdate}
className="aspect-video w-full rounded-lg border bg-black"
>
<track kind="captions" />
</video>
) : resolution.kind === 'resolving' ? (
<StatusPanel
busy
title="Resolvendo o trecho na Câmara…"
detail="Isso pode levar alguns segundos. Se demorar, tente novamente em instantes — a página não trava."
/>
) : resolution.kind === 'generating' ? (
<StatusPanel
title="A Câmara está gerando o trecho deste vídeo."
detail="A geração pode levar alguns instantes. Tente novamente em um momento."
>
<Button
variant="outline"
className="mt-1 min-h-10"
onClick={() => void requestResolution(false)}
>
Tentar novamente
</Button>
</StatusPanel>
) : resolution.kind === 'failed' ||
(resolution.kind === 'resolved' && !resolution.playbackUrl) ? (
<StatusPanel
title="Não foi possível carregar o vídeo deste trecho."
detail={
resolution.kind === 'failed' && resolution.message
? resolution.message
: 'A Câmara não entregou o arquivo agora. A transcrição e a fonte oficial continuam disponíveis.'
}
>
<Button
variant="outline"
className="mt-1 min-h-10"
onClick={() => void requestResolution(false)}
>
Tentar novamente
</Button>
</StatusPanel>
) : vodResolvable ? (
<StatusPanel
title="O trecho deste vídeo é gerado pela Câmara dos Deputados."
detail="Clique para resolver o arquivo exato desta fala — ele é verificado antes de tocar."
>
<Button className="mt-1 min-h-10" onClick={() => void requestResolution(false)}>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
Assistir o trecho
</Button>
</StatusPanel>
) : (
<StatusPanel
title="Vídeo indisponível neste momento."
detail="Esta fala não tem vídeo no YouTube nem trecho gerado pela Câmara disponível agora."
/>
)
const renderMedia = (): ReactNode => {
if (youtubeVideoId) {
return (
<iframe
src={buildYoutubeSrc(youtubeVideoId, youtubeStart)}
title="Vídeo da sessão no YouTube"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; compute-pressure"
allowFullScreen
className="aspect-video w-full rounded-lg border bg-black"
/>
)
}
if (playbackUrl) {
return (
<video
ref={videoRef}
controls
preload="metadata"
src={playbackUrl}
onTimeUpdate={onTimeUpdate}
className="aspect-video w-full rounded-lg border bg-black"
>
<track kind="captions" />
</video>
)
}
if (resolution.kind === 'resolving') {
return (
<StatusPanel
busy
title="Resolvendo o trecho na Câmara…"
detail="Isso pode levar alguns segundos. Se demorar, tente novamente em instantes — a página não trava."
/>
)
}
if (resolution.kind === 'generating') {
return (
<StatusPanel
title={GENERATING_TITLE}
detail="A geração pode levar alguns instantes. Tente novamente em um momento."
>
<Button
variant="outline"
className="mt-1 min-h-10"
onClick={() => void requestResolution(false)}
>
Tentar novamente
</Button>
</StatusPanel>
)
}
if (resolution.kind === 'failed' || resolution.kind === 'resolved') {
return (
<StatusPanel
title="Não foi possível carregar o vídeo deste trecho."
detail={
resolution.kind === 'failed' && resolution.message
? resolution.message
: 'A Câmara não entregou o arquivo agora. A transcrição e a fonte oficial continuam disponíveis.'
}
>
<Button
variant="outline"
className="mt-1 min-h-10"
onClick={() => void requestResolution(false)}
>
Tentar novamente
</Button>
</StatusPanel>
)
}
if (vodResolvable) {
return (
<StatusPanel
title="O trecho deste vídeo é gerado pela Câmara dos Deputados."
detail="Clique para resolver o arquivo exato desta fala — ele é verificado antes de tocar."
>
<Button className="mt-1 min-h-10" onClick={() => void requestResolution(false)}>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
Assistir o trecho
</Button>
</StatusPanel>
)
}
return (
<StatusPanel
title="Vídeo indisponível neste momento."
detail="Esta fala não tem vídeo no YouTube nem trecho gerado pela Câmara disponível agora."
/>
)
}
const inlineNotice = youtubeVideoId ? (
resolution.kind === 'generating' ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
A Câmara está gerando o trecho deste vídeo. Tente novamente em um momento.
{GENERATING_TITLE} Tente novamente em um momento.
</p>
) : resolution.kind === 'failed' ? (
<p className="text-xs text-destructive" role="status" aria-live="polite">
@ -281,10 +300,10 @@ export const SpeechDetailPlayer = ({
return (
<div data-slot="speech-player" aria-busy={resolving || undefined}>
{media}
{renderMedia()}
<div className="mt-3 flex flex-wrap items-center gap-2">
{downloadAvailable ? (
{vodResolvable ? (
<Button
className="min-h-10"
disabled={resolving}

View file

@ -40,6 +40,30 @@ export type SpeechVodResolution =
| { state: 'gerando' }
| { state: 'indisponivel' }
export type SpeechVodCoordinatesSource = {
vodPlaybackUrl?: string | null
vodDownloadUrl?: string | null
eventId?: number | null
audioId?: number | null
excerptTMs?: number | null
}
/**
* The Câmara coordinates of a speech whose excerpt can be re-resolved: a
* stored VOD link (eligibility signal only — it is a cache and never a URL
* handed to the browser) plus `eventId`/`audioId`/`excerptTMs`. Null means the
* player must not offer the resolution; the view model and the action share
* this one decision.
*/
export const speechVodCoordinates = (
speech: SpeechVodCoordinatesSource,
): { eventId: number; audioId: number; excerptTms: number } | null => {
const hasStoredVod = Boolean(speech.vodPlaybackUrl || speech.vodDownloadUrl)
const { eventId, audioId, excerptTMs } = speech
if (!hasStoredVod || !eventId || !audioId || !excerptTMs) return null
return { eventId, audioId, excerptTms: excerptTMs }
}
/**
* Normalizes the `video-sob-demanda` JSON (`{ estado, video }`). `state` is
* `GERANDO` while the server transcodes, `PRONTO` when the MP4 exists,
@ -149,21 +173,24 @@ const brtWallClock = new Intl.DateTimeFormat('en-CA', {
hour12: false,
})
/** BRT wall-clock of an epoch instant, as seconds since epoch-treated-as-UTC. */
const wallClockSeconds = (epochMs: number): number | null => {
/** JavaScript Date range: ±100,000,000 days from the epoch. */
const MAX_DATE_MS = 8.64e15
/** BRT wall-clock of a valid epoch instant, as seconds since epoch-as-UTC. */
const wallClockSeconds = (epochMs: number): number => {
const parts = brtWallClock.formatToParts(new Date(epochMs))
const get = (type: Intl.DateTimeFormatPartTypes): number =>
Number(parts.find((part) => part.type === type)?.value)
const [year, month, day, hour, minute, second] = [
get('year'),
get('month'),
get('day'),
get('hour'),
get('minute'),
get('second'),
]
if (![year, month, day, hour, minute, second].every(Number.isFinite)) return null
return Date.UTC(year, month - 1, day, hour % 24, minute, second) / 1000
return (
Date.UTC(
get('year'),
get('month') - 1,
get('day'),
get('hour') % 24,
get('minute'),
get('second'),
) / 1000
)
}
/**
@ -174,7 +201,7 @@ const wallClockSeconds = (epochMs: number): number | null => {
*/
export const excerptOffsetSeconds = (excerptTMs: unknown, eventStartAt: unknown): number | null => {
const epochMs = Number(excerptTMs)
if (!Number.isFinite(epochMs) || epochMs <= 0) return null
if (!Number.isFinite(epochMs) || epochMs <= 0 || epochMs > MAX_DATE_MS) return null
const match = NAIVE_DATETIME.exec(String(eventStartAt ?? '').trim())
if (!match) return null
@ -183,11 +210,16 @@ export const excerptOffsetSeconds = (excerptTMs: unknown, eventStartAt: unknown)
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) {
return null
}
const startSeconds = Date.UTC(y, mo - 1, d, h, mi, s) / 1000
const startMs = Date.UTC(y, mo - 1, d, h, mi, s)
const startDate = new Date(startMs)
if (
startDate.getUTCFullYear() !== y ||
startDate.getUTCMonth() !== mo - 1 ||
startDate.getUTCDate() !== d
) {
return null
}
const excerptSeconds = wallClockSeconds(epochMs)
if (excerptSeconds === null) return null
const offset = Math.floor(excerptSeconds - startSeconds)
const offset = Math.floor(wallClockSeconds(epochMs) - startMs / 1000)
return offset >= 0 ? offset : null
}

View file

@ -15,7 +15,7 @@ import {
type SpeechHighlightPart,
} from '@/lib/speechHighlight'
import { normalizeForSearch } from '@/lib/speechSearch'
import { excerptOffsetSeconds, parseYoutubeVideoId } from '@/lib/speechVod'
import { excerptOffsetSeconds, parseYoutubeVideoId, speechVodCoordinates } from '@/lib/speechVod'
import type { Municipality } from '@/payload-types'
import { speechScopeLabels, speechTopicLabels } from '@/utilities/speech/speechListUrl'
@ -245,12 +245,7 @@ export const toSpeechDetailViewModel = ({
}): 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 = Boolean(
(speech.vodPlaybackUrl || speech.vodDownloadUrl) &&
speech.eventId &&
speech.audioId &&
speech.excerptTMs,
)
const vodResolvable = speechVodCoordinates(speech) !== null
return {
id: speech.id,

View file

@ -225,7 +225,7 @@ describe('speech acervo (C154)', () => {
const row = data.rows.find((item) => item.id === id)
expect(row).toBeDefined()
expect('downloadUrl' in (row as object)).toBe(false)
expect(row).not.toHaveProperty('downloadUrl')
})
it('allows communicator/coordinator/candidate and denies advisor/leader', async () => {

View file

@ -95,6 +95,8 @@ describe('SpeechDetailPlayer — VOD-only quadrant', () => {
expect(screen.getByText(/O trecho deste vídeo é gerado pela Câmara/)).toBeDefined()
expect(videoElement()).toBeNull()
expect(iframeElement()).toBeNull()
// The invariant: rendering the detail never calls the Câmara.
expect(fetchMock).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: /assistir o trecho/i }))
@ -178,6 +180,18 @@ describe('SpeechDetailPlayer — YouTube quadrant', () => {
})
})
it('embeds the session at the excerpt offset even without a ?t deep link', () => {
renderPlayer({
youtubeVideoId: 'lLhRDkSPw0A',
youtubeOffsetSeconds: 2634,
initialSeconds: null,
})
expect(iframeElement()?.getAttribute('src')).toBe(
'https://www.youtube-nocookie.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634',
)
})
it('leaves the transcript inert when the session offset is unknown', () => {
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: null })

View file

@ -127,20 +127,30 @@ describe('resolveSpeechVod', () => {
expect(calls).toHaveLength(1)
vi.unstubAllGlobals()
stubFetch(async () => jsonResponse({ estado: 'INDISPONIVEL', video: null }))
const unavailableCalls = stubFetch(async () =>
jsonResponse({ estado: 'INDISPONIVEL', video: null }),
)
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).resolves.toEqual({
state: 'indisponivel',
})
expect(unavailableCalls).toHaveLength(1)
})
it('throws after the bounded retries when the Câmara is unreachable', async () => {
const calls = stubFetch(async () => {
throw new Error('connect ETIMEDOUT')
})
vi.useFakeTimers()
try {
const calls = stubFetch(async () => {
throw new Error('connect ETIMEDOUT')
})
await expect(resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 })).rejects.toThrow(
'connect ETIMEDOUT',
)
expect(calls).toHaveLength(2)
const assertion = expect(
resolveSpeechVod({ eventId: 1, audioId: 2, excerptTms: 3 }),
).rejects.toThrow('connect ETIMEDOUT')
await vi.advanceTimersByTimeAsync(1_100)
await assertion
expect(calls).toHaveLength(2)
} finally {
vi.useRealTimers()
}
})
})