mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
import 'server-only'
|
|
|
|
import { execFile } from 'node:child_process'
|
|
import { createWriteStream } from 'node:fs'
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { Readable, Transform } from 'node:stream'
|
|
import { pipeline } from 'node:stream/promises'
|
|
import type { Payload } from 'payload'
|
|
|
|
import { SPEECH_VOD_INELIGIBLE_MESSAGE } from '@/lib/schemas/speechVod'
|
|
import { formatSpeechDate } from '@/lib/speechClock'
|
|
import {
|
|
buildSpeechCutFallbackMetadata,
|
|
buildSpeechCutFfmpegArgs,
|
|
SPEECH_CUT_FAILURE_GENERATING,
|
|
SPEECH_CUT_FAILURE_INTERRUPTED,
|
|
SPEECH_CUT_FAILURE_SPEECH_GONE,
|
|
SPEECH_CUT_FAILURE_UNAVAILABLE,
|
|
SPEECH_CUT_FAILURE_UNPLAYABLE,
|
|
type SpeechCutStep,
|
|
} from '@/lib/speechCut'
|
|
import { CAMARA_USER_AGENT, speechVodCoordinates } from '@/lib/speechVod'
|
|
import type { SpeechCut } from '@/payload-types'
|
|
import {
|
|
withPayloadTransaction,
|
|
type PayloadTransactionRequest,
|
|
} from '@/utilities/payloadTransaction'
|
|
import { resolveSpeechVod, SPEECH_VOD_CUT_POLICY } from '@/utilities/speech/speechVodResolver'
|
|
|
|
/**
|
|
* C167 — the cut job: resolve the Câmara VOD, cut the exact [start, end] with
|
|
* ffmpeg, store the MP4 as `media` and publish the `speechCut` row. Runs after
|
|
* the create response (`speechCutScheduler`) so no request waits on ffmpeg;
|
|
* every failure leaves the row `failed` and nothing published.
|
|
*/
|
|
|
|
/** `FFMPEG_PATH` lets a test/runtime point at a fake binary; the image ships `ffmpeg`. */
|
|
const ffmpegBinary = (): string => process.env.FFMPEG_PATH?.trim() || 'ffmpeg'
|
|
|
|
/** A cut stopped mid-flight (deploy/restart) is reaped to `failed` after this. */
|
|
export const SPEECH_CUT_STALE_MS = 15 * 60_000
|
|
|
|
/** Defensive ceiling for the source download (the VOD of a speech is minutes long). */
|
|
const MAX_SOURCE_BYTES = 2 * 1024 * 1024 * 1024
|
|
|
|
const SOURCE_DOWNLOAD_TIMEOUT_MS = 180_000
|
|
const FFMPEG_MIN_TIMEOUT_MS = 30_000
|
|
const FFMPEG_MAX_TIMEOUT_MS = 300_000
|
|
const FFMPEG_MAX_BUFFER_BYTES = 8 * 1024 * 1024
|
|
|
|
const messageOf = (error: unknown): string =>
|
|
error instanceof Error && error.message !== '' ? error.message : 'Falha ao processar o corte.'
|
|
|
|
// The job runs after the create response, with no request actor: every write
|
|
// below is an intentional admin bypass, justified because the row was created
|
|
// behind the acervo gate and the pipeline owns its state. Keeping the writes in
|
|
// these helpers keeps the bypass deliberate (and documented for the guard).
|
|
|
|
const loadCutSystem = (payload: Payload, cutId: number) =>
|
|
payload.findByID({
|
|
collection: 'speechCut',
|
|
id: cutId,
|
|
depth: 1,
|
|
// Intentional admin bypass: the job reads the row it owns.
|
|
overrideAccess: true,
|
|
})
|
|
|
|
const updateCutSystem = (
|
|
payload: Payload,
|
|
cutId: number,
|
|
data: Partial<SpeechCut>,
|
|
req?: PayloadTransactionRequest,
|
|
) =>
|
|
payload.update({
|
|
collection: 'speechCut',
|
|
id: cutId,
|
|
data,
|
|
// Intentional admin bypass: the pipeline owns the row state.
|
|
overrideAccess: true,
|
|
...(req ? { req } : {}),
|
|
})
|
|
|
|
const createMediaSystem = (
|
|
payload: Payload,
|
|
alt: string,
|
|
filePath: string,
|
|
req?: PayloadTransactionRequest,
|
|
) =>
|
|
payload.create({
|
|
collection: 'media',
|
|
data: { alt },
|
|
filePath,
|
|
// Intentional admin bypass: the stored file belongs to the cut row.
|
|
overrideAccess: true,
|
|
...(req ? { req } : {}),
|
|
})
|
|
|
|
/**
|
|
* Fails the row and keeps the step it died on (the first stage is `resolving`
|
|
* by default): the dialog maps the step and the known literals into the honest
|
|
* message, while `error` keeps the raw detail for the admin.
|
|
*/
|
|
const failCut = (
|
|
payload: Payload,
|
|
cutId: number,
|
|
message: string,
|
|
step: SpeechCutStep | null = 'resolving',
|
|
): Promise<unknown> => updateCutSystem(payload, cutId, { status: 'failed', step, error: message })
|
|
|
|
const updateStep = (payload: Payload, cutId: number, step: SpeechCutStep): Promise<unknown> =>
|
|
updateCutSystem(payload, cutId, { step })
|
|
|
|
/**
|
|
* Streams the resolved VOD to a temp file. A `text/html` body is the CDN's
|
|
* error page wearing a 200 (same rule as the C162 probe) and is refused.
|
|
*/
|
|
const downloadSource = async (url: string, destination: string): Promise<void> => {
|
|
const response = await fetch(url, {
|
|
headers: { 'User-Agent': CAMARA_USER_AGENT },
|
|
signal: AbortSignal.timeout(SOURCE_DOWNLOAD_TIMEOUT_MS),
|
|
})
|
|
const contentType = response.headers.get('content-type') ?? ''
|
|
if (!response.ok || contentType.includes('text/html')) {
|
|
throw new Error(`A Câmara não entregou o arquivo do trecho (HTTP ${response.status}).`)
|
|
}
|
|
const length = Number(response.headers.get('content-length'))
|
|
if (Number.isFinite(length) && length > MAX_SOURCE_BYTES) {
|
|
throw new Error('O arquivo do trecho na Câmara é grande demais para cortar.')
|
|
}
|
|
if (!response.body) throw new Error('A Câmara não entregou o corpo do arquivo do trecho.')
|
|
|
|
// The counter enforces the ceiling even when the CDN answers chunked (no
|
|
// content-length): the stream aborts instead of filling the disk.
|
|
let received = 0
|
|
const guard = new Transform({
|
|
transform(chunk: Buffer, _encoding, callback) {
|
|
received += chunk.length
|
|
if (received > MAX_SOURCE_BYTES) {
|
|
callback(new Error('O arquivo do trecho na Câmara é grande demais para cortar.'))
|
|
return
|
|
}
|
|
callback(null, chunk)
|
|
},
|
|
})
|
|
|
|
await pipeline(
|
|
Readable.fromWeb(response.body as unknown as import('node:stream/web').ReadableStream),
|
|
guard,
|
|
createWriteStream(destination),
|
|
)
|
|
}
|
|
|
|
const runFfmpeg = (args: string[], durationSeconds: number): Promise<void> =>
|
|
new Promise((resolve, reject) => {
|
|
const timeout = Math.min(
|
|
FFMPEG_MAX_TIMEOUT_MS,
|
|
Math.max(FFMPEG_MIN_TIMEOUT_MS, durationSeconds * 8_000),
|
|
)
|
|
execFile(
|
|
ffmpegBinary(),
|
|
args,
|
|
{ timeout, maxBuffer: FFMPEG_MAX_BUFFER_BYTES },
|
|
(error, _stdout, stderr) => {
|
|
if (!error) {
|
|
resolve()
|
|
return
|
|
}
|
|
const detail = String(stderr ?? '')
|
|
.trim()
|
|
.slice(-400)
|
|
reject(new Error(`${messageOf(error)}${detail ? ` — ${detail}` : ''}`))
|
|
},
|
|
)
|
|
})
|
|
|
|
/**
|
|
* The whole pipeline of one cut. Never throws: an orphan job (server restart)
|
|
* is covered by the lazy reaper, and any error marks the row `failed` so the
|
|
* retry reuses it.
|
|
*/
|
|
export const runSpeechCutJob = async (payload: Payload, cutId: number): Promise<void> => {
|
|
let tempDir: string | null = null
|
|
// The step the row would be showing if the job died right now: the catch
|
|
// records it so the failure message separates cortar from guardar.
|
|
let currentStep: SpeechCutStep = 'resolving'
|
|
const markStep = async (step: SpeechCutStep): Promise<void> => {
|
|
currentStep = step
|
|
await updateStep(payload, cutId, step)
|
|
}
|
|
|
|
try {
|
|
const cut = await loadCutSystem(payload, cutId)
|
|
if (cut.status !== 'processing') return
|
|
|
|
await markStep('resolving')
|
|
const speech = typeof cut.speech === 'object' && cut.speech !== null ? cut.speech : null
|
|
if (!speech) {
|
|
await failCut(payload, cutId, SPEECH_CUT_FAILURE_SPEECH_GONE)
|
|
return
|
|
}
|
|
const coordinates = speechVodCoordinates(speech)
|
|
if (!coordinates) {
|
|
await failCut(payload, cutId, SPEECH_VOD_INELIGIBLE_MESSAGE)
|
|
return
|
|
}
|
|
|
|
const resolution = await resolveSpeechVod(coordinates, {
|
|
policy: SPEECH_VOD_CUT_POLICY,
|
|
// The stored links are the Câmara's own cache: tried only when the API
|
|
// left no verified URL, and probed exactly like the API links.
|
|
cachedUrls: { playbackUrl: speech.vodPlaybackUrl, downloadUrl: speech.vodDownloadUrl },
|
|
})
|
|
if (resolution.state === 'gerando') {
|
|
await failCut(payload, cutId, SPEECH_CUT_FAILURE_GENERATING)
|
|
return
|
|
}
|
|
if (resolution.state !== 'pronto') {
|
|
await failCut(payload, cutId, SPEECH_CUT_FAILURE_UNAVAILABLE)
|
|
return
|
|
}
|
|
const sourceUrl = resolution.playbackUrl ?? resolution.downloadUrl
|
|
if (!sourceUrl) {
|
|
await failCut(payload, cutId, SPEECH_CUT_FAILURE_UNPLAYABLE)
|
|
return
|
|
}
|
|
|
|
await markStep('cutting')
|
|
tempDir = await mkdtemp(join(tmpdir(), 'speech-cut-'))
|
|
const inputPath = join(tempDir, 'source.mp4')
|
|
const outputName = `corte-${cut.id}-${Math.round(cut.startSeconds)}-${Math.round(cut.endSeconds)}.mp4`
|
|
const outputPath = join(tempDir, outputName)
|
|
const durationSeconds = Math.max(0, Math.round(cut.endSeconds) - Math.round(cut.startSeconds))
|
|
|
|
await downloadSource(sourceUrl, inputPath)
|
|
await runFfmpeg(
|
|
buildSpeechCutFfmpegArgs({
|
|
inputPath,
|
|
outputPath,
|
|
startSeconds: cut.startSeconds,
|
|
endSeconds: cut.endSeconds,
|
|
}),
|
|
durationSeconds,
|
|
)
|
|
|
|
await markStep('metadata')
|
|
const fallback = buildSpeechCutFallbackMetadata({
|
|
speechType: speech.type ?? null,
|
|
dateLabel: formatSpeechDate(speech.speechAt),
|
|
summary: speech.summary ?? null,
|
|
})
|
|
const title = cut.title?.trim() || fallback.title
|
|
const description = cut.description?.trim() || fallback.description
|
|
if (title !== cut.title || description !== cut.description) {
|
|
await updateCutSystem(payload, cutId, { title, description })
|
|
}
|
|
|
|
await markStep('publishing')
|
|
await withPayloadTransaction(payload, async ({ req }) => {
|
|
const media = await createMediaSystem(payload, title, outputPath, req)
|
|
await updateCutSystem(
|
|
payload,
|
|
cutId,
|
|
{
|
|
status: 'published',
|
|
media: media.id,
|
|
publishedAt: new Date().toISOString(),
|
|
step: null,
|
|
error: null,
|
|
},
|
|
req,
|
|
)
|
|
})
|
|
} catch (error) {
|
|
await failCut(payload, cutId, messageOf(error), currentStep).catch(() => undefined)
|
|
} finally {
|
|
if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lazy reaper: a `processing` row untouched for `SPEECH_CUT_STALE_MS` was lost
|
|
* to a restart (there is no queue to ask). The conditional `where` never
|
|
* clobbers a job that just finished — intentional admin bypass, like the other
|
|
* system writes above.
|
|
*/
|
|
export const reapStaleSpeechCut = async (
|
|
payload: Payload,
|
|
cut: { id: number; status: string; updatedAt: string },
|
|
): Promise<boolean> => {
|
|
if (cut.status !== 'processing') return false
|
|
const updatedAt = Date.parse(cut.updatedAt)
|
|
if (!Number.isFinite(updatedAt) || Date.now() - updatedAt < SPEECH_CUT_STALE_MS) return false
|
|
|
|
const result = await payload.update({
|
|
collection: 'speechCut',
|
|
where: { and: [{ id: { equals: cut.id } }, { status: { equals: 'processing' } }] },
|
|
data: { status: 'failed', step: null, error: SPEECH_CUT_FAILURE_INTERRUPTED },
|
|
// Intentional admin bypass: the reaper repairs a row the system owns.
|
|
overrideAccess: true,
|
|
})
|
|
return result.docs.length > 0
|
|
}
|