teqo-backup/src/lib/speechVod.ts

328 lines
13 KiB
TypeScript

/**
* Pure VOD rules shared by the Câmara speech scripts (C152 pilot/C153 import)
* and the acervo player (C162): the `video-sob-demanda` URL/status contract,
* the crawl UA, the session-offset math and the YouTube video id.
*
* No I/O here — the scripts and the server resolver own the fetch/timeout
* policy, so the fragile rules stay unit-testable without network.
*/
/** Browser-like UA — the trecho/evento pages answer 400 "Acesso via bot" to curl. */
export const CAMARA_USER_AGENT =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
/** The async VOD-generation status URL for one excerpt. */
export const buildVodUrl = (eventId: number, audioId: number, tMs: number): string =>
`https://www.camara.leg.br/evento-legislativo/${eventId}/video-sob-demanda?idAudio=${audioId}&trecho=${tMs}`
type CamaraVodVideo = {
title: string | null
subtitle: string | null
duration: string | null
clock: string | null
downloadUrl: string | null
playbackUrl: string | null
}
export type CamaraVodStatus = {
state: string
video: CamaraVodVideo | null
}
/**
* What the acervo player can do after asking the Câmara about one excerpt:
* `pronto` carries only URLs that passed the media probe (either can still be
* null when its own probe failed), `gerando` means the server is transcoding,
* `indisponivel` means there is nothing usable.
*/
export type SpeechVodResolution =
| { state: 'pronto'; playbackUrl: string | null; downloadUrl: string | null }
| { 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 the excerpt, with no eligibility judgement: the
* speech needs `eventId`/`audioId`/`excerptTMs` and nothing else. Null when any
* of the three is missing; the two callers below decide what else to require.
*/
export const speechExcerptCoordinates = (
speech: SpeechVodCoordinatesSource,
): { eventId: number; audioId: number; excerptTms: number } | null => {
const { eventId, audioId, excerptTMs } = speech
if (!eventId || !audioId || !excerptTMs) return null
return { eventId, audioId, excerptTms: excerptTMs }
}
/**
* The Câmara coordinates of a speech whose excerpt the player can re-resolve: a
* stored VOD link (eligibility signal only — it is a cache and never a URL
* handed to the browser) plus the excerpt coordinates. Null means the player
* must not offer the resolution; the view model and the action share this one
* decision. The acervo frame (C182) does not need the stored link — it reads
* `speechExcerptCoordinates` directly.
*/
export const speechVodCoordinates = (
speech: SpeechVodCoordinatesSource,
): { eventId: number; audioId: number; excerptTms: number } | null => {
const hasStoredVod = Boolean(speech.vodPlaybackUrl || speech.vodDownloadUrl)
return hasStoredVod ? speechExcerptCoordinates(speech) : null
}
/**
* Normalizes the `video-sob-demanda` JSON (`{ estado, video }`). `state` is
* `GERANDO` while the server transcodes, `PRONTO` when the MP4 exists,
* `INDISPONIVEL` when there is none.
*/
export const parseVodStatus = (json: unknown): CamaraVodStatus => {
const data = json && typeof json === 'object' ? (json as Record<string, unknown>) : {}
const estado = typeof data.estado === 'string' ? data.estado : 'DESCONHECIDO'
const raw =
data.video && typeof data.video === 'object' ? (data.video as Record<string, unknown>) : null
const pick = (key: string): string | null =>
typeof raw?.[key] === 'string' ? (raw[key] as string) : null
return {
state: estado,
video: raw
? {
title: pick('titulo'),
subtitle: pick('subtitulo'),
duration: pick('duracao'),
clock: pick('horario'),
downloadUrl: pick('linkParaDownload'),
playbackUrl: pick('linkParaReproducao'),
}
: null,
}
}
/**
* Parses the Câmara duration spellings into seconds: `0h04'03"` (excerpt card)
* and `0:04:07` / `4:07` (VOD status). Null when unparseable.
*/
export const parseDurationToSeconds = (value: unknown): number | null => {
const raw = String(value ?? '').trim()
if (raw === '') return null
const card = /^(\d+)h(\d{1,2})'(\d{1,2})"?$/.exec(raw)
if (card) {
const minutes = Number(card[2])
const seconds = Number(card[3])
if (minutes > 59 || seconds > 59) return null
return Number(card[1]) * 3600 + minutes * 60 + seconds
}
const parts = raw.split(':')
if (!parts.every((part) => /^\d{1,2}$/.test(part))) return null
if (parts.length === 3) {
const [hours, minutes, seconds] = parts.map(Number)
if (minutes > 59 || seconds > 59) return null
return hours * 3600 + minutes * 60 + seconds
}
if (parts.length === 2) {
const [minutes, seconds] = parts.map(Number)
if (seconds > 59) return null
return minutes * 60 + seconds
}
return null
}
const YOUTUBE_VIDEO_ID = /^[A-Za-z0-9_-]{6,20}$/
const YOUTUBE_HOSTS = new Set(['youtube.com', 'youtube-nocookie.com', 'youtu.be'])
const YOUTUBE_ID_PATH_PREFIXES = new Set(['embed', 'live', 'shorts', 'v'])
/**
* YouTube video id of an `urlRegistro` link, or null when the URL is not a
* recognized YouTube form (`watch?v=`, `youtu.be/<id>`, `embed`/`live`/
* `shorts`/`v`) or the id does not look like one. Defensive on purpose: the
* open-data field varies, and a null simply falls back to the VOD path.
*/
export const parseYoutubeVideoId = (raw: unknown): string | null => {
const value = String(raw ?? '').trim()
if (value === '') return null
let url: URL
try {
url = new URL(value)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
const host = url.hostname.toLowerCase().replace(/^(www|m|music)\./, '')
if (!YOUTUBE_HOSTS.has(host)) return null
const segments = url.pathname.split('/').filter(Boolean)
let candidate: string | null = null
if (host === 'youtu.be') {
candidate = segments[0] ?? null
} else if (segments[0] === 'watch') {
candidate = url.searchParams.get('v')
} else if (segments[0] && YOUTUBE_ID_PATH_PREFIXES.has(segments[0])) {
candidate = segments[1] ?? null
}
return candidate && YOUTUBE_VIDEO_ID.test(candidate) ? candidate : null
}
/**
* The YouTube default cover (C175) of an already-parsed video id, or null when
* there is none. `hqdefault` is the variant the whole repo uses; `object-cover`
* crops the 4:3 letterbox bars the YouTube player frame adds to 16:9 video.
*/
export const youtubeThumbnailUrl = (videoId: string | null): string | null =>
videoId ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : null
/**
* The session cover of a raw link: composition of the two rules above, so the
* list view model (C182 fallback) and the poster route build it the same way.
*/
export const speechCoverUrl = (youtubeUrl: unknown): string | null =>
youtubeThumbnailUrl(parseYoutubeVideoId(youtubeUrl))
const SAO_PAULO_TIME_ZONE = 'America/Sao_Paulo'
const NAIVE_DATETIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?/
const brtWallClock = new Intl.DateTimeFormat('en-CA', {
timeZone: SAO_PAULO_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
/** 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)
return (
Date.UTC(
get('year'),
get('month') - 1,
get('day'),
get('hour') % 24,
get('minute'),
get('second'),
) / 1000
)
}
/** BRT wall-clock seconds-since-epoch of a naive Câmara datetime, or null. */
const naiveWallClockSeconds = (value: unknown): number | null => {
const match = NAIVE_DATETIME.exec(String(value ?? '').trim())
if (!match) return null
const [, year, month, day, hour, minute, second = '0'] = match
const [y, mo, d, h, mi, s] = [year, month, day, hour, minute, second].map(Number)
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) {
return null
}
const ms = Date.UTC(y, mo - 1, d, h, mi, s)
const date = new Date(ms)
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== mo - 1 || date.getUTCDate() !== d) {
return null
}
return ms / 1000
}
/**
* Seconds from the session start (`eventStartAt`, naive Câmara wall clock) to
* the excerpt (`excerptTMs`, epoch ms). Both sides are read as BRT wall clock,
* so a session crossing midnight still measures the right distance. Null when
* either side is unparseable or the excerpt precedes the session start.
*/
export const excerptOffsetSeconds = (excerptTMs: unknown, eventStartAt: unknown): number | null => {
const epochMs = Number(excerptTMs)
if (!Number.isFinite(epochMs) || epochMs <= 0 || epochMs > MAX_DATE_MS) return null
const startSeconds = naiveWallClockSeconds(eventStartAt)
if (startSeconds === null) return null
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)
}