mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
460 lines
16 KiB
TypeScript
460 lines
16 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import type { Payload } from 'payload'
|
|
import { getPayload } from 'payload'
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
|
|
import { matchMunicipalityMentions } from '@/lib/speechGazetteer'
|
|
import { normalizeForSearch } from '@/lib/speechSearch'
|
|
import { INTERNET_SPEECH_MEDIA_SLUG, type WebSpeechFinding } from '@/lib/webSpeech'
|
|
import type { Speech } from '@/payload-types'
|
|
import config from '@/payload.config'
|
|
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
|
import {
|
|
createWebSpeechMediaMirror,
|
|
ingestWebSpeech,
|
|
type WebSpeechAcquirer,
|
|
type WebSpeechClassifier,
|
|
type WebSpeechIngestDeps,
|
|
type WebSpeechTranscriber,
|
|
} from '@/utilities/speech/webSpeechIngest'
|
|
|
|
import {
|
|
WEB_SPEECH_JPEG_BYTES as JPEG_BYTES,
|
|
WEB_SPEECH_MP3_BYTES as MP3_BYTES,
|
|
WEB_SPEECH_MP4_BYTES as MP4_BYTES,
|
|
} from '../helpers/webSpeechMediaFixture'
|
|
|
|
// C215 — the ingestion pipeline with injected seams: acquire → transcribe →
|
|
// classify → mirror → upsert. The fakes keep it offline; the assertions pin
|
|
// idempotency (second run only refreshes metadata), --reprocess, the mirrored
|
|
// private file and the honest failure stages.
|
|
|
|
let payload: Payload
|
|
const acquireCalls: string[] = []
|
|
|
|
// Every key these specs may touch, in both the external-id and the URL form:
|
|
// a failed attempt must never leak a row into the retry.
|
|
const TEST_SOURCE_KEYS = [
|
|
'web:youtube:abc123',
|
|
'web:youtube:https://www.youtube.com/watch?v=abc123',
|
|
'web:youtube:reproc',
|
|
'web:youtube:https://www.youtube.com/watch?v=reproc',
|
|
'web:youtube:thumb',
|
|
'web:youtube:https://www.youtube.com/watch?v=thumb',
|
|
'web:youtube:thumb-keep',
|
|
'web:youtube:https://www.youtube.com/watch?v=thumb-keep',
|
|
'web:youtube:broken',
|
|
'web:youtube:https://www.youtube.com/watch?v=broken',
|
|
'web:youtube:long',
|
|
'web:youtube:https://www.youtube.com/watch?v=long',
|
|
'web:youtube:semvoz',
|
|
'web:youtube:https://www.youtube.com/watch?v=semvoz',
|
|
'web:radio:https://radio.example/entrevista',
|
|
]
|
|
|
|
const cleanup = async (): Promise<void> => {
|
|
await payload.delete({
|
|
collection: 'speech',
|
|
where: { sourceKey: { in: TEST_SOURCE_KEYS } },
|
|
overrideAccess: true,
|
|
})
|
|
}
|
|
|
|
const finding = (overrides: Partial<WebSpeechFinding> = {}): WebSpeechFinding => ({
|
|
platform: 'youtube',
|
|
url: 'https://www.youtube.com/watch?v=abc123',
|
|
publishedAt: '2026-09-20',
|
|
...overrides,
|
|
})
|
|
|
|
const acquire: WebSpeechAcquirer = async (current, tempDir) => {
|
|
acquireCalls.push(current.url)
|
|
const fileName = 'source.mp4'
|
|
await writeFile(join(tempDir, fileName), MP4_BYTES)
|
|
return {
|
|
fileName,
|
|
metadata: {
|
|
externalId: 'abc123',
|
|
title: 'Título do yt-dlp',
|
|
channel: 'Canal do yt-dlp',
|
|
durationSeconds: 12,
|
|
thumbnailUrl: null,
|
|
},
|
|
}
|
|
}
|
|
|
|
const transcribe: WebSpeechTranscriber = async () => ({
|
|
ok: true,
|
|
segments: [{ start: 0, end: 2.5, text: 'A saúde pública em Feira de Santana' }],
|
|
durationSeconds: 2.5,
|
|
})
|
|
|
|
const classify: WebSpeechClassifier = async () => ({
|
|
facets: {
|
|
topics: ['saude'],
|
|
scopes: ['bahia'],
|
|
municipalities: matchMunicipalityMentions('Feira de Santana'),
|
|
people: [],
|
|
programs: [],
|
|
projects: [],
|
|
classifiedBy: 'gazetteer',
|
|
},
|
|
llm: { used: false, totalTokens: null, estimatedCostUsd: null, error: null },
|
|
})
|
|
|
|
const fakeFfmpeg: WebSpeechIngestDeps['runFfmpeg'] = async (args) => {
|
|
const outputPattern = args.at(-1) ?? ''
|
|
if (!outputPattern.includes('%03d')) throw new Error('fake ffmpeg sem outputPattern')
|
|
await writeFile(outputPattern.replace('%03d', '000'), MP3_BYTES)
|
|
}
|
|
|
|
const deps = (overrides: WebSpeechIngestDeps = {}): WebSpeechIngestDeps => ({
|
|
acquire,
|
|
transcribe,
|
|
classify,
|
|
runFfmpeg: fakeFfmpeg,
|
|
...overrides,
|
|
})
|
|
|
|
const findBySourceKey = async (sourceKey: string): Promise<Speech | null> => {
|
|
const found = await payload.find({
|
|
collection: 'speech',
|
|
where: { sourceKey: { equals: sourceKey } },
|
|
depth: 1,
|
|
overrideAccess: true,
|
|
})
|
|
return found.docs[0] ?? null
|
|
}
|
|
|
|
const countSegments = async (speechId: number): Promise<number> =>
|
|
(
|
|
await payload.count({
|
|
collection: 'speechSegment',
|
|
where: { speech: { equals: speechId } },
|
|
overrideAccess: true,
|
|
})
|
|
).totalDocs
|
|
|
|
describe('ingestWebSpeech (C215)', () => {
|
|
beforeAll(async () => {
|
|
payload = await getPayload({ config: await config })
|
|
await cleanup()
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await cleanup()
|
|
})
|
|
|
|
it('ingests one finding end to end and re-running only refreshes metadata', async () => {
|
|
acquireCalls.length = 0
|
|
const current = finding({ externalId: 'abc123', title: 'Título do lote' })
|
|
const sourceKey = 'web:youtube:abc123'
|
|
|
|
const first = await ingestWebSpeech(payload, current, { deps: deps() })
|
|
expect(first).toMatchObject({
|
|
sourceKey,
|
|
status: 'created',
|
|
stage: null,
|
|
error: null,
|
|
})
|
|
expect(first.asrSeconds).toBe(2.5)
|
|
expect(first.asrCostUsd).toBeGreaterThan(0)
|
|
|
|
const speech = await findBySourceKey(sourceKey)
|
|
expect(speech).toMatchObject({
|
|
origin: 'web',
|
|
platform: 'youtube',
|
|
externalId: 'abc123',
|
|
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
|
|
title: 'Título do lote',
|
|
channel: 'Canal do yt-dlp',
|
|
speechAt: '2026-09-20T00:00',
|
|
year: 2026,
|
|
durationSeconds: 3,
|
|
classifiedBy: 'gazetteer',
|
|
topics: ['saude'],
|
|
})
|
|
expect(speech?.searchText).toBe('a saude publica em feira de santana')
|
|
expect(speech?.mentionedMunicipalities?.[0]).toMatchObject({ slug: 'feira-de-santana' })
|
|
const media = typeof speech?.mirroredMedia === 'object' ? speech.mirroredMedia : null
|
|
expect(media?.mimeType).toBe('video/mp4')
|
|
expect(await countSegments(speech?.id ?? 0)).toBe(1)
|
|
expect(acquireCalls).toHaveLength(1)
|
|
|
|
const second = await ingestWebSpeech(payload, current, { deps: deps() })
|
|
expect(second.status).toBe('skipped')
|
|
expect(acquireCalls).toHaveLength(1)
|
|
const again = await findBySourceKey(sourceKey)
|
|
expect(again?.id).toBe(speech?.id)
|
|
expect(again?.title).toBe('Título do lote')
|
|
// The metadata-only refresh must never wipe the stored duration/media.
|
|
expect(again?.durationSeconds).toBe(3)
|
|
expect(await countSegments(again?.id ?? 0)).toBe(1)
|
|
})
|
|
|
|
it('ingests and re-runs a long transcript without truncating it', async () => {
|
|
acquireCalls.length = 0
|
|
const marker = 'long-final-marker'
|
|
const longText = 'a'.repeat(20_000)
|
|
const longSegments = [
|
|
{ start: 0, end: 120, text: `${longText} primeiro` },
|
|
{ start: 120, end: 240, text: `${longText} segundo` },
|
|
{ start: 240, end: 360, text: `${longText} ${marker}` },
|
|
]
|
|
const longSearchText = normalizeForSearch(longSegments.map((segment) => segment.text).join(' '))
|
|
const current = finding({
|
|
externalId: 'long',
|
|
url: 'https://www.youtube.com/watch?v=long',
|
|
})
|
|
const longTranscriber: WebSpeechTranscriber = async () => ({
|
|
ok: true,
|
|
segments: longSegments,
|
|
durationSeconds: 3_600,
|
|
})
|
|
|
|
const first = await ingestWebSpeech(payload, current, {
|
|
deps: deps({ transcribe: longTranscriber }),
|
|
})
|
|
expect(first).toMatchObject({ status: 'created', stage: null, error: null })
|
|
|
|
const speech = await findBySourceKey('web:youtube:long')
|
|
expect(speech?.searchText).toBe(longSearchText)
|
|
const mirroredMedia = speech?.mirroredMedia
|
|
const mediaId = mirroredMedia && typeof mirroredMedia === 'object' ? mirroredMedia.id : null
|
|
expect(mediaId).not.toBeNull()
|
|
const segments = await payload.find({
|
|
collection: 'speechSegment',
|
|
where: { speech: { equals: speech?.id } },
|
|
depth: 0,
|
|
sort: 'order',
|
|
overrideAccess: true,
|
|
})
|
|
expect(segments.docs.map((segment) => segment.text)).toEqual(
|
|
longSegments.map((segment) => segment.text),
|
|
)
|
|
expect(await countSegments(speech?.id ?? 0)).toBe(3)
|
|
expect(acquireCalls).toHaveLength(1)
|
|
|
|
const second = await ingestWebSpeech(payload, current, {
|
|
deps: deps({ transcribe: longTranscriber }),
|
|
})
|
|
expect(second).toMatchObject({ status: 'skipped', stage: null, error: null })
|
|
const again = await findBySourceKey('web:youtube:long')
|
|
expect(again?.id).toBe(speech?.id)
|
|
expect(again?.searchText).toBe(longSearchText)
|
|
const againMedia = again?.mirroredMedia
|
|
expect(againMedia && typeof againMedia === 'object' ? againMedia.id : null).toBe(mediaId)
|
|
const againSegments = await payload.find({
|
|
collection: 'speechSegment',
|
|
where: { speech: { equals: again?.id } },
|
|
depth: 0,
|
|
sort: 'order',
|
|
overrideAccess: true,
|
|
})
|
|
expect(againSegments.docs.map((segment) => segment.text)).toEqual(
|
|
longSegments.map((segment) => segment.text),
|
|
)
|
|
expect(await countSegments(speech?.id ?? 0)).toBe(3)
|
|
expect(acquireCalls).toHaveLength(1)
|
|
})
|
|
|
|
it('reprocesses on demand and removes the replaced artifact', async () => {
|
|
acquireCalls.length = 0
|
|
const current = finding({
|
|
externalId: 'reproc',
|
|
url: 'https://www.youtube.com/watch?v=reproc',
|
|
})
|
|
const sourceKey = 'web:youtube:reproc'
|
|
|
|
await ingestWebSpeech(payload, current, { deps: deps() })
|
|
const before = await findBySourceKey(sourceKey)
|
|
const previousMediaId =
|
|
typeof before?.mirroredMedia === 'object' ? before.mirroredMedia?.id : null
|
|
expect(previousMediaId).not.toBeNull()
|
|
|
|
const result = await ingestWebSpeech(payload, current, { deps: deps(), reprocess: true })
|
|
expect(result.status).toBe('updated')
|
|
expect(acquireCalls).toHaveLength(2)
|
|
|
|
const after = await findBySourceKey(sourceKey)
|
|
const nextMediaId = typeof after?.mirroredMedia === 'object' ? after.mirroredMedia?.id : null
|
|
expect(nextMediaId).not.toBe(previousMediaId)
|
|
|
|
const previous = await payload
|
|
.findByID({
|
|
collection: INTERNET_SPEECH_MEDIA_SLUG,
|
|
id: previousMediaId ?? 0,
|
|
overrideAccess: true,
|
|
})
|
|
.catch(() => null)
|
|
expect(previous).toBeNull()
|
|
})
|
|
|
|
it('mirrors the origin thumbnail when the finding brings one', async () => {
|
|
const current = finding({
|
|
externalId: 'thumb',
|
|
url: 'https://www.youtube.com/watch?v=thumb',
|
|
thumbnailUrl: 'https://i.ytimg.com/vi/thumb/hq.jpg',
|
|
})
|
|
const sourceKey = 'web:youtube:thumb'
|
|
|
|
const result = await ingestWebSpeech(payload, current, {
|
|
deps: deps({
|
|
downloadThumbnail: async (_url, destinationPath) => {
|
|
await writeFile(destinationPath, JPEG_BYTES)
|
|
},
|
|
}),
|
|
})
|
|
expect(result.status).toBe('created')
|
|
|
|
const speech = await findBySourceKey(sourceKey)
|
|
const thumbnail = typeof speech?.thumbnail === 'object' ? speech.thumbnail : null
|
|
expect(thumbnail?.mimeType).toBe('image/jpeg')
|
|
})
|
|
|
|
it('keeps the stored thumbnail when a reprocess cannot fetch a new one', async () => {
|
|
const current = finding({
|
|
externalId: 'thumb-keep',
|
|
url: 'https://www.youtube.com/watch?v=thumb-keep',
|
|
thumbnailUrl: 'https://i.ytimg.com/vi/thumb-keep/hq.jpg',
|
|
})
|
|
const sourceKey = 'web:youtube:thumb-keep'
|
|
|
|
await ingestWebSpeech(payload, current, {
|
|
deps: deps({
|
|
downloadThumbnail: async (_url, destinationPath) => {
|
|
await writeFile(destinationPath, JPEG_BYTES)
|
|
},
|
|
}),
|
|
})
|
|
const before = await findBySourceKey(sourceKey)
|
|
const beforeThumbnail = typeof before?.thumbnail === 'object' ? before.thumbnail?.id : null
|
|
expect(beforeThumbnail).not.toBeNull()
|
|
|
|
const result = await ingestWebSpeech(payload, current, {
|
|
deps: deps({
|
|
downloadThumbnail: async () => {
|
|
throw new Error('capa indisponível')
|
|
},
|
|
}),
|
|
reprocess: true,
|
|
})
|
|
expect(result.status).toBe('updated')
|
|
|
|
const after = await findBySourceKey(sourceKey)
|
|
const afterThumbnail = typeof after?.thumbnail === 'object' ? after.thumbnail?.id : null
|
|
expect(afterThumbnail).toBe(beforeThumbnail)
|
|
})
|
|
|
|
it('fails honestly when the acquisition fails, without a ghost row', async () => {
|
|
const current = finding({
|
|
externalId: 'broken',
|
|
url: 'https://www.youtube.com/watch?v=broken',
|
|
})
|
|
const sourceKey = 'web:youtube:broken'
|
|
|
|
const result = await ingestWebSpeech(payload, current, {
|
|
deps: deps({
|
|
acquire: async () => {
|
|
throw new Error('yt-dlp falhou no download')
|
|
},
|
|
}),
|
|
})
|
|
expect(result).toMatchObject({ status: 'failed', stage: 'acquisition' })
|
|
expect(result.error).toContain('yt-dlp falhou no download')
|
|
expect(await findBySourceKey(sourceKey)).toBeNull()
|
|
})
|
|
|
|
it('fails honestly when the transcription fails', async () => {
|
|
const current = finding({
|
|
externalId: 'semvoz',
|
|
url: 'https://www.youtube.com/watch?v=semvoz',
|
|
})
|
|
const sourceKey = 'web:youtube:semvoz'
|
|
|
|
const result = await ingestWebSpeech(payload, current, {
|
|
deps: deps({
|
|
transcribe: async () => ({ ok: false, error: 'Transcrição vazia.', status: 502 }),
|
|
}),
|
|
})
|
|
expect(result).toMatchObject({ status: 'failed', stage: 'transcription' })
|
|
expect(result.error).toContain('Transcrição vazia')
|
|
expect(await findBySourceKey(sourceKey)).toBeNull()
|
|
})
|
|
|
|
it('updates a large media row through the direct S3 seam', async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'web-speech-large-test-'))
|
|
const inputPath = join(dir, 'source.mp4')
|
|
await writeFile(inputPath, MP4_BYTES)
|
|
const mirror = createWebSpeechMediaMirror({
|
|
thresholdBytes: 0,
|
|
uploadLarge: async ({ filename }) => ({
|
|
filename: filename ?? 'source.mp4',
|
|
mimeType: 'video/mp4',
|
|
filesize: MP4_BYTES.length,
|
|
url: 'http://127.0.0.1:3900/teqo-media/source.mp4',
|
|
}),
|
|
removeLarge: async () => undefined,
|
|
})
|
|
let mediaId = 0
|
|
|
|
try {
|
|
await withPayloadTransaction(payload, async ({ req }) => {
|
|
const mirrored = await mirror({ payload, req, alt: 'YouTube — fala', inputPath })
|
|
mediaId = mirrored.id
|
|
await mirrored.cleanup()
|
|
})
|
|
const media = await payload.findByID({
|
|
collection: INTERNET_SPEECH_MEDIA_SLUG,
|
|
id: mediaId,
|
|
overrideAccess: true,
|
|
})
|
|
expect(media).toMatchObject({ filesize: MP4_BYTES.length, mimeType: 'video/mp4' })
|
|
} finally {
|
|
if (mediaId) {
|
|
await payload
|
|
.delete({ collection: INTERNET_SPEECH_MEDIA_SLUG, id: mediaId, overrideAccess: true })
|
|
.catch(() => undefined)
|
|
}
|
|
await rm(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('accepts a radio finding with a direct mediaUrl', async () => {
|
|
const current = finding({
|
|
platform: 'radio',
|
|
url: 'https://radio.example/entrevista',
|
|
mediaUrl: 'https://radio.example/entrevista.mp3',
|
|
publishedAt: '2026-09-21',
|
|
})
|
|
const sourceKey = 'web:radio:https://radio.example/entrevista'
|
|
|
|
const downloaded: string[] = []
|
|
const result = await ingestWebSpeech(payload, current, {
|
|
deps: deps({
|
|
acquire: async (currentFinding, tempDir) => {
|
|
downloaded.push(currentFinding.mediaUrl ?? '')
|
|
const fileName = 'source.mp3'
|
|
await writeFile(join(tempDir, fileName), MP3_BYTES)
|
|
return { fileName, metadata: null }
|
|
},
|
|
}),
|
|
})
|
|
expect(result.status).toBe('created')
|
|
expect(downloaded).toEqual(['https://radio.example/entrevista.mp3'])
|
|
|
|
const speech = await findBySourceKey(sourceKey)
|
|
expect(speech).toMatchObject({
|
|
origin: 'web',
|
|
platform: 'radio',
|
|
sourceUrl: 'https://radio.example/entrevista',
|
|
channel: null,
|
|
})
|
|
const media = typeof speech?.mirroredMedia === 'object' ? speech.mirroredMedia : null
|
|
expect(media?.mimeType).toBe('audio/mpeg')
|
|
})
|
|
})
|