mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(C215): ingestão de falas da internet no acervo — origem web, espelho privado e catálogo (Closes #1291)
This commit is contained in:
parent
d0823043ed
commit
f6fb6465cb
45 changed files with 22185 additions and 297 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -78,6 +78,8 @@ next-env.d.ts
|
|||
/data/ai-fill/
|
||||
# Câmara pilot media/JSON cache (pnpm camara:pilot — VOD MP4 + report, never commit)
|
||||
/data/camara/
|
||||
# C215 — web-speech ingestion batch cache + reports (pnpm falas-web:import)
|
||||
/data/falas-web/
|
||||
# Solla × Ceuci report HTML cache (scripts/build-solla-ceuci-salvador-report.mjs)
|
||||
/data/solla-ceuci-salvador/
|
||||
|
||||
|
|
|
|||
1
docs/changelog/2026-09-24-c215.md
Normal file
1
docs/changelog/2026-09-24-c215.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C215 — Ingestão de falas da internet no acervo (2026-09-24):** a esteira que recebe um lote de achados da web (contrato do C218), baixa/espelha a mídia, transcreve com timestamps, classifica e grava de forma idempotente, com relatório honesto. O `speech` deixou de ser só Câmara: ganhou o discriminador `origin` (`camara` default | `web`) e os campos web nullable (`platform` youtube|instagram|radio|audio, `externalId`, `sourceUrl`, `title`, `channel`, `mirroredMedia`/`thumbnail` → nova collection privada `internetSpeechMedia`), numa migration aditiva (`20260924_042028_add_web_speech_origin`, `origin` NOT NULL DEFAULT `'camara'` — as linhas existentes ficam intactas) e a collection privada registrada no plugin S3 (`internetSpeechMedia: true`; sem isso, produção cairia no disco efêmero), com `read = canReadSpeech` (o gate do acervo, fail-closed), escrita `payloadAdminOnly` (o CLI usa o bypass justificado) e cascade no `beforeDelete` do `Speech` (mídia + capa). A identidade da fala web é namespaced (`web:<platform>:<externalId|url canônica>`, nunca colide com o `sourceKey` da Câmara) e o contrato puro vive em `src/lib/webSpeech.ts` (taxonomia + labels pt-BR, schema zod do lote `{ generatedAt?, findings[] }` com `mediaUrl` obrigatória para rádio/áudio e `publishedAt` normalizado para o `speechAt` wall-clock, canonicalização de URL que colapsa youtu.be/shorts/live/embed e reels e remove tracking, e o `webSpeechSourceKey` estável). A aquisição tem dono único por ferramenta: `src/utilities/media/ytdlp.ts` (YouTube/Instagram via `YTDLP_PATH` → PATH, probe `--version` fail-closed com hint de `pipx install yt-dlp`, `--dump-json` preenchendo lacunas de título/canal/duração/id/capa e download capado em ≤720p H.264/AAC com `--merge-output-format mp4` e `--ffmpeg-location` do binário resolvido) e `src/utilities/media/downloadToFile.ts` (rádio/áudio direto, streaming com timeout); a resolução/probe/runner genérico de binários saiu de `scripts/lib/reelFfmpeg.mjs` para `scripts/lib/mediaBinaries.mjs` (o reel re-exporta — API e unit pins intactos) e o script resolve o ffmpeg com o fallback empacotado, setando `FFMPEG_PATH`. O pipeline `src/utilities/speech/webSpeechIngest.ts` (deps injetáveis para os specs) reusa os donos existentes — `deepInfraTranscribeSegments` + chunk/merge/`searchText` do C199, `classifySpeech` (gazetteer + LLM), `withPayloadTransaction` e o `upsertSpeechBundle` estendido com os campos web e `options.req` (o caminho Câmara segue idêntico) — com a capa em best-effort fora da transação (imagem quebrada nunca perde a fala) e limpeza best-effort do artefato substituído no `--reprocess`. O CLI `pnpm falas-web:import --findings <lote.json> [--out data/falas-web] [--limit n] [--dry-run] [--reprocess]` (relatórios gitignored em `data/falas-web/reports/`) tem as guardas do C155 (`assertLocalDatabase` + `FALAS_WEB_IMPORT_CONFIRM=1` para alvo não-local/produção; `--dry-run` é read-only e não exige a flag), dedupe por `sourceKey` no lote, item inválido vira falha do item (nunca derruba o lote) e o relatório declara achados/novos/atualizados/ignorados/falhas (com estágio e motivo)/tempo/segundos e custo de ASR/custo LLM — exit 1 quando nenhum achado entrou. Blindagem obrigatória das superfícies existentes: `origin: camara` no where da lista/opções da Câmara (`speechListFilters`/`speechPageData`), na cobertura (`speechCoverage`) e no tool do Sollinha (`findSpeechExcerpts`) — a fala web nasce invisível até o C216 abrir a fonte própria; o serving/player fica no C216 (`buildPrivateMediaResponse`) e o corte no C217 (`downloadPrivateMediaToFile`), nada de rota nova aqui e nenhuma URL pública de mídia. Sem UI (Impeccable A), sem PII/`Consent` novo (canal/autor é texto de origem; menções continuam texto), sem segundo cadastro de pessoa. Verificação: unit (`webSpeech` — schema/sourceKey/canonicalização/labels; `webSpeeches` — plano/dedupe/relatório; `ytdlp` — resolução/parse/args; pins de `speechListFilters` e `speechExcerptsTool` atualizados), int (`webSpeechImport` — round-trip dos campos web, idempotência com facetas/segmentos preservados, curadoria `manual` vencendo, cascade da mídia no delete; `webSpeechIngest` — pipeline com fakes de acquirer/ffmpeg/transcriber/classificador, segundo run só atualiza metadados, `--reprocess` troca o artefato e apaga o antigo, falhas honestas por estágio sem linha fantasma, thumbnail espelhada, achado de rádio com `mediaUrl`), smoke local do CLI (`--dry-run` com inválido/duplicado e execução real com yt-dlp ausente → relatório + exit 1), `pnpm gate:fast`, `pnpm test:int` e o e2e afetado local. O run real de um lote em produção é operação do C218 (o PR não escreve no acervo de produção).
|
||||
242
docs/plans/acervo-falas-web-ingestao-impl.md
Normal file
242
docs/plans/acervo-falas-web-ingestao-impl.md
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
# Impl: C215 — Ingestão de falas da internet no acervo (descoberta, espelho e catálogo)
|
||||
|
||||
Status: executado
|
||||
Atualizado em: 2026-09-24
|
||||
Issue: #1291
|
||||
Intenção: docs/plans/acervo-falas-web-ingestao.md
|
||||
Appetite restante: herdado (~3–4 dias de engenharia + tempo de máquina; um outcome verificável — um lote de falas encontradas na web entra no catálogo com transcrição, facetas e mídia espelhada, reexecutável sem duplicar e com relatório honesto)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** dado um lote de achados da web (plataforma + URL + metadados que a descoberta — C218 — já trouxe), cada fala vira registro no acervo com data, duração, transcrição com timestamps, temas/alcance/menções (mesmas facetas do "Falas da Câmara"), proveniência da classificação, origem (plataforma + URL) e canal/autor; a mídia é espelhada privada e preservada. Reexecutar não duplica nem sobrescreve curadoria manual e cada execução imprime relatório honesto (achados, novos, atualizados, ignorados, falhas, tempo e custo de ASR).
|
||||
- **O que NÃO negociar (lockdowns):**
|
||||
- Mídia espelhada é privada e preservada: nunca URL pública, nunca `/api/media/file`; leitura só pelo gate do acervo (`canReadSpeech`/`canReadCommunicationCatalog`, fail-closed; `advisor`/`leader` negados).
|
||||
- Nenhum segundo pipeline de ASR/classificação/upsert e nenhum segundo cadastro de pessoa (menções continuam texto; sem `Contact`, sem `Consent` novo — não há PII nova).
|
||||
- `sourceKey` da Câmara é identidade por contrato: a fala web nasce em namespace próprio (`web:*`) e não toca os registros existentes.
|
||||
- Guardas de execução: `assertLocalDatabase` + `FALAS_WEB_IMPORT_CONFIRM=1` para alvo não-local/override; `--dry-run` sem flag.
|
||||
- Não tocar feed do site público, Central de Conteúdos (C211 é dona da peça), publicação externa nem score de vaidade.
|
||||
- Curadoria manual nunca sobrescrita (hook `preserveManualFacets` existente é o pino).
|
||||
- **O que reavaliar (hipóteses da intenção vs. exploração):**
|
||||
- A intenção deixou "ferramenta de download e destino do espelho" para a implementação: ficam **yt-dlp** (YouTube/Instagram) + download HTTP direto (rádio/áudio) e uma collection privada nova `internetSpeechMedia` — decisões D2/D4.
|
||||
- "Modelo de dados" também estava em aberto: a exploração confirma que `Speech` não tem origem/plataforma/URL/canal/título/thumb/mídia espelhada e que **quatro superfícies leem `speech` sem discriminador** (`speechPageData`, `speechListFilters`, `speechCoverage`, `findSpeechExcerpts`) — sem blindagem a fala web vazaria para "Falas da Câmara" com crédito errado (D10).
|
||||
- `speechPosterJob`/`speechCutJob` são by-id (VOD Câmara) e degradam sozinhos — não entram nesta fatia.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
L["lote de achados (JSON)<br/>generatedAt + findings[]"] --> CLI["scripts/import-web-speeches.mjs<br/>zod + dedupe por sourceKey"]
|
||||
CLI --> PLAN["plano do lote<br/>novo · atualizar · ignorar"]
|
||||
PLAN -->|youtube/instagram| YTDLP["yt-dlp --dump-json<br/>YTDLP_PATH fail-closed"]
|
||||
PLAN -->|radio/audio| HTTP["download HTTP do mediaUrl"]
|
||||
YTDLP --> MEDIA[("internetSpeechMedia<br/>upload privado S3/disco")]
|
||||
HTTP --> MEDIA
|
||||
MEDIA --> FFMPEG["ffmpeg: mp3 mono 16k + chunks<br/>buildRecordingAudioFfmpegArgs"]
|
||||
FFMPEG --> ASR["deepInfraTranscribeSegments<br/>merge de offsets"]
|
||||
ASR --> CLS["classifySpeech<br/>gazetteer + LLM"]
|
||||
CLS --> UPSERT["upsertSpeechBundle estendido<br/>tx: speech + speechSegment + searchText"]
|
||||
UPSERT --> DB[("speech origin=web<br/>sourceKey web:...")]
|
||||
CLI --> REPORT["relatório stdout + JSON<br/>data/falas-web/reports"]
|
||||
DB -.->|"origin: camara nas consultas"| CAM["superfícies Câmara<br/>lista · opções · cobertura · Sollinha"]
|
||||
DB -.-> C216["C216: lista/detalhe/player<br/>buildPrivateMediaResponse"]
|
||||
DB -.-> C217["C217: cortes<br/>downloadPrivateMediaToFile"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) estender `speech` com discriminador de origem + collection privada própria de mídia + aquisição yt-dlp/HTTP + pipeline com dependências injetadas | B) collection nova `internetSpeech` (gêmea) | C) reusar `recordingMedia` e/ou servir a mídia no próprio C215.
|
||||
**Recomendação:** A — porque preserva o contrato prod da Câmara (`sourceKey`, facetas, segmentos, busca) sem criar uma segunda cópia de todo o conhecimento (classificador, ASR, upsert, busca) e sem forçar relação polimórfica no `SpeechCut` que o C217 vai estender.
|
||||
**Rejeitadas:**
|
||||
|
||||
- **B (collection nova)** porque duplica facets/segmentos/classificador/import/busca e obriga `SpeechCut.speech` (relação fixa a `speech`) a virar polimorfismo, acoplando o C217 sem ganho.
|
||||
- **C (reusar `recordingMedia` / servir no C215)** porque `recordingMedia` é o artefato da gravação enviada (domínio e access próprios), e o serving pertence ao C216 (detalhe/player/baixar) e ao C217 (corte), que já consomem `privateMediaResponse.ts`; rota nova aqui seria trabalho jogado fora.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**D1 — Modelo de dados: estender `speech` com discriminador de origem; NÃO criar collection de fala nova.**
|
||||
`Decisão: A — novos campos nullable em Speech (só origin obrigatório) + sourceKey namespaced web:*; Câmara intacta.`
|
||||
`Por quê: reusa facetas, segmentos, classificador, upsert, busca trigram e access já existentes; a fala web é a mesma unidade de catálogo ("mesmas facetas, mesmos filtros" — intenção).`
|
||||
`Rejeitadas: B) collection nova internetSpeech porque é gêmea de facets/segmentos/classificador/import/busca e força relação polimórfica no SpeechCut (C217); C) sem migration — impossível: mídia espelhada + origem/canal exigem schema.`
|
||||
|
||||
- Campos novos: `origin` (select `camara` default | `web`, required, index — backfill dos existentes pelo default da migration); `platform` (select `youtube|instagram|radio|audio`, index, web-only); `externalId`; `sourceUrl`; `title`; `channel`; `mirroredMedia` (upload → `internetSpeechMedia`); `thumbnail` (upload → mesma collection).
|
||||
- Reusados sem mudança: `speechAt` (data de publicação no caso web; `deriveSpeechYear` continua), `durationSeconds`, `searchText`, facets, `classifiedBy`, `speechSegment`. `legislature`/`type`/`phase`/VOD ficam nulos na origem web (já são nullable).
|
||||
- `sourceKey` namespaced: `web:<platform>:<externalId>` quando houver id externo; senão `web:<platform>:<url canônica normalizada>`. Câmara permanece exatamente como está (o índice unique já existe e o namespace elimina colisão).
|
||||
|
||||
**D2 — Mídia privada: collection de upload nova `internetSpeechMedia`.**
|
||||
`Decisão: A — collection privada própria; read = canReadSpeech; create/update/delete = payloadAdminOnly (o CLI usa overrideAccess justificado); registro explícito no plugin S3; cascade no beforeDelete do Speech.`
|
||||
`Por quê: a mídia da fala web tem gate do acervo, não o gate da gravação; e o dono do ciclo de vida é o próprio Speech (a mídia não existe sem a fala).`
|
||||
`Rejeitadas: reusar recordingMedia (domínio/labels/access da gravação enviada — faria o modelo mentir); usar media pública (read anônimo por contrato — vazaria mídia de terceiro).`
|
||||
|
||||
- Sem registro no plugin S3 (`internetSpeechMedia: true`), produção cai no disco efêmero do container — mesmo modo de falha documentado no C199.
|
||||
- Cascade: estender o `beforeDelete` do `Speech` (hoje apaga `speechSegment`) para apagar `mirroredMedia`/`thumbnail` quando existirem, com o mesmo bypass justificado (cascade é do dono; roda dentro do delete autorizado do Speech).
|
||||
|
||||
**D3 — Servir o arquivo é do C216/C217, não daqui.**
|
||||
`Decisão: A — C215 entrega só o artefato armazenado + a collection; C216 usa buildPrivateMediaResponse (detalhe/player/baixar) e C217 usa downloadPrivateMediaToFile (corte).`
|
||||
`Por quê: o dono único de I/O privado já existe (privateMediaResponse.ts, C193/C199) e a superfície de leitura nasce no C216; rota nova aqui não tem consumidor.`
|
||||
`Rejeitadas: rota de arquivo no C215 (sem UI/consumidor, morreria no C216); signed URLs do S3 (já rejeitado no C193: endpoint interno inalcançável do browser).`
|
||||
|
||||
**D4 — Aquisição: yt-dlp (YouTube/Instagram) + download HTTP direto (rádio/áudio).**
|
||||
`Decisão: A — yt-dlp via YTDLP_PATH → PATH, fail-closed com hint de instalação; radio/audio exigem mediaUrl no lote; qualidade suficiente (≤720p, H.264/AAC, merge mp4) e áudio direto preservado; metadados do --dump-json preenchem lacunas; resolver de ffmpeg extraído de reelFfmpeg.mjs para módulo neutro.`
|
||||
`Por quê: yt-dlp é a ferramenta que cobre as duas plataformas confirmadas no gate; o lote do C218 já traz URL e (quando houver) mídia direta; o probe/resolução de binário já existe e testado nos reels — extrair o dono é melhor que um segundo resolver.`
|
||||
`Rejeitadas: gallery-dl/TikTok/Facebook/X (fora das plataformas v1); baixar em qualidade máxima/master (disco e tempo sem necessidade); transcode forçado (o ASR extrai o áudio e o player/corte consomem o arquivo como veio); segunda cópia do resolver de ffmpeg (twin).`
|
||||
|
||||
- `YTDLP_PATH` é o seam de teste (padrão do `FFMPEG_PATH`): os testes injetam um fake pelo env. Sem binário e sem caminho → falha fechada com hint (ex.: `pipx install yt-dlp`), nunca "segue sem baixar".
|
||||
- Achado `radio`/`audio` sem `mediaUrl` → falha honesta no relatório (estágio `validation`/`download`), o lote continua.
|
||||
- ffmpeg: extrair `runProcess`/`probeFfmpeg`/`resolveFfmpeg` de `scripts/lib/reelFfmpeg.mjs` para `scripts/lib/mediaBinaries.mjs`; `reelFfmpeg.mjs` re-exporta (API dos reels intacta e unit tests dos reels verdes). O script novo resolve o binário e seta `FFMPEG_PATH` resolvido quando o env não veio explícito, para que `src/utilities/media/ffmpeg.ts` (dono de execução) use o mesmo binário.
|
||||
|
||||
**D5 — Donos únicos reusados (proibido gêmeo).**
|
||||
`Decisão: A — ASR deepInfraTranscribeSegments + chunking/merge puros de recordingTranscription.ts; classificação classifySpeech; upsertSpeechBundle ESTENDIDO com campos web opcionais; privateMediaResponse.ts; withPayloadTransaction.`
|
||||
`Por quê: são os donos já testados e usados pelo Câmara/C199; a fala web tem o mesmo problema (áudio longo → chunks → segmentos com timestamps) e a mesma curadoria.`
|
||||
`Rejeitadas: chamar deepInfraTranscribe sem timestamps (não entrega o aceite); portar o normalizador do CLI da Câmara para um módulo novo (já é dono em recordingTranscription.ts); escrever o upsert da web à parte (dois escritores da mesma tabela = facetas divergindo).`
|
||||
|
||||
- `upsertSpeechBundle` ganha os campos web **opcionais** no bundle (origin/platform/externalId/sourceUrl/title/channel/mirroredMedia/thumbnail) e `findSpeechImportState` passa a devolver o estado web (origin, platform, externalId, sourceUrl, mirroredMedia, thumbnail) para as decisões de skip/reprocess. O caminho Câmara (bundle sem os campos web) continua idêntico; o `metadataData` da Câmara não muda.
|
||||
- Escrita multi-collection: a ingestão cria o upload `internetSpeechMedia` e roda o upsert **na mesma transação** (`withPayloadTransaction`), passando o `req` ao upsert; sem `req`, o upsert abre a própria transação (Câmara segue igual). Objeto S3 órfão em rollback é aceito e registrado (best-effort de limpeza no catch).
|
||||
- O artefato espelhado é o input do ffmpeg: a ingestão baixa o objeto para temp com `downloadPrivateMediaToFile` (mesmo dono do job de gravação), extrai/dividi com `buildRecordingAudioFfmpegArgs` e mescla com `mergeChunkTranscriptions`/`recordingSearchText`.
|
||||
|
||||
**D6 — Contrato do lote (interface do C218).**
|
||||
`Decisão: A — JSON { generatedAt, findings: [...] }, schema zod puro em src/lib/webSpeech.ts; finding com platform/url/mediaUrl?/externalId?/title?/channel?/publishedAt (obrigatório)/durationSeconds?/thumbnailUrl?; mediaUrl obrigatória para radio|audio; dedupe por sourceKey dentro do lote.`
|
||||
`Por quê: o C218 descobre e cura; a ingestão valida — o contrato é a fronteira entre os dois e precisa ser puro (client-safe, unit-testável) e explícito sobre a obrigatoriedade de mediaUrl nas plataformas sem extrator.`
|
||||
`Rejeitadas: aceitar qualquer JSON sem validação (falha silenciosa vira lixo no catálogo); exigir todos os metadados (a descoberta não garante tudo — o yt-dlp completa as lacunas).`
|
||||
|
||||
- O envelope é validado uma vez; cada finding é validado individualmente (`safeParse`) para que um achado inválido vire falha do achado no relatório, sem derrubar o lote (espírito do aceite do C218: falha de um não derruba o lote).
|
||||
- Duplicado dentro do lote (mesmo sourceKey) = ignorado, contado no relatório.
|
||||
|
||||
**D7 — Idempotência.**
|
||||
`Decisão: A — reexecução pula item completo (segmentos + mídia espelhada) atualizando metadados; --reprocess força re-download/re-ASR; curadoria manual nunca sobrescrita; --dry-run planeja sem rede e sem escrita (lê estado do banco); --limit.`
|
||||
`Por quê: o aceite exige reexecutar sem duplicar; o padrão já é o do import da Câmara (skip por estado) e do C199 (retry reusa o que existe).`
|
||||
`Rejeitadas: re-baixar/re-transcrever sempre (tempo e custo de ASR sem ganho); apagar-e-recriar o registro (perderia curadoria manual e quebraria a identidade sourceKey).`
|
||||
|
||||
**D8 — Relatório por execução.**
|
||||
`Decisão: A — stdout + JSON em <out>/reports/<timestamp>.json (out default data/falas-web/, gitignored, padrão data/camara/): achados, novos, atualizados, ignorados (duplicados/já completos), falhas (com estágio e motivo), tempo total, segundos de ASR, custo ASR (constante DeepInfra existente) e custo LLM quando houver.`
|
||||
`Por quê: falha não é maquiada e o operador precisa decidir o que reprocessar; o formato segue o CLI da Câmara (mesma família de relatórios gitignored).`
|
||||
`Rejeitadas: só stdout (não auditável); gravar falha como estado do item no catálogo (a intenção cortou: item que falhou fica fora da lista e aparece no relatório).`
|
||||
|
||||
**D9 — Guardas de execução.**
|
||||
`Decisão: A — assertLocalDatabase + FALAS_WEB_IMPORT_CONFIRM=1 para alvo não-local/override (mesmo padrão de CAMARA_IMPORT_CONFIRM); --dry-run é modo read-only e não exige a flag.`
|
||||
`Por quê: a ingestão escreve no acervo real e baixa mídia de terceiro; o guard de intenção explícita é o mesmo contrato já auditado no C155/C160.`
|
||||
`Rejeitadas: confiar só no host (o proxy do homeserver reescreve para 127.0.0.1 e NODE_ENV=production é o sinal honesto — o guard compartilhado já cobre); --dry-run exigindo a flag (travaria o planejamento inofensivo).`
|
||||
|
||||
**D10 — Blindagem das superfícies Câmara (obrigatória).**
|
||||
`Decisão: A — adicionar origin: camara ao where da lista/opções (speechListFilters.ts/speechPageData.ts), ao speechCoverage.ts (SQL) e ao findSpeechExcerpts.ts; testes pinam.`
|
||||
`Por quê: a fala web nasce invisível às superfícies existentes; sem isso a ingestão vaza para "Falas da Câmara" com crédito errado e o Sollinha sugeriria trechos com link de player da Câmara que não toca o arquivo. C216 abre a fonte própria e C219 cuida da paridade.`
|
||||
`Rejeitadas: filtrar no client/view-model (não protege paginação/contagem/cobertura/SQL); abrir a fonte web nessas superfícies agora (é o C216, com contrato de URL próprio).`
|
||||
|
||||
**D11 — Nomes/arquivos.**
|
||||
`Decisão: A — src/lib/webSpeech.ts (puro: plataformas+labels, schema zod do lote, webSpeechSourceKey, normalização de URL canônica, INTERNET_SPEECH_MEDIA_SLUG); src/utilities/media/ytdlp.ts (dono do binário/execução/metadados); src/utilities/speech/webSpeechIngest.ts (pipeline por achado com deps injetadas — testável); scripts/import-web-speeches.mjs + scripts/lib/webSpeeches.mjs (helpers puros do CLI); src/collections/InternetSpeechMedia.ts; script pnpm falas-web:import.`
|
||||
`Por quê: cada concern cai no dono existente (lib puro, utilities/media, utilities/speech) sem módulo top-level novo em utilities/ (regra do codebase-map); o CLI mantém o padrão scripts/<verbo>-<objeto>.mjs + lib pura.`
|
||||
`Rejeitadas: um único módulo gigante (não testável por camada); pipeline dentro do CLI (não reaproveitável e sem injeção de deps); nome em português para identificadores (convenção: copy pt-BR, identificadores em inglês).`
|
||||
|
||||
**D12 — Migration.**
|
||||
`Decisão: A — pnpm migrate:create add_web_speech_origin (campos + collection + índices); nunca editar migrations existentes; HARD-STOP humano antes de criar/aplicar, mesmo no modo --auto.`
|
||||
`Por quê: schema muda só por migration committed; o build aplica pendentes em produção, então a migration é o ponto de não-retorno e exige confirmação explícita.`
|
||||
`Rejeitadas: push automático (push: false é invariante); editar a migration inicial/prod (história congelada); SQL manual no banco (não sobrevive a deploy).`
|
||||
|
||||
**D13 — Testes.**
|
||||
`Decisão: A — unit de src/lib/webSpeech.ts (schema/sourceKey/URL canônica) + unit dos helpers puros do script (plano/relatório); int do upsert estendido (round-trip dos campos web, idempotência, manual preservado, mídia privada) e do pipeline com fakes (YTDLP_PATH fake, transcriber/classificador injetados, FFMPEG_PATH fake do repo); unit dos reels continua verde após a extração do resolver. Sem e2e novo (sem UI); rodar pnpm test:e2e:affected se o classifier selecionar specs do acervo.`
|
||||
`Por quê: não há superfície nova; as camadas inferiores cobrem o risco real (schema, idempotência, blindagem, download).`
|
||||
`Rejeitadas: e2e de script (não há harness para scripts — o padrão é unit da lib pura + int do upsert); e2e de UI (não há UI no C215).`
|
||||
|
||||
- Atualizar `scripts/lib/e2e-affected-manifest.mjs` (entry do acervo, ~linha 434) com `src/lib/webSpeech`, `src/collections/Speech.ts` e `src/collections/InternetSpeechMedia.ts` para não cair no smoke fallback. (`src/utilities/speech`, `src/utilities/media` e `src/lib/speech*` já estão mapeados; `src/lib/webSpeech` e as collections não casam prefixo nenhum hoje.)
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`Speech`** (`src/collections/Speech.ts`, editar): campos de origem/mídia (D1); `beforeDelete` estendido para apagar `mirroredMedia`/`thumbnail` (D2); `admin.description`/`defaultColumns` passam a citar as duas origens. Nenhum campo da Câmara muda.
|
||||
- **`InternetSpeechMedia`** (`src/collections/InternetSpeechMedia.ts`, novo): upload privado; `alt` obrigatório (preenchido pelo pipeline); access D2; `admin.group: 'Comunicação'` com descrição de privacidade (forma de `RecordingMedia.ts`).
|
||||
- **`src/lib/webSpeech.ts`** (novo, puro): `WEB_SPEECH_PLATFORMS` + labels pt-BR ("YouTube", "Instagram", "Rádio", "Áudio"), schema zod do envelope/finding, `webSpeechSourceKey`, `canonicalWebSpeechUrl`, `INTERNET_SPEECH_MEDIA_SLUG`.
|
||||
- **`src/utilities/media/ytdlp.ts`** (novo): resolução fail-closed do binário (`YTDLP_PATH` → PATH), execução e parse do `--dump-json` (título/canal/data/duração/id externo/thumb), args de qualidade ≤720p H.264/AAC `--merge-output-format mp4`.
|
||||
- **`src/utilities/speech/webSpeechIngest.ts`** (novo): pipeline por achado com deps injetadas (downloader, transcriber, classifier, runner de ffmpeg) — reusa os donos do D5 e devolve o resultado por achado (novo/atualizado/ignorado/falha com estágio).
|
||||
- **`src/utilities/speech/speechImport.ts`** (editar): bundle + state com os campos web opcionais; `upsertSpeechBundle` aceita `req` de transação aberta (Câmara sem `req` segue abrindo a própria).
|
||||
- **`scripts/lib/mediaBinaries.mjs`** (novo): extração de `runProcess`/`probeFfmpeg`/`resolveFfmpeg`; `scripts/lib/reelFfmpeg.mjs` re-exporta sem mudar a API.
|
||||
- **`scripts/lib/webSpeeches.mjs`** (novo, puro): plano do lote (dedupe/ordem/skip por estado), agregação e linhas do relatório.
|
||||
- **`scripts/import-web-speeches.mjs`** (novo): CLI (`--findings <arquivo>` — interface do C218 —, `--out`, `--limit`, `--dry-run`, `--reprocess`), guardas D9, caches de download no padrão `ensureCachedDownload`, relatório D8.
|
||||
- **`package.json`** (editar): script `falas-web:import` (padrão do `camara:import`).
|
||||
- **`.gitignore`** (editar): `data/falas-web/`.
|
||||
- **`payload.config.ts`** (editar): registrar `InternetSpeechMedia` na lista de collections e `internetSpeechMedia: true` no plugin S3.
|
||||
- **`scripts/lib/e2e-affected-manifest.mjs`** (editar): prefixos do D13.
|
||||
- **Migration:** `pnpm migrate:create add_web_speech_origin` — enum `origin`/`platform`, colunas em `speech` (`origin` NOT NULL DEFAULT `'camara'`, demais nullable), tabela `internet_speech_media` + FKs + índices (`origin`, `platform`) e as rels de locked documents. Revisar o SQL gerado; **não** criar índice trigram novo (o `speech_search_text_trgm_idx` da C154 cobre as duas origens). **HARD-STOP humano (mesmo em `--auto`), local-only** antes de criar/aplicar; nunca editar migrations antigas.
|
||||
- **Access / Consent:** `read = canReadSpeech`; `create/update/delete = payloadAdminOnly`; o CLI/pipeline usam `overrideAccess: true` com comentário justificado (ator confiável sem sessão, padrão do import da Câmara). Sem `Consent` novo (sem PII/opt-in; menções continuam texto).
|
||||
- **UI:** N/A — sem UI (C216 é dono da tela; Impeccable A no plano de intenção).
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- **N/A por decisão da intenção** (`acervo-falas-web-ingestao.md:35`): aqui nenhum agregado é apresentado; o relatório de execução é insumo operacional de quem roda o CLI, não superfície de usuário — sem dashboard de vaidade nem score. Nada a apresentar; nenhuma forma nova.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / schema + server** (~1 dia).
|
||||
- `src/lib/webSpeech.ts`; `InternetSpeechMedia`; campos/`beforeDelete` no `Speech`; registro em `payload.config.ts`; `pnpm generate:types`; `upsertSpeechBundle`/`findSpeechImportState` estendidos.
|
||||
- Unit: schema do lote (envelope/finding, `mediaUrl` obrigatória em radio|audio), `webSpeechSourceKey`, URL canônica, labels.
|
||||
- Int: round-trip dos campos web (create/update), idempotência do upsert, `manual` preservado, cascade da mídia no delete do Speech.
|
||||
- **Migration `add_web_speech_origin` — HARD-STOP humano, local-only** (confirmação explícita antes de criar/aplicar).
|
||||
2. **Aquisição + pipeline** (~1 dia).
|
||||
- `scripts/lib/mediaBinaries.mjs` (extração; unit dos reels verde); `src/utilities/media/ytdlp.ts`; `src/utilities/speech/webSpeechIngest.ts`.
|
||||
- Unit: resolução fail-closed do yt-dlp + parse do metadata (fake via `YTDLP_PATH`); args de qualidade; plano do pipeline.
|
||||
- Int: pipeline com fakes (`YTDLP_PATH` fake, transcriber/classificador injetados, `FFMPEG_PATH` fake) — happy path com espelho + segmentos + searchText; falha de download/ASR → falha no relatório sem row fantasma; `--reprocess` re-baixa/re-transcreve; segunda execução não re-baixa nem re-transcreve.
|
||||
3. **CLI + relatório** (~0,75 dia).
|
||||
- `scripts/lib/webSpeeches.mjs` + `scripts/import-web-speeches.mjs`; `package.json`; `.gitignore`.
|
||||
- Unit: dedupe por sourceKey, plano (novo/atualizar/ignorar), agregação do relatório, falhas por estágio.
|
||||
- Int/manual: `--dry-run` sem rede e sem escrita (lê estado do banco); guardas (`FALAS_WEB_IMPORT_CONFIRM`/`assertLocalDatabase`); JSON em `<out>/reports/<timestamp>.json`.
|
||||
4. **Blindagem Câmara** (~0,25 dia).
|
||||
- `origin: camara` em `buildSpeechFacetWhere` (cobre `buildSpeechListWhere` e `buildSpeechListWhereIncludingCutOrigins`), `loadSpeechFilterOptions`, `speechCoverage.ts` (SQL) e `findSpeechExcerpts.ts`.
|
||||
- Unit: where/coverage com o discriminador; int: fala web não aparece na lista, nas opções, na cobertura nem no Sollinha; cobertura da Câmara inalterada.
|
||||
5. **Testes + gates** (~0,5 dia).
|
||||
- Manifest e2e; `pnpm gate:fast`; `pnpm test` (unit + int); `pnpm test:e2e:affected` (se o classifier selecionar specs do acervo); `pnpm push`.
|
||||
|
||||
Quota: ~3,5 dias eng + tempo de máquina (download/ASR), dentro do appetite herdado. Se apertar, o corte é o enriquecimento de metadados pelo `--dump-json` (o lote já traz os campos) — nunca as garantias: idempotência, guardas, relatório e blindagem.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Descoberta/crawler/monitoramento e agendamento (C218); varredura de perfis.
|
||||
- TikTok/Facebook/X, gallery-dl, login/cookies para conteúdo privado.
|
||||
- Segundo pipeline de ASR/classificação/upsert; segundo serving de mídia (C216/C217); segundo resolver de ffmpeg.
|
||||
- Publicar mídia/URL pública; reusar `media` pública; reusar `recordingMedia`; signed URLs.
|
||||
- Transcode/qualidade máxima/masters; geração de thumbnail (só armazena a da origem quando o lote/yt-dlp trouxer; C216 mostra placeholder sem ela); ffprobe.
|
||||
- UI (C216), cortes (C217), paridade de filtros (C219), diarização, edição de transcrição, publicação.
|
||||
- Merge multi-fonte/busca global; item de nav novo; tocar o feed do site ou a Central de Conteúdos (C211).
|
||||
- Dedupe entre plataformas (mesma fala no YouTube e no Instagram) — identidade é por origem; cross-platform é item próprio.
|
||||
- Limpeza de mídia órfã fora do cascade do Speech; retenção/expurgo.
|
||||
- Alterar `sourceKey`/dados da Câmara; editar migrations antigas; `push` de schema.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **yt-dlp ausente ou quebrado por mudança de plataforma.** Fail-closed com hint de instalação; falha por achado no relatório com estágio; rerodar após atualizar; testes injetam fake por `YTDLP_PATH`.
|
||||
- **Instagram bloqueia/expira sem login.** Falha honesta no relatório; fluxo de cookies/login fora da v1 (declarado); o lote pode trazer `mediaUrl` quando a descoberta tiver o arquivo direto.
|
||||
- **Custo/tempo de ASR em horas de vídeo.** `--limit` e `--dry-run`; relatório com segundos e custo; o cap de qualidade evita baixar master (o ASR extrai áudio, não precisa de 4K).
|
||||
- **Disco efêmero em produção (`internetSpeechMedia: true` esquecido).** Registro no plugin no mesmo PR + checklist de aceite; `resolveS3StorageEnv` falha fechado em config S3 parcial; dev/test em disco local.
|
||||
- **Vazamento da fala web para as superfícies Câmara (crédito errado).** D10 + pins unit/int; a fala web nasce invisível à lista/opções/cobertura/Sollinha.
|
||||
- **Duplicata por URL não canônica (tracking params, host, barra final).** `canonicalWebSpeechUrl` unit-pinada + dedupe no lote + `sourceKey` namespaced (índice unique já existente).
|
||||
- **Migração em prod (coluna NOT NULL).** `origin` nasce com default `'camara'` na migration — linhas existentes cobertas; HARD-STOP humano; nunca editar migrations antigas; o deploy aplica pendentes.
|
||||
- **Regressão da Câmara pelo upsert estendido.** Campos web opcionais; `metadataData` da Câmara intocado; os int existentes (`speechImport.int.spec.ts`) são o pino.
|
||||
- **Curadoria manual sobrescrita em `--reprocess`.** Hook `preserveManualFacets` é o pino; int cobre o re-run sobre registro `manual`.
|
||||
- **PR high-risk (migration) roda só o conjunto curado de e2e**, que não inclui `campaignSpeechAcervo` — a blindagem e o pipeline ficam pinados por unit/int; incluir o spec no curado é mudança deliberada de pin, a decidir se a cobertura e2e for exigida.
|
||||
- **Objeto S3 órfão em rollback da transação de upload+upsert.** Best-effort de limpeza no catch; órfão é inofensivo (bucket privado) e reexecutar reusa/sobrescreve.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto: cada fala com data/duração/transcrição com timestamps/facetas/menções/proveniência/origem/canal; mídia espelhada privada e preservada; reexecução não duplica nem sobrescreve curadoria; relatório honesto (achados/novos/atualizados/ignorados/falhas/tempo/custo ASR); sem espelho público, sem segundo cadastro, sem publicação.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: `overrideAccess: false` + `user` onde há ator (leitura C216/C217 nasce no C216; aqui o CLI é ator confiável com bypass justificado); escrita multi-collection em transação (`withPayloadTransaction` + `req`); sem `Consent`/PII novos; `sourceKey` da Câmara intacto; sem rota/URL pública nova; migração nova sem editar antigas; copy pt-BR / identificadores em inglês; `pnpm gate:fast` e `pnpm push`.
|
||||
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: schema/sourceKey/URL canônica; upsert estendido (round-trip, idempotência, `manual`, mídia privada); pipeline com fakes; cascade da mídia; blindagem Câmara (lista/opções/cobertura/Sollinha); reels unit verde após a extração do resolver.
|
||||
- [ ] Migration `add_web_speech_origin` com HARD-STOP humano (mesmo em `--auto`), local-only, revisada antes de aplicar.
|
||||
- [ ] `internetSpeechMedia: true` no plugin S3 (sem isso, produção cai no disco efêmero).
|
||||
- [ ] Manifest e2e atualizado (`src/lib/webSpeech`, `src/collections/Speech.ts`, `src/collections/InternetSpeechMedia.ts`) para não cair no smoke fallback.
|
||||
|
||||
## Execução — desvios do plano (2026-09-24)
|
||||
|
||||
- **Thumbnail fora da transação principal (D5 refinado).** O upload da capa roda em best-effort **antes** da transação (media + upsert) e é limpo se a transação falhar: uma imagem quebrada (o sharp do Payload reprocessa imagens) nunca derruba a fala — o teste int cobre o caminho com um JPEG real. No `--reprocess`, se a capa nova falhar, a antiga é preservada (só é apagada quando há substituta).
|
||||
- **`publishedAt` é obrigatório no schema** (o aceite exige data por fala) e um valor com fuso explícito é convertido para `America/Bahia` antes de virar `speechAt`; data impossível falha o achado em vez de virar mentira no card.
|
||||
- **Pós-simplify:** o `defaultRun` do `reelFfmpeg` (referência órfã após a extração) foi reconectado; `downloadUrlToFile` ganhou teto de bytes (4 GiB); `databaseTarget`/`assertWriteConfirm` viraram helpers do `scripts/lib/cli.mjs` usados pelos dois CLIs; o predicado de completude foi para `src/lib/webSpeech.ts`; a constante de custo do ASR passou a ter dono único (o CLI da Câmara re-exporta a do app).
|
||||
- **`ytDlpBinary` não usa o resolver de `mediaBinaries.mjs`.** Não existe binário empacotado de yt-dlp; o dono `src/utilities/media/ytdlp.ts` resolve `YTDLP_PATH` → PATH com probe `--version` fail-closed. O resolver extraído de `reelFfmpeg.mjs` continua sendo usado só para o ffmpeg (o script resolve com fallback empacotado e seta `FFMPEG_PATH`).
|
||||
- **`upsertSpeechBundle` ganhou `options.req`** e `origin` no bundle (default `'camara'` no create) em vez de um writer novo — o caminho Câmara não mudou.
|
||||
- **Testes existentes pinados:** `speechListFilters.unit.spec.ts` e `speechExcerptsTool.unit.spec.ts` passam a esperar `origin: camara`; os creates de `speech` em int/e2e ganharam `origin: 'camara'` (o campo é required no tipo gerado).
|
||||
- **`scripts/lib/mediaBinaries.mjs` e `scripts/lib/webSpeeches.mjs` entraram no `SCRIPTS_SPEC_PINNED`** (invariante OPS119++ de blast radius).
|
||||
- **Smoke local:** `pnpm falas-web:import --findings … --dry-run` (plano com inválido/duplicado) e a execução real com yt-dlp ausente (falha honesta no relatório + exit 1) rodados contra o banco local do worktree. O run real com mídia é do operador (C218) — a esteira não é executada em produção por este PR.
|
||||
|
||||
## Self-score de decision-quality
|
||||
|
||||
**4,5/5.**
|
||||
|
||||
- **Decisões caras com rejeitadas (1):** as 13 decisões registradas têm opções e rejeições ancoradas em file:line/contrato (`sourceKey` unique da Câmara, relação fixa `SpeechCut.speech`, `preserveManualFacets`, `reelFfmpeg` já testado, `resolveS3StorageEnv` fail-closed, `push: false`, ausência de yt-dlp no repo).
|
||||
- **Cabe no appetite (2):** schema+server → aquisição/pipeline → CLI/relatório → blindagem → testes/gates ≈3,5 dias eng + tempo de máquina, dentro dos ~3–4 dias herdados; tracer cedo no schema/upsert antes do pipeline.
|
||||
- **Rabbit holes nomeados (3):** descoberta/crawler, plataformas fora da v1, segundo pipeline/serving, transcode/master, merge multi-fonte, dedupe cross-platform, limpeza de órfãos.
|
||||
- **Depth check (4):** reusa `upsertSpeechBundle`, `deepInfraTranscribeSegments`, `classifySpeech`, `recordingTranscription`, `privateMediaResponse`, `withPayloadTransaction`, `ensureCachedDownload`, `assertLocalDatabase`; extrai o resolver de binário para um dono neutro em vez de criar um segundo; nada de módulo top-level novo em `src/utilities/`.
|
||||
- **Outcome preservado (5):** a engenharia não reescreveu o produto — a fala web entra no catálogo com as mesmas facetas, privada, idempotente e com relatório honesto; o que ficou de fora (tela, cortes, descoberta) é exatamente o que a intenção cortou.
|
||||
|
||||
Perde 0,5 porque duas pontas ficam condicionadas a confirmação humana/execução fora do código: o HARD-STOP da migration (deliberado) e a cobertura e2e do PR high-risk (conjunto curado não inclui `campaignSpeechAcervo`; a blindagem fica pinada por unit/int) — o plano declara isso em vez de fingir que está coberto.
|
||||
|
|
@ -89,6 +89,7 @@
|
|||
"images:fill": "node scripts/fill-image.mjs",
|
||||
"camara:pilot": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/pilot-camara-speeches.mjs",
|
||||
"camara:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-camara-speeches.mjs",
|
||||
"falas-web:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-web-speeches.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:tsc6": "tsc6 --noEmit",
|
||||
"prepare": "husky",
|
||||
|
|
|
|||
|
|
@ -78,12 +78,11 @@ import {
|
|||
speechSourceKey,
|
||||
} from './lib/camaraSpeeches.mjs'
|
||||
import {
|
||||
databaseHostname,
|
||||
assertWriteConfirm,
|
||||
databaseTarget,
|
||||
dieWithLabel,
|
||||
ensureCachedDownload,
|
||||
isTruthyEnv,
|
||||
loadCliEnv,
|
||||
requiresWriteConfirm,
|
||||
} from './lib/cli.mjs'
|
||||
|
||||
loadCliEnv()
|
||||
|
|
@ -1149,14 +1148,6 @@ const runRepairLinks = async (payload, options) => {
|
|||
}
|
||||
}
|
||||
|
||||
const databaseTarget = () => {
|
||||
const url = process.env.DATABASE_URL
|
||||
const host = databaseHostname(url)
|
||||
if (host === null) return '(DATABASE_URL ausente ou inválida)'
|
||||
// `databaseHostname` only returns non-null when `new URL` already parsed.
|
||||
return `${host}${new URL(url).pathname}`
|
||||
}
|
||||
|
||||
const modeLabel = (options) => {
|
||||
if (options.coverage) return 'coverage (read-only)'
|
||||
if (options.verifyLinks !== null) return `verify-links n=${options.verifyLinks} (read-only)`
|
||||
|
|
@ -1168,21 +1159,6 @@ const modeLabel = (options) => {
|
|||
return `legislature ${options.legislature}`
|
||||
}
|
||||
|
||||
/**
|
||||
* C155 write guard: the homeserver env file sets `NODE_ENV=production` and the
|
||||
* runbook rewrites the DB host to the local proxy, so the host check alone
|
||||
* cannot see production — the explicit flag is what makes the write deliberate.
|
||||
*/
|
||||
const assertWriteAllowed = () => {
|
||||
if (!requiresWriteConfirm()) return
|
||||
if (isTruthyEnv(process.env[WRITE_CONFIRM_FLAG])) return
|
||||
die(
|
||||
`alvo de escrita não-local/produção detectado (${databaseTarget()}).\n` +
|
||||
`Confirme a intenção com:\n ${WRITE_CONFIRM_FLAG}=1 pnpm camara:import …\n` +
|
||||
`Runbook: docs/ops/teqo-1313-deploy.md §C155/§C160.`,
|
||||
)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
|
|
@ -1197,7 +1173,12 @@ async function main() {
|
|||
} else {
|
||||
// Confirm first: a non-local host is exactly what the flag is for; the
|
||||
// local-DB guard below still blocks a remote host without ALLOW_REMOTE_DB.
|
||||
assertWriteAllowed()
|
||||
assertWriteConfirm({
|
||||
label: 'camara:import',
|
||||
flag: WRITE_CONFIRM_FLAG,
|
||||
command: 'pnpm camara:import',
|
||||
hint: 'Runbook: docs/ops/teqo-1313-deploy.md §C155/§C160.',
|
||||
})
|
||||
assertLocalDatabase(
|
||||
'camara:import',
|
||||
'O import escreve no acervo; produção exige CAMARA_IMPORT_CONFIRM=1 (runbook §C155/§C160).',
|
||||
|
|
|
|||
240
scripts/import-web-speeches.mjs
Normal file
240
scripts/import-web-speeches.mjs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
/**
|
||||
* C215 — ingests a batch of web-speech findings (the discovery output of C218)
|
||||
* into the acervo: validate the batch → acquire the media (yt-dlp for
|
||||
* YouTube/Instagram, direct HTTP for radio/audio) → transcribe with timestamps
|
||||
* → classify → mirror the artifact privately → upsert idempotently. Re-running
|
||||
* never duplicates and never overwrites a manual facet curation; every run
|
||||
* prints and writes an honest report (found/new/updated/skipped/failures,
|
||||
* time and ASR cost).
|
||||
*
|
||||
* The report lands under `data/falas-web/reports/` (gitignored); the mirrored
|
||||
* media goes to the private collection (temp files are removed after each
|
||||
* finding). The local-DB guard refuses a non-local DATABASE_URL, and any write
|
||||
* run targeting production (or a remote/override DB) additionally requires
|
||||
* `FALAS_WEB_IMPORT_CONFIRM=1`.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm falas-web:import --findings data/falas-web/lote.json
|
||||
* pnpm falas-web:import --findings lote.json --dry-run
|
||||
* pnpm falas-web:import --findings lote.json --limit 3
|
||||
* pnpm falas-web:import --findings lote.json --reprocess
|
||||
* FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import --findings lote.json
|
||||
*/
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { assertLocalDatabase } from './assert-local-database.mjs'
|
||||
import { assertWriteConfirm, databaseTarget, dieWithLabel, loadCliEnv } from './lib/cli.mjs'
|
||||
import { resolveFfmpeg } from './lib/mediaBinaries.mjs'
|
||||
import {
|
||||
formatWebSpeechReport,
|
||||
planWebSpeechBatch,
|
||||
summarizePlannedActions,
|
||||
summarizeWebSpeechResults,
|
||||
webSpeechPlannedAction,
|
||||
webSpeechReportStamp,
|
||||
} from './lib/webSpeeches.mjs'
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
const die = dieWithLabel('falas-web:import')
|
||||
|
||||
const config = (await import('../src/payload.config.ts')).default
|
||||
const { parseWebSpeechBatch } = await import('../src/lib/webSpeech.ts')
|
||||
const { findSpeechImportState } = await import('../src/utilities/speech/speechImport.ts')
|
||||
const { ingestWebSpeech } = await import('../src/utilities/speech/webSpeechIngest.ts')
|
||||
|
||||
const WRITE_CONFIRM_FLAG = 'FALAS_WEB_IMPORT_CONFIRM'
|
||||
const DEFAULT_OUT_DIR = 'data/falas-web'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const options = {
|
||||
findings: null,
|
||||
out: DEFAULT_OUT_DIR,
|
||||
limit: null,
|
||||
dryRun: false,
|
||||
reprocess: false,
|
||||
help: false,
|
||||
}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
const value = () => {
|
||||
const next = argv[++index]
|
||||
if (next === undefined || next.startsWith('--')) die(`faltou valor para ${arg}.`)
|
||||
return next
|
||||
}
|
||||
if (arg === '--help' || arg === '-h') options.help = true
|
||||
else if (arg === '--findings') options.findings = value()
|
||||
else if (arg === '--out') options.out = value()
|
||||
else if (arg === '--limit') options.limit = Number(value())
|
||||
else if (arg === '--dry-run') options.dryRun = true
|
||||
else if (arg === '--reprocess') options.reprocess = true
|
||||
else die(`argumento desconhecido: ${arg}`)
|
||||
}
|
||||
if (options.help) return options
|
||||
if (!options.findings) die('informe --findings <arquivo.json> (veja --help).')
|
||||
if (options.limit !== null && (!Number.isInteger(options.limit) || options.limit < 1)) {
|
||||
die('--limit deve ser >= 1.')
|
||||
}
|
||||
if (options.out.includes('..')) die('--out não pode escapar do diretório do repo (sem "..").')
|
||||
return options
|
||||
}
|
||||
|
||||
const HELP = `
|
||||
Uso: pnpm falas-web:import --findings <arquivo.json> [opções]
|
||||
|
||||
O lote é o JSON da descoberta (C218): { generatedAt?, findings: [...] }.
|
||||
Cada achado: platform (youtube|instagram|radio|audio), url, publishedAt e —
|
||||
para radio|audio — mediaUrl (arquivo direto). externalId/title/channel/
|
||||
durationSeconds/thumbnailUrl são opcionais e o yt-dlp completa lacunas.
|
||||
|
||||
Opções:
|
||||
--findings <arquivo> lote de achados (obrigatório)
|
||||
--out <dir> diretório dos relatórios (default data/falas-web)
|
||||
--limit <n> processa no máximo n achados
|
||||
--dry-run planeja o lote (lê o catálogo) sem baixar nem escrever
|
||||
--reprocess refaz download/transcrição mesmo com item completo
|
||||
--help esta ajuda
|
||||
|
||||
Sucesso parcial sai com código 0 (as falhas ficam no relatório); código 1
|
||||
quando nenhum achado entrou no catálogo.
|
||||
|
||||
Escrita em alvo não-local ou com NODE_ENV=production exige
|
||||
${WRITE_CONFIRM_FLAG}=1. O binário do yt-dlp vem de YTDLP_PATH ou do PATH;
|
||||
instale com \`pipx install yt-dlp\` (o ffmpeg empacotado é usado se não houver
|
||||
no PATH).
|
||||
`
|
||||
|
||||
const writeReport = async (options, report) => {
|
||||
const reportPath = join(
|
||||
options.out,
|
||||
'reports',
|
||||
`falas-web-${webSpeechReportStamp(report.runAt)}.json`,
|
||||
)
|
||||
await mkdir(dirname(reportPath), { recursive: true })
|
||||
await writeFile(reportPath, JSON.stringify(report, null, 2))
|
||||
return reportPath
|
||||
}
|
||||
|
||||
/** The packaged ffmpeg fallback keeps the operator flow working without a system binary. */
|
||||
const ensureFfmpeg = async () => {
|
||||
if (process.env.FFMPEG_PATH?.trim()) return
|
||||
const resolved = await resolveFfmpeg({
|
||||
audio: true,
|
||||
filters: [],
|
||||
requirementsLabel: 'libmp3lame para extrair o áudio',
|
||||
})
|
||||
process.env.FFMPEG_PATH = resolved.bin
|
||||
console.log(`[falas-web:import] ffmpeg: ${resolved.bin} (${resolved.source})`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
console.log(HELP)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
|
||||
} else {
|
||||
assertWriteConfirm({
|
||||
label: 'falas-web:import',
|
||||
flag: WRITE_CONFIRM_FLAG,
|
||||
command: 'pnpm falas-web:import',
|
||||
})
|
||||
assertLocalDatabase(
|
||||
'falas-web:import',
|
||||
`A ingestão escreve no acervo; produção exige ${WRITE_CONFIRM_FLAG}=1.`,
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
`[falas-web:import] alvo: ${databaseTarget()} | modo: ${options.dryRun ? 'dry-run (read-only)' : options.reprocess ? 'import (--reprocess)' : 'import'}`,
|
||||
)
|
||||
|
||||
const envelope = parseWebSpeechBatch(JSON.parse(await readFile(options.findings, 'utf8')))
|
||||
if (!envelope.ok) die(envelope.error)
|
||||
const plan = planWebSpeechBatch(envelope.batch.findings)
|
||||
const entries = options.limit ? plan.entries.slice(0, options.limit) : plan.entries
|
||||
if (envelope.batch.findings.length > 0 && entries.length === 0) {
|
||||
die('nenhum achado válido no lote — corrija o JSON (inválidos/duplicados no relatório).')
|
||||
}
|
||||
const runAt = new Date().toISOString()
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
const startedAt = Date.now()
|
||||
|
||||
if (options.dryRun) {
|
||||
const planned = []
|
||||
for (const entry of entries) {
|
||||
const state = await findSpeechImportState(payload, entry.sourceKey)
|
||||
planned.push({
|
||||
sourceKey: entry.sourceKey,
|
||||
platform: entry.finding.platform,
|
||||
action: webSpeechPlannedAction(state, options.reprocess),
|
||||
})
|
||||
}
|
||||
const report = {
|
||||
runAt,
|
||||
mode: 'dry-run',
|
||||
batchGeneratedAt: envelope.batch.generatedAt,
|
||||
findings: envelope.batch.findings.length,
|
||||
planned: entries.length,
|
||||
invalid: plan.invalid,
|
||||
duplicates: plan.duplicates,
|
||||
plannedActions: summarizePlannedActions(planned),
|
||||
plannedEntries: planned,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(options, report)
|
||||
for (const line of formatWebSpeechReport({ ...report, failures: [] })) console.log(line)
|
||||
console.log(`\n[falas-web:import] relatório JSON: ${reportPath}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await ensureFfmpeg()
|
||||
|
||||
const results = []
|
||||
for (const entry of entries) {
|
||||
console.log(`[falas-web:import] ${entry.sourceKey}`)
|
||||
const result = await ingestWebSpeech(payload, entry.finding, { reprocess: options.reprocess })
|
||||
results.push(result)
|
||||
console.log(
|
||||
` → ${result.status}${result.stage ? ` (${result.stage})` : ''}${result.error ? `: ${result.error}` : ''}`,
|
||||
)
|
||||
}
|
||||
|
||||
const summary = summarizeWebSpeechResults(results)
|
||||
const report = {
|
||||
runAt,
|
||||
mode: 'import',
|
||||
batchGeneratedAt: envelope.batch.generatedAt,
|
||||
findings: envelope.batch.findings.length,
|
||||
planned: entries.length,
|
||||
invalid: plan.invalid,
|
||||
duplicates: plan.duplicates,
|
||||
reprocess: options.reprocess,
|
||||
...summary,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(options, report)
|
||||
for (const line of formatWebSpeechReport(report)) console.log(line)
|
||||
console.log(`\n[falas-web:import] relatório JSON: ${reportPath}`)
|
||||
|
||||
const succeeded = summary.created + summary.updated + summary.skipped
|
||||
if (summary.failed > 0 && succeeded === 0) {
|
||||
die('nenhum achado entrou no catálogo — falhas listadas no relatório.')
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
die(error?.message || String(error))
|
||||
})
|
||||
|
|
@ -216,7 +216,12 @@ export async function resolveOfficialPdfUrl(legacyUrl, { timeoutMs = 180_000, at
|
|||
throw lastError
|
||||
}
|
||||
|
||||
export const DEEPINFRA_COST_PER_MINUTE_USD = 0.00045
|
||||
/**
|
||||
* Provider price (~US$ per audio minute). The app-side owner is
|
||||
* `src/utilities/ai/deepInfraTranscribe.ts` (C215) — re-exported here so the
|
||||
* Câmara scripts and the web ingestion always report the same number.
|
||||
*/
|
||||
export { DEEPINFRA_TRANSCRIBE_COST_PER_MINUTE_USD as DEEPINFRA_COST_PER_MINUTE_USD } from '../../src/utilities/ai/deepInfraTranscribe.ts'
|
||||
|
||||
/**
|
||||
* Sends the excerpt MP4 (video/mp4 is accepted; no ffmpeg) to Deep Infra's
|
||||
|
|
|
|||
|
|
@ -194,6 +194,33 @@ export const requiresWriteConfirm = ({
|
|||
allowRemoteDb = isRemoteDbOverrideSet(),
|
||||
} = {}) => nodeEnv === 'production' || allowRemoteDb || !isLocalDatabaseUrl(databaseUrl)
|
||||
|
||||
/** Human label of the write target for the guard messages (`host/name`). */
|
||||
export const databaseTarget = () => {
|
||||
const url = process.env.DATABASE_URL
|
||||
const host = databaseHostname(url)
|
||||
if (host === null) return '(DATABASE_URL ausente ou inválida)'
|
||||
return `${host}${new URL(url).pathname}`
|
||||
}
|
||||
|
||||
/**
|
||||
* C155/C215 write guard shared by the import CLIs: a non-local/production
|
||||
* target (or the `ALLOW_REMOTE_DB` escape) refuses the run unless the command's
|
||||
* own intent flag is set. The homeserver env sets `NODE_ENV=production` and the
|
||||
* proxy rewrites the DB host, so the explicit flag is what makes the write
|
||||
* deliberate.
|
||||
*
|
||||
* @param {{ label: string, flag: string, command: string, hint?: string }} options
|
||||
*/
|
||||
export const assertWriteConfirm = ({ label, flag, command, hint = '' }) => {
|
||||
if (!requiresWriteConfirm()) return
|
||||
if (isTruthyEnv(process.env[flag])) return
|
||||
dieWithLabel(label)(
|
||||
`alvo de escrita não-local/produção detectado (${databaseTarget()}).\n` +
|
||||
`Confirme a intenção com:\n ${flag}=1 ${command} …` +
|
||||
(hint ? `\n${hint}` : ''),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Port for `next dev` (OPS40). Next's CLI resolves its port via commander's
|
||||
* `.env('PORT')` BEFORE `@next/env` loads `.env.local`, so a `PORT` written by
|
||||
|
|
|
|||
|
|
@ -454,6 +454,11 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'src/utilities/privateMedia',
|
||||
'src/utilities/media',
|
||||
'src/lib/speech',
|
||||
// C215 — the web-speech contract, its collection and the private mirror
|
||||
// collection drive the same acervo vertical.
|
||||
'src/lib/webSpeech',
|
||||
'src/collections/Speech.ts',
|
||||
'src/collections/InternetSpeechMedia.ts',
|
||||
'src/lib/reel',
|
||||
'src/lib/recording',
|
||||
'src/lib/contentPiece',
|
||||
|
|
|
|||
149
scripts/lib/mediaBinaries.mjs
Normal file
149
scripts/lib/mediaBinaries.mjs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Generic external-binary plumbing of the CLI tools: a process runner that
|
||||
* never throws, a fail-closed capability probe and the ffmpeg resolution
|
||||
* (`FFMPEG_PATH` → `ffmpeg` on PATH → the registry-packaged
|
||||
* `@ffmpeg-installer/ffmpeg` binary). Extracted from the reel composer (C196)
|
||||
* so the web-speech ingestion (C215) reuses the same owner instead of a second
|
||||
* resolver.
|
||||
*
|
||||
* Resolution order (see `docs/plans/reels-tutoriais-impl.md`, Decisão 2): the
|
||||
* packed fallback keeps the non-technical operator flow working on a
|
||||
* workstation without a system ffmpeg; the homeserver Docker build installs it
|
||||
* from npm (never from GitHub releases, which the homeserver cannot reach — see
|
||||
* the sharp override note in `pnpm-workspace.yaml`).
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const DEFAULT_MAX_BUFFER_BYTES = 16 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* @typedef {{ ok: boolean, stdout: string, stderr: string, error?: { code?: string } }} RunResult
|
||||
* @typedef {(bin: string, args: string[]) => Promise<RunResult>} RunFn
|
||||
*/
|
||||
|
||||
/**
|
||||
* Runs a process and returns the exit outcome instead of throwing.
|
||||
*
|
||||
* @type {(bin: string, args: string[]) => Promise<RunResult>}
|
||||
*/
|
||||
export const runProcess = async (bin, args) => {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(bin, args, {
|
||||
maxBuffer: DEFAULT_MAX_BUFFER_BYTES,
|
||||
})
|
||||
return { ok: true, stdout, stderr }
|
||||
} catch (error) {
|
||||
return { ok: false, stdout: error.stdout ?? '', stderr: error.stderr ?? '', error }
|
||||
}
|
||||
}
|
||||
|
||||
const defaultRun = runProcess
|
||||
|
||||
const versionLine = (stdout) =>
|
||||
stdout.split('\n').find((line) => line.startsWith('ffmpeg version')) ?? ''
|
||||
|
||||
/**
|
||||
* Capability probe: the binary must run and ship every required encoder and
|
||||
* filter. Missing capabilities are reported, never assumed.
|
||||
*
|
||||
* @param {string} bin
|
||||
* @param {{ run?: RunFn, audio?: boolean, encoders?: string[], filters?: string[] }} [options]
|
||||
* @returns {Promise<{ ok: true, bin: string, version: string } | { ok: false, bin: string, reason: string }>}
|
||||
*/
|
||||
export const probeFfmpeg = async (
|
||||
bin,
|
||||
{ run = defaultRun, audio = false, encoders = [], filters = [] } = {},
|
||||
) => {
|
||||
const version = await run(bin, ['-hide_banner', '-version'])
|
||||
if (!version.ok) {
|
||||
return { ok: false, bin, reason: `não executou (${version.error?.code ?? 'erro'})` }
|
||||
}
|
||||
|
||||
const requiredEncoders = [...encoders, ...(audio ? ['libmp3lame'] : [])]
|
||||
if (requiredEncoders.length > 0) {
|
||||
const listed = await run(bin, ['-hide_banner', '-encoders'])
|
||||
const missing = requiredEncoders.filter(
|
||||
(encoder) => !listed.ok || !new RegExp(`\\b${encoder}\\b`).test(listed.stdout),
|
||||
)
|
||||
if (missing.length > 0) return { ok: false, bin, reason: `sem encoder ${missing.join(', ')}` }
|
||||
}
|
||||
|
||||
if (filters.length > 0) {
|
||||
const listed = await run(bin, ['-hide_banner', '-filters'])
|
||||
if (!listed.ok) return { ok: false, bin, reason: 'não listou filtros' }
|
||||
const missing = filters.filter((filter) => !new RegExp(`\\b${filter}\\b`).test(listed.stdout))
|
||||
if (missing.length > 0)
|
||||
return { ok: false, bin, reason: `sem filtro(s): ${missing.join(', ')}` }
|
||||
}
|
||||
|
||||
return { ok: true, bin, version: versionLine(version.stdout) }
|
||||
}
|
||||
|
||||
const FFMPEG_INSTALL_HINT =
|
||||
'Defina FFMPEG_PATH, instale o ffmpeg (ex.: `sudo apt install ffmpeg`) ou rode `pnpm install` para o binário empacotado.'
|
||||
|
||||
/**
|
||||
* Resolves the first candidate that passes the probe. Explicit `FFMPEG_PATH` is strict.
|
||||
*
|
||||
* @param {{ env?: Record<string, string | undefined>, run?: RunFn, installer?: { path?: string } | null, audio?: boolean, encoders?: string[], filters?: string[], requirementsLabel?: string, installHint?: string }} [options]
|
||||
* @returns {Promise<{ ok: true, bin: string, version: string, source: string }>}
|
||||
*/
|
||||
export const resolveFfmpeg = async ({
|
||||
env = process.env,
|
||||
run = defaultRun,
|
||||
installer,
|
||||
audio = false,
|
||||
encoders = [],
|
||||
filters = [],
|
||||
requirementsLabel = '',
|
||||
installHint = FFMPEG_INSTALL_HINT,
|
||||
} = {}) => {
|
||||
let packaged = installer
|
||||
if (packaged === undefined) {
|
||||
try {
|
||||
packaged = (await import('@ffmpeg-installer/ffmpeg')).default
|
||||
} catch {
|
||||
// The packaged binary throws on import when its platform package is
|
||||
// missing; the probe below decides, never the import.
|
||||
packaged = null
|
||||
}
|
||||
}
|
||||
const candidates = []
|
||||
const explicit = env.FFMPEG_PATH?.trim()
|
||||
if (explicit) candidates.push({ bin: explicit, source: 'FFMPEG_PATH', strict: true })
|
||||
candidates.push({ bin: 'ffmpeg', source: 'PATH', strict: false })
|
||||
if (packaged?.path) candidates.push({ bin: packaged.path, source: 'empacotado', strict: false })
|
||||
|
||||
const failures = []
|
||||
for (const candidate of candidates) {
|
||||
const probe = await probeFfmpeg(candidate.bin, { run, audio, encoders, filters })
|
||||
if (probe.ok) {
|
||||
return { ok: true, bin: probe.bin, version: probe.version, source: candidate.source }
|
||||
}
|
||||
failures.push(`${candidate.source}: ${probe.reason}`)
|
||||
if (candidate.strict) break
|
||||
}
|
||||
|
||||
const suffix = requirementsLabel ? ` (${requirementsLabel})` : ''
|
||||
throw new Error(`Nenhum ffmpeg utilizável${suffix}. ${failures.join('; ')}. ${installHint}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a process and fails with the stderr tail — never swallows the exit
|
||||
* code. Shared by the CLI tools (ffmpeg and edge-tts).
|
||||
*
|
||||
* @param {string} bin
|
||||
* @param {string[]} args
|
||||
* @param {{ run?: RunFn, label?: string }} [options]
|
||||
*/
|
||||
export const runTool = async (bin, args, { run = defaultRun, label = 'processo' } = {}) => {
|
||||
const result = await run(bin, args)
|
||||
if (!result.ok) {
|
||||
const tail = (result.stderr || result.error?.message || '').trim().slice(-400)
|
||||
throw new Error(`${label} falhou${tail ? `: ${tail}` : ''}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -1,29 +1,24 @@
|
|||
/**
|
||||
* C196 — ffmpeg plumbing of the reel composer: binary resolution with a
|
||||
* fail-closed capability probe, the per-scene clip command (zoompan + burned
|
||||
* caption overlays) and the concat that stitches the scenes into `reel.mp4`.
|
||||
*
|
||||
* Resolution order (see `docs/plans/reels-tutoriais-impl.md`, Decisão 2):
|
||||
* `FFMPEG_PATH` → `ffmpeg` on PATH → the registry-packaged
|
||||
* `@ffmpeg-installer/ffmpeg` binary. The packed fallback keeps the
|
||||
* non-technical operator flow working on a workstation without a system
|
||||
* ffmpeg; the homeserver Docker build installs it from npm (never from GitHub
|
||||
* releases, which the homeserver cannot reach — see the sharp override note in
|
||||
* `pnpm-workspace.yaml`).
|
||||
* C196 — ffmpeg plumbing of the reel composer: the per-scene clip command
|
||||
* (zoompan + burned caption overlays) and the concat that stitches the scenes
|
||||
* into `reel.mp4`. The generic binary resolution/probe/runner (C215 moved them
|
||||
* to `mediaBinaries.mjs`) is re-exported here so the reel tools and their unit
|
||||
* pins keep the same import surface; the reel requirements (libx264 + the
|
||||
* composer filters, `atempo` when audio) are injected into the wrappers.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
import {
|
||||
probeFfmpeg as probeFfmpegBin,
|
||||
resolveFfmpeg as resolveFfmpegBin,
|
||||
runProcess,
|
||||
runTool,
|
||||
} from './mediaBinaries.mjs'
|
||||
import { NARRATION_PCM } from './reelScript.mjs'
|
||||
import { seconds } from './reelTimeline.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const MAX_BUFFER_BYTES = 16 * 1024 * 1024
|
||||
|
||||
const REQUIRED_FILTERS = ['fade', 'overlay', 'scale', 'zoompan']
|
||||
const AUDIO_REQUIRED_ENCODER = 'libmp3lame'
|
||||
const AUDIO_REQUIRED_FILTERS = ['atempo']
|
||||
export const REEL_PRESET = 'medium'
|
||||
export const REEL_CRF = 18
|
||||
|
|
@ -38,25 +33,7 @@ export const PREVIEW_CRF = 14
|
|||
* @typedef {(bin: string, args: string[]) => Promise<{ ok: boolean, stdout: string, stderr: string, error?: { code?: string } }>} RunFn
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generic process runner shared by the reel tools (ffmpeg and edge-tts): never
|
||||
* throws, returns the exit outcome.
|
||||
*
|
||||
* @type {RunFn}
|
||||
*/
|
||||
export const runProcess = async (bin, args) => {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(bin, args, { maxBuffer: MAX_BUFFER_BYTES })
|
||||
return { ok: true, stdout, stderr }
|
||||
} catch (error) {
|
||||
return { ok: false, stdout: error.stdout ?? '', stderr: error.stderr ?? '', error }
|
||||
}
|
||||
}
|
||||
|
||||
const defaultRun = runProcess
|
||||
|
||||
const versionLine = (stdout) =>
|
||||
stdout.split('\n').find((line) => line.startsWith('ffmpeg version')) ?? ''
|
||||
export { runProcess, runTool }
|
||||
|
||||
/**
|
||||
* Capability probe: the binary must encode H.264 (libx264) and ship the
|
||||
|
|
@ -66,90 +43,28 @@ const versionLine = (stdout) =>
|
|||
* @param {{ run?: RunFn, audio?: boolean }} [options]
|
||||
* @returns {Promise<{ ok: true, bin: string, version: string } | { ok: false, bin: string, reason: string }>}
|
||||
*/
|
||||
export const probeFfmpeg = async (bin, { run = defaultRun, audio = false } = {}) => {
|
||||
const version = await run(bin, ['-hide_banner', '-version'])
|
||||
if (!version.ok)
|
||||
return { ok: false, bin, reason: `não executou (${version.error?.code ?? 'erro'})` }
|
||||
const encoders = await run(bin, ['-hide_banner', '-encoders'])
|
||||
if (!encoders.ok || !/\blibx264\b/.test(encoders.stdout)) {
|
||||
return { ok: false, bin, reason: 'sem encoder libx264' }
|
||||
}
|
||||
if (audio && !new RegExp(`\\b${AUDIO_REQUIRED_ENCODER}\\b`).test(encoders.stdout)) {
|
||||
return { ok: false, bin, reason: `sem encoder ${AUDIO_REQUIRED_ENCODER}` }
|
||||
}
|
||||
const filters = await run(bin, ['-hide_banner', '-filters'])
|
||||
if (!filters.ok) return { ok: false, bin, reason: 'não listou filtros' }
|
||||
const requiredFilters = audio
|
||||
? [...REQUIRED_FILTERS, ...AUDIO_REQUIRED_FILTERS]
|
||||
: REQUIRED_FILTERS
|
||||
const missing = requiredFilters.filter(
|
||||
(filter) => !new RegExp(`\\b${filter}\\b`).test(filters.stdout),
|
||||
)
|
||||
if (missing.length > 0) return { ok: false, bin, reason: `sem filtro(s): ${missing.join(', ')}` }
|
||||
return { ok: true, bin, version: versionLine(version.stdout) }
|
||||
}
|
||||
|
||||
const installHint =
|
||||
'Defina FFMPEG_PATH, instale o ffmpeg (ex.: `sudo apt install ffmpeg`) ou rode `pnpm install` para o binário empacotado.'
|
||||
export const probeFfmpeg = (bin, { audio = false, ...options } = {}) =>
|
||||
probeFfmpegBin(bin, {
|
||||
...options,
|
||||
audio,
|
||||
encoders: ['libx264'],
|
||||
filters: [...REQUIRED_FILTERS, ...(audio ? AUDIO_REQUIRED_FILTERS : [])],
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolves the first candidate that passes the probe. Explicit `FFMPEG_PATH` is strict.
|
||||
* Resolves the first candidate that passes the reel probe. Explicit
|
||||
* `FFMPEG_PATH` is strict.
|
||||
*
|
||||
* @param {{ env?: Record<string, string | undefined>, run?: RunFn, installer?: { path?: string } | null, audio?: boolean }} [options]
|
||||
* @returns {Promise<{ ok: true, bin: string, version: string, source: string }>}
|
||||
*/
|
||||
export const resolveFfmpeg = async ({
|
||||
env = process.env,
|
||||
run = defaultRun,
|
||||
installer,
|
||||
audio = false,
|
||||
} = {}) => {
|
||||
let packaged = installer
|
||||
if (packaged === undefined) {
|
||||
try {
|
||||
packaged = (await import('@ffmpeg-installer/ffmpeg')).default
|
||||
} catch {
|
||||
// The packaged binary throws on import when its platform package is
|
||||
// missing; the probe below decides, never the import.
|
||||
packaged = null
|
||||
}
|
||||
}
|
||||
const candidates = []
|
||||
const explicit = env.FFMPEG_PATH?.trim()
|
||||
if (explicit) candidates.push({ bin: explicit, source: 'FFMPEG_PATH', strict: true })
|
||||
candidates.push({ bin: 'ffmpeg', source: 'PATH', strict: false })
|
||||
if (packaged?.path) candidates.push({ bin: packaged.path, source: 'empacotado', strict: false })
|
||||
const failures = []
|
||||
for (const candidate of candidates) {
|
||||
const probe = await probeFfmpeg(candidate.bin, { run, audio })
|
||||
if (probe.ok) {
|
||||
return { ok: true, bin: probe.bin, version: probe.version, source: candidate.source }
|
||||
}
|
||||
failures.push(`${candidate.source}: ${probe.reason}`)
|
||||
if (candidate.strict) break
|
||||
}
|
||||
throw new Error(
|
||||
`Nenhum ffmpeg utilizável (libx264 + filtros do reel${audio ? ' + áudio' : ''}). ${failures.join('; ')}. ${installHint}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a process and fails with the stderr tail — never swallows the exit
|
||||
* code. Shared by the reel tools (ffmpeg and edge-tts); `runFfmpeg` is the
|
||||
* ffmpeg-labelled alias kept for the historical call sites.
|
||||
*
|
||||
* @param {string} bin
|
||||
* @param {string[]} args
|
||||
* @param {{ run?: RunFn, label?: string }} [options]
|
||||
*/
|
||||
export const runTool = async (bin, args, { run = defaultRun, label = 'processo' } = {}) => {
|
||||
const result = await run(bin, args)
|
||||
if (!result.ok) {
|
||||
const tail = (result.stderr || result.error?.message || '').trim().slice(-400)
|
||||
throw new Error(`${label} falhou${tail ? `: ${tail}` : ''}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
export const resolveFfmpeg = (options = {}) =>
|
||||
resolveFfmpegBin({
|
||||
requirementsLabel: `libx264 + filtros do reel${options.audio ? ' + áudio' : ''}`,
|
||||
...options,
|
||||
encoders: ['libx264'],
|
||||
filters: [...REQUIRED_FILTERS, ...(options.audio ? AUDIO_REQUIRED_FILTERS : [])],
|
||||
})
|
||||
|
||||
/** @type {typeof runTool} */
|
||||
export const runFfmpeg = (bin, args, options = {}) =>
|
||||
|
|
@ -284,7 +199,7 @@ export const encodeFrames = async ({
|
|||
preset = PREVIEW_PRESET,
|
||||
crf = PREVIEW_CRF,
|
||||
writeFileImpl = writeFile,
|
||||
run = defaultRun,
|
||||
run = runProcess,
|
||||
}) => {
|
||||
await writeFileImpl(listPath, buildFrameListContent({ frames, fps }))
|
||||
await runFfmpeg(
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export const SCRIPTS_SPEC_PINNED = [
|
|||
'scripts/lib/imageResize.mjs',
|
||||
'scripts/lib/issues-panel.mjs',
|
||||
'scripts/lib/markdown-ansi.mjs',
|
||||
'scripts/lib/mediaBinaries.mjs',
|
||||
'scripts/lib/plansOnlyClosesGuard.mjs',
|
||||
'scripts/lib/playwright-e2e-args.mjs',
|
||||
'scripts/lib/portalTransparenciaEmendas.mjs',
|
||||
|
|
@ -99,6 +100,7 @@ export const SCRIPTS_SPEC_PINNED = [
|
|||
'scripts/lib/staging-test-account.mjs',
|
||||
'scripts/lib/test-affected-core.mjs',
|
||||
'scripts/lib/testing-audit-metrics-core.mjs',
|
||||
'scripts/lib/webSpeeches.mjs',
|
||||
'scripts/lib/worktree-env.mjs',
|
||||
'scripts/lib/worktree.mjs',
|
||||
'scripts/lib/wpArticles.mjs',
|
||||
|
|
|
|||
131
scripts/lib/webSpeeches.mjs
Normal file
131
scripts/lib/webSpeeches.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* C215 — pure planning/reporting of the web-speech ingestion CLI: validate the
|
||||
* batch (one invalid finding fails only itself), dedupe by natural key, decide
|
||||
* the planned action per entry and aggregate the run report. No I/O: the unit
|
||||
* specs read this module and `import-web-speeches.mjs` is the impure shell.
|
||||
*/
|
||||
|
||||
import { formatSpeechSpan } from '../../src/lib/speechClock.ts'
|
||||
import {
|
||||
isCompleteWebSpeechState,
|
||||
parseWebSpeechFinding,
|
||||
webSpeechSourceKey,
|
||||
} from '../../src/lib/webSpeech.ts'
|
||||
|
||||
/** @typedef {import('../../src/lib/webSpeech.ts').WebSpeechFinding} WebSpeechFinding */
|
||||
/** @typedef {import('../../src/utilities/speech/webSpeechIngest.ts').WebSpeechIngestResult} WebSpeechIngestResult */
|
||||
|
||||
export { isCompleteWebSpeechState }
|
||||
|
||||
/**
|
||||
* @param {unknown[]} findings
|
||||
* @returns {{ entries: Array<{ index: number, sourceKey: string, finding: WebSpeechFinding }>, invalid: Array<{ index: number, error: string }>, duplicates: Array<{ index: number, sourceKey: string }> }}
|
||||
*/
|
||||
export const planWebSpeechBatch = (findings) => {
|
||||
const entries = []
|
||||
const invalid = []
|
||||
const duplicates = []
|
||||
const seen = new Set()
|
||||
|
||||
findings.forEach((raw, index) => {
|
||||
const parsed = parseWebSpeechFinding(raw)
|
||||
if (!parsed.ok) {
|
||||
invalid.push({ index, error: parsed.error })
|
||||
return
|
||||
}
|
||||
const sourceKey = webSpeechSourceKey(parsed.finding)
|
||||
if (seen.has(sourceKey)) {
|
||||
duplicates.push({ index, sourceKey })
|
||||
return
|
||||
}
|
||||
seen.add(sourceKey)
|
||||
entries.push({ index, sourceKey, finding: parsed.finding })
|
||||
})
|
||||
|
||||
return { entries, invalid, duplicates }
|
||||
}
|
||||
|
||||
/** What a re-run would do to an entry, from its stored state. */
|
||||
export const webSpeechPlannedAction = (state, reprocess = false) => {
|
||||
if (state === null) return 'novo'
|
||||
if (!isCompleteWebSpeechState(state)) return 'atualizado'
|
||||
return reprocess ? 'reprocessado' : 'ignorado'
|
||||
}
|
||||
|
||||
/** @param {WebSpeechIngestResult[]} results */
|
||||
export const summarizeWebSpeechResults = (results) => {
|
||||
const count = (status) => results.filter((result) => result.status === status).length
|
||||
const sum = (pick) => results.reduce((total, result) => total + (Number(pick(result)) || 0), 0)
|
||||
|
||||
return {
|
||||
created: count('created'),
|
||||
updated: count('updated'),
|
||||
skipped: count('skipped'),
|
||||
failed: count('failed'),
|
||||
asrSeconds: sum((result) => result.asrSeconds),
|
||||
asrCostUsd: sum((result) => result.asrCostUsd),
|
||||
llmCostUsd: sum((result) => result.llmCostUsd),
|
||||
failures: results
|
||||
.filter((result) => result.status === 'failed')
|
||||
.map((result) => ({
|
||||
sourceKey: result.sourceKey,
|
||||
stage: result.stage ?? null,
|
||||
error: result.error ?? null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Array<{ action: string }>} planned */
|
||||
export const summarizePlannedActions = (planned) => ({
|
||||
novo: planned.filter((entry) => entry.action === 'novo').length,
|
||||
atualizado: planned.filter((entry) => entry.action === 'atualizado').length,
|
||||
ignorado: planned.filter((entry) => entry.action === 'ignorado').length,
|
||||
reprocessado: planned.filter((entry) => entry.action === 'reprocessado').length,
|
||||
})
|
||||
|
||||
const secondsLabel = (seconds) => formatSpeechSpan(Math.round(seconds))
|
||||
|
||||
/**
|
||||
* Human lines for stdout; the JSON file keeps the full report. Failures are
|
||||
* never hidden — each one names its stage and reason.
|
||||
*
|
||||
* @param {ReturnType<typeof summarizeWebSpeechResults> & { runAt: string, mode: string, findings: number, planned: number, invalid: Array<{index: number, error: string}>, duplicates: Array<{index: number, sourceKey: string}>, durationMs: number, plannedActions?: Record<string, number>|null }} report
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export const formatWebSpeechReport = (report) => {
|
||||
const label = '[falas-web:import]'
|
||||
const lines = []
|
||||
lines.push(
|
||||
`${label} achados: ${report.findings} · considerados: ${report.planned} · inválidos: ${report.invalid.length} · duplicados: ${report.duplicates.length}`,
|
||||
)
|
||||
|
||||
if (report.plannedActions) {
|
||||
const actions = report.plannedActions
|
||||
lines.push(
|
||||
`${label} plano (dry-run): novos: ${actions.novo} · atualizados: ${actions.atualizado} · ignorados: ${actions.ignorado} · reprocessados: ${actions.reprocessado}`,
|
||||
)
|
||||
} else {
|
||||
const summary = report
|
||||
lines.push(
|
||||
`${label} novos: ${summary.created} · atualizados: ${summary.updated} · ignorados: ${summary.skipped} · falhas: ${summary.failed}`,
|
||||
)
|
||||
lines.push(
|
||||
`${label} tempo: ${(report.durationMs / 1000).toFixed(1)}s · ASR: ${secondsLabel(summary.asrSeconds)} (~US$ ${summary.asrCostUsd.toFixed(4)}) · LLM: US$ ${summary.llmCostUsd.toFixed(4)}`,
|
||||
)
|
||||
}
|
||||
|
||||
for (const failure of report.invalid) {
|
||||
lines.push(`${label} inválido #${failure.index}: ${failure.error}`)
|
||||
}
|
||||
if (report.failures?.length) {
|
||||
lines.push(`${label} falhas:`)
|
||||
for (const failure of report.failures) {
|
||||
lines.push(
|
||||
` - ${failure.sourceKey} (${failure.stage ?? 'desconhecido'}): ${failure.error ?? 'erro'}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
export const webSpeechReportStamp = (runAt) => runAt.replace(/[:.]/g, '-')
|
||||
44
src/collections/InternetSpeechMedia.ts
Normal file
44
src/collections/InternetSpeechMedia.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { INTERNET_SPEECH_MEDIA_SLUG } from '@/lib/webSpeech'
|
||||
import { canReadSpeech, payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
|
||||
/**
|
||||
* C215 — private upload collection of a web speech's mirrored artifact (the
|
||||
* media and the optional thumbnail). Like `recordingMedia`, deliberately NOT
|
||||
* `media`: that collection is anonymous-read by contract (public pages), while
|
||||
* a third-party speech file may only be read by the communication roles. The
|
||||
* browser reaches the file through the authenticated acervo route (C216),
|
||||
* never the public `/api/media/file` proxy.
|
||||
*/
|
||||
|
||||
export const InternetSpeechMedia: CollectionConfig = {
|
||||
slug: INTERNET_SPEECH_MEDIA_SLUG,
|
||||
labels: {
|
||||
singular: 'Mídia de fala da internet',
|
||||
plural: 'Mídias de falas da internet',
|
||||
},
|
||||
admin: {
|
||||
group: 'Comunicação',
|
||||
description:
|
||||
'Arquivos privados das falas encontradas na internet (mídia espelhada e capa). Só abrem com login da campanha.',
|
||||
},
|
||||
access: {
|
||||
create: payloadAdminOnly,
|
||||
read: canReadSpeech,
|
||||
update: payloadAdminOnly,
|
||||
delete: payloadAdminOnly,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
label: 'Texto alternativo',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Descrição do arquivo para acessibilidade.',
|
||||
},
|
||||
},
|
||||
],
|
||||
upload: true,
|
||||
}
|
||||
|
|
@ -6,15 +6,20 @@ import type {
|
|||
} from 'payload'
|
||||
|
||||
import { SPEECH_CLASSIFICATION_SOURCES, SPEECH_SCOPES, SPEECH_TOPICS } from '@/lib/speechFacets'
|
||||
import { INTERNET_SPEECH_MEDIA_SLUG, WEB_SPEECH_PLATFORMS } from '@/lib/webSpeech'
|
||||
import { canReadSpeech, canUpdateSpeech, payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
|
||||
/**
|
||||
* Speech catalog (C153) — one record per Jorge Solla speech in the Câmara,
|
||||
* with the official transcript (taquigrafia), the ASR segments with timestamps
|
||||
* (speechSegment), curated facets and extracted mentions.
|
||||
* Speech catalog (C153) — one record per Jorge Solla speech: the Câmara
|
||||
* speeches with the official transcript (taquigrafia), ASR segments with
|
||||
* timestamps (speechSegment), curated facets and extracted mentions, plus the
|
||||
* web speeches (C215) mirrored from the internet with the same facets and
|
||||
* segments.
|
||||
*
|
||||
* Data provenance: Câmara open data + VOD, CC BY 4.0 — keep the credit.
|
||||
* Identity is the API speech (`sourceKey`), never the regenerable VOD URL.
|
||||
* Câmara data provenance: open data + VOD, CC BY 4.0 — keep the credit. Web
|
||||
* rows carry their own origin (platform + URL) and a private mirrored file.
|
||||
* Identity is the source (`sourceKey`): the Câmara API speech or the web
|
||||
* namespace (`web:<platform>:<externalId|url>`), never a regenerable URL.
|
||||
*/
|
||||
|
||||
const FACET_FIELDS = [
|
||||
|
|
@ -56,8 +61,22 @@ const preserveManualFacets: CollectionBeforeChangeHook = ({
|
|||
return data
|
||||
}
|
||||
|
||||
/** Segments are owned by the speech; Payload relationships do not cascade. */
|
||||
const deleteSpeechSegments: CollectionBeforeDeleteHook = async ({ id, req }) => {
|
||||
/** Speech assets are owned by the speech; Payload relationships do not cascade. */
|
||||
const deleteSpeechAssets: CollectionBeforeDeleteHook = async ({ id, req }) => {
|
||||
const speech = await req.payload.findByID({
|
||||
collection: 'speech',
|
||||
id,
|
||||
depth: 0,
|
||||
select: { mirroredMedia: true, thumbnail: true },
|
||||
req,
|
||||
// Intentional bypass: the cascade is owned by this hook; media access is the
|
||||
// acervo gate and this runs inside the authorized speech delete request.
|
||||
overrideAccess: true,
|
||||
})
|
||||
const mediaIds = [speech?.mirroredMedia, speech?.thumbnail]
|
||||
.map((media) => (typeof media === 'object' && media !== null ? media.id : media))
|
||||
.filter((mediaId): mediaId is number => typeof mediaId === 'number')
|
||||
|
||||
await req.payload.delete({
|
||||
collection: 'speechSegment',
|
||||
where: { speech: { equals: id } },
|
||||
|
|
@ -66,6 +85,15 @@ const deleteSpeechSegments: CollectionBeforeDeleteHook = async ({ id, req }) =>
|
|||
// admin-only and this runs inside the authorized speech delete request.
|
||||
overrideAccess: true,
|
||||
})
|
||||
if (mediaIds.length > 0) {
|
||||
await req.payload.delete({
|
||||
collection: INTERNET_SPEECH_MEDIA_SLUG,
|
||||
where: { id: { in: mediaIds } },
|
||||
req,
|
||||
// Intentional bypass: same cascade, now over the private mirrored files.
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Speech: CollectionConfig = {
|
||||
|
|
@ -77,9 +105,9 @@ export const Speech: CollectionConfig = {
|
|||
admin: {
|
||||
group: 'Comunicação',
|
||||
useAsTitle: 'speechAt',
|
||||
defaultColumns: ['speechAt', 'type', 'phase', 'topics', 'classifiedBy'],
|
||||
defaultColumns: ['speechAt', 'origin', 'platform', 'type', 'topics', 'classifiedBy'],
|
||||
description:
|
||||
'Acervo de falas do deputado na Câmara. Dados e vídeos da Câmara dos Deputados (CC BY 4.0).',
|
||||
'Acervo de falas do deputado: discursos na Câmara (CC BY 4.0) e falas publicadas na internet (mídia espelhada privada).',
|
||||
},
|
||||
access: {
|
||||
create: payloadAdminOnly,
|
||||
|
|
@ -90,7 +118,7 @@ export const Speech: CollectionConfig = {
|
|||
hooks: {
|
||||
beforeValidate: [deriveSpeechYear],
|
||||
beforeChange: [preserveManualFacets],
|
||||
beforeDelete: [deleteSpeechSegments],
|
||||
beforeDelete: [deleteSpeechAssets],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
|
|
@ -102,7 +130,24 @@ export const Speech: CollectionConfig = {
|
|||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Identidade da fala na API da Câmara (data/hora + tipo + fase).',
|
||||
description: 'Identidade da fala: API da Câmara ou web:<plataforma>:<id|url>.',
|
||||
},
|
||||
},
|
||||
{
|
||||
// C215 — discriminator of the catalog source. The Câmara surfaces filter
|
||||
// `camara`; the web source is served by C216 with its own list contract.
|
||||
name: 'origin',
|
||||
type: 'select',
|
||||
label: 'Origem',
|
||||
required: true,
|
||||
defaultValue: 'camara',
|
||||
index: true,
|
||||
options: [
|
||||
{ label: 'Câmara', value: 'camara' },
|
||||
{ label: 'Internet', value: 'web' },
|
||||
],
|
||||
admin: {
|
||||
description: 'Discurso da Câmara ou fala publicada na internet.',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -267,6 +312,71 @@ export const Speech: CollectionConfig = {
|
|||
label: 'VOD (baixar)',
|
||||
admin: { readOnly: true, description: 'Último link conhecido; regerável pelo VOD.' },
|
||||
},
|
||||
// C215 — web speech origin and mirrored assets (null on Câmara rows).
|
||||
{
|
||||
name: 'platform',
|
||||
type: 'select',
|
||||
label: 'Plataforma',
|
||||
index: true,
|
||||
options: WEB_SPEECH_PLATFORMS.map(({ value, label }) => ({ value, label })),
|
||||
admin: {
|
||||
description: 'Plataforma de origem da fala da internet.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'externalId',
|
||||
type: 'text',
|
||||
label: 'Id externo',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Identificador da publicação na plataforma (quando houver).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sourceUrl',
|
||||
type: 'text',
|
||||
label: 'URL de origem',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Link canônico da publicação original.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
label: 'Título',
|
||||
admin: {
|
||||
description: 'Título da publicação na plataforma.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'channel',
|
||||
type: 'text',
|
||||
label: 'Canal/autor',
|
||||
admin: {
|
||||
description: 'Quem publicou (canal, rádio, perfil) — texto de origem, não é contato.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mirroredMedia',
|
||||
type: 'upload',
|
||||
relationTo: INTERNET_SPEECH_MEDIA_SLUG,
|
||||
label: 'Mídia espelhada',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Arquivo privado preservado para player, download e cortes.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'thumbnail',
|
||||
type: 'upload',
|
||||
relationTo: INTERNET_SPEECH_MEDIA_SLUG,
|
||||
label: 'Capa',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Capa da origem quando capturada; sem ela a lista mostra placeholder.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'topics',
|
||||
type: 'select',
|
||||
|
|
|
|||
280
src/lib/webSpeech.ts
Normal file
280
src/lib/webSpeech.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* C215 — pure contract of the web-speech ingestion: the platform taxonomy, the
|
||||
* finding-batch schema the discovery (C218) hands over, the natural key
|
||||
* (`web:<platform>:<externalId|url>`) and the canonical URL normalization that
|
||||
* keeps re-runs idempotent. No I/O: the CLI, the pipeline and the tests share
|
||||
* this module.
|
||||
*/
|
||||
|
||||
export const WEB_SPEECH_PLATFORMS = [
|
||||
{ value: 'youtube', label: 'YouTube' },
|
||||
{ value: 'instagram', label: 'Instagram' },
|
||||
{ value: 'radio', label: 'Rádio' },
|
||||
{ value: 'audio', label: 'Áudio' },
|
||||
] as const
|
||||
|
||||
export type WebSpeechPlatform = (typeof WEB_SPEECH_PLATFORMS)[number]['value']
|
||||
|
||||
const WEB_SPEECH_PLATFORM_VALUES = WEB_SPEECH_PLATFORMS.map((platform) => platform.value) as [
|
||||
WebSpeechPlatform,
|
||||
...WebSpeechPlatform[],
|
||||
]
|
||||
|
||||
export const webSpeechPlatformLabel = (value: WebSpeechPlatform): string =>
|
||||
WEB_SPEECH_PLATFORMS.find((platform) => platform.value === value)?.label ?? value
|
||||
|
||||
/** Owner of the private upload collection slug (collection, config and S3). */
|
||||
export const INTERNET_SPEECH_MEDIA_SLUG = 'internetSpeechMedia' as const
|
||||
|
||||
/**
|
||||
* Platforms whose acquisition goes through yt-dlp. The others (`radio`,
|
||||
* `audio`) are direct files: the finding must carry `mediaUrl` or the item
|
||||
* fails honestly in the report — the script never guesses a page's media.
|
||||
*/
|
||||
const YTDLP_PLATFORMS: readonly WebSpeechPlatform[] = ['youtube', 'instagram']
|
||||
|
||||
export const requiresDirectMediaUrl = (platform: WebSpeechPlatform): boolean =>
|
||||
!YTDLP_PLATFORMS.includes(platform)
|
||||
|
||||
const TRACKING_PARAMS = new Set(['fbclid', 'gclid', 'igshid', 'igsh', 'si', 'feature'])
|
||||
|
||||
const isTrackingParam = (name: string): boolean =>
|
||||
TRACKING_PARAMS.has(name) || name.toLowerCase().startsWith('utm_')
|
||||
|
||||
const stripTrackingParams = (url: URL): void => {
|
||||
for (const name of [...url.searchParams.keys()]) {
|
||||
if (isTrackingParam(name)) url.searchParams.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
const withoutTrailingSlash = (pathname: string): string =>
|
||||
pathname.length > 1 && pathname.endsWith('/') ? pathname.replace(/\/+$/, '') : pathname
|
||||
|
||||
const YOUTUBE_HOSTS = new Set(['youtube.com', 'm.youtube.com', 'music.youtube.com'])
|
||||
const YOUTU_BE_HOSTS = new Set(['youtu.be'])
|
||||
|
||||
const normalizedHost = (url: URL): string => url.hostname.toLowerCase().replace(/^www\./, '')
|
||||
|
||||
const firstPathSegment = (pathname: string): string | null =>
|
||||
pathname.split('/').find((segment) => segment !== '') ?? null
|
||||
|
||||
/** Video id from any YouTube URL shape (`watch`, `shorts`, `live`, `embed`, `youtu.be`). */
|
||||
const youtubeVideoId = (url: URL): string | null => {
|
||||
const host = normalizedHost(url)
|
||||
if (YOUTU_BE_HOSTS.has(host)) return firstPathSegment(url.pathname)
|
||||
if (!YOUTUBE_HOSTS.has(host)) return null
|
||||
if (url.pathname === '/watch') return url.searchParams.get('v')
|
||||
const match = /^\/(?:shorts|live|embed)\/([^/?#]+)/.exec(url.pathname)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
/** Shortcode + kind (`reel`/`p`/`tv`) from any Instagram post URL. */
|
||||
const instagramPost = (url: URL): { kind: string; code: string } | null => {
|
||||
if (normalizedHost(url) !== 'instagram.com') return null
|
||||
const match = /^\/(p|reel|reels|tv)\/([^/?#]+)/.exec(url.pathname)
|
||||
if (!match) return null
|
||||
return { kind: match[1] === 'reels' ? 'reel' : match[1], code: match[2] }
|
||||
}
|
||||
|
||||
const parseUrl = (raw: string): URL | null => {
|
||||
try {
|
||||
return new URL(raw.trim())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable URL for identity and provenance: YouTube/Instagram collapse to the
|
||||
* canonical post URL and every platform drops tracking params, the fragment
|
||||
* and a trailing slash. An unparseable URL returns trimmed as-is (the finding
|
||||
* schema validates the shape before this runs).
|
||||
*/
|
||||
export const canonicalWebSpeechUrl = (platform: WebSpeechPlatform, rawUrl: string): string => {
|
||||
const url = parseUrl(rawUrl)
|
||||
if (!url) return rawUrl.trim()
|
||||
|
||||
if (platform === 'youtube') {
|
||||
const videoId = youtubeVideoId(url)
|
||||
if (videoId) return `https://www.youtube.com/watch?v=${videoId}`
|
||||
}
|
||||
if (platform === 'instagram') {
|
||||
const post = instagramPost(url)
|
||||
if (post) return `https://www.instagram.com/${post.kind}/${post.code}/`
|
||||
}
|
||||
|
||||
url.hash = ''
|
||||
stripTrackingParams(url)
|
||||
url.hostname = url.hostname.toLowerCase()
|
||||
url.pathname = withoutTrailingSlash(url.pathname)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Natural key of one web speech. `externalId` wins when the discovery already
|
||||
* knows the platform id; otherwise the canonical URL is the identity — the
|
||||
* same URL always produces the same key, so re-running never duplicates.
|
||||
*/
|
||||
export const webSpeechSourceKey = ({
|
||||
platform,
|
||||
url,
|
||||
externalId,
|
||||
}: {
|
||||
platform: WebSpeechPlatform
|
||||
url: string
|
||||
externalId?: string | null
|
||||
}): string => {
|
||||
const id = externalId?.trim()
|
||||
return id ? `web:${platform}:${id}` : `web:${platform}:${canonicalWebSpeechUrl(platform, url)}`
|
||||
}
|
||||
|
||||
/** A web speech row is complete when it has both the transcript and the mirrored file. */
|
||||
export const isCompleteWebSpeechState = (
|
||||
state: { segmentCount: number; mirroredMedia: number | null } | null,
|
||||
): boolean => state !== null && state.segmentCount > 0 && state.mirroredMedia !== null
|
||||
|
||||
const PUBLISHED_AT_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?$/
|
||||
const TIMEZONE_SUFFIX = /(?:Z|[+-]\d{2}:?\d{2})$/
|
||||
|
||||
const isValidCalendarDay = (year: string, month: string, day: string): boolean => {
|
||||
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
|
||||
return (
|
||||
date.getUTCFullYear() === Number(year) &&
|
||||
date.getUTCMonth() === Number(month) - 1 &&
|
||||
date.getUTCDate() === Number(day)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `speechAt` (Brasília wall-clock text, same contract as the Câmara rows) from
|
||||
* the discovery's `publishedAt` — day-only becomes midnight and an explicit
|
||||
* timezone is converted to America/Bahia. Unrecognized values return null so
|
||||
* the finding fails validation instead of inventing a date.
|
||||
*/
|
||||
export const webSpeechAtFromPublishedAt = (publishedAt: string): string | null => {
|
||||
const trimmed = publishedAt.trim()
|
||||
|
||||
if (TIMEZONE_SUFFIX.test(trimmed)) {
|
||||
const instant = new Date(trimmed)
|
||||
if (Number.isNaN(instant.getTime())) return null
|
||||
// sv-SE renders the ISO-like `YYYY-MM-DD HH:mm` for the requested zone.
|
||||
return new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: 'America/Bahia',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.format(instant)
|
||||
.replace(' ', 'T')
|
||||
}
|
||||
|
||||
const match = PUBLISHED_AT_PATTERN.exec(trimmed)
|
||||
if (!match) return null
|
||||
const [, year, month, day, hour = '00', minute = '00'] = match
|
||||
if (!isValidCalendarDay(year, month, day)) return null
|
||||
if (Number(hour) > 23 || Number(minute) > 59) return null
|
||||
return `${year}-${month}-${day}T${hour}:${minute}`
|
||||
}
|
||||
|
||||
const MAX_TITLE_LENGTH = 300
|
||||
const MAX_CHANNEL_LENGTH = 200
|
||||
|
||||
const webSpeechFindingSchema = z
|
||||
.object({
|
||||
platform: z.enum(WEB_SPEECH_PLATFORM_VALUES, { error: 'plataforma desconhecida' }),
|
||||
url: z.string({ error: 'url obrigatória' }).trim().url('url inválida'),
|
||||
mediaUrl: z.string().trim().url('mediaUrl inválida').optional(),
|
||||
externalId: z.string().trim().min(1, 'externalId vazio').optional(),
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'título vazio')
|
||||
.max(MAX_TITLE_LENGTH, `título até ${MAX_TITLE_LENGTH} caracteres`)
|
||||
.optional(),
|
||||
channel: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'canal vazio')
|
||||
.max(MAX_CHANNEL_LENGTH, `canal até ${MAX_CHANNEL_LENGTH} caracteres`)
|
||||
.optional(),
|
||||
publishedAt: z
|
||||
.string({ error: 'publishedAt obrigatório' })
|
||||
.trim()
|
||||
.min(4, 'publishedAt inválido'),
|
||||
durationSeconds: z
|
||||
.number({ error: 'durationSeconds inválido' })
|
||||
.finite()
|
||||
.nonnegative('durationSeconds inválido')
|
||||
.optional(),
|
||||
thumbnailUrl: z.string().trim().url('thumbnailUrl inválida').optional(),
|
||||
})
|
||||
.superRefine((finding, context) => {
|
||||
if (requiresDirectMediaUrl(finding.platform) && !finding.mediaUrl) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['mediaUrl'],
|
||||
message: `Achado de ${webSpeechPlatformLabel(finding.platform)} exige mediaUrl (arquivo direto).`,
|
||||
})
|
||||
}
|
||||
if (webSpeechAtFromPublishedAt(finding.publishedAt) === null) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['publishedAt'],
|
||||
message: 'publishedAt deve ser YYYY-MM-DD ou data/hora ISO.',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type WebSpeechFinding = z.infer<typeof webSpeechFindingSchema>
|
||||
|
||||
export type WebSpeechFindingParseResult =
|
||||
| { ok: true; finding: WebSpeechFinding }
|
||||
| { ok: false; error: string }
|
||||
|
||||
export const parseWebSpeechFinding = (value: unknown): WebSpeechFindingParseResult => {
|
||||
const parsed = webSpeechFindingSchema.safeParse(value)
|
||||
if (parsed.success) return { ok: true, finding: parsed.data }
|
||||
const issue = parsed.error.issues[0]
|
||||
const path = issue?.path.join('.') ?? ''
|
||||
return {
|
||||
ok: false,
|
||||
error: path ? `${path}: ${issue?.message}` : (issue?.message ?? 'achado inválido'),
|
||||
}
|
||||
}
|
||||
|
||||
type WebSpeechBatch = {
|
||||
generatedAt: string | null
|
||||
findings: unknown[]
|
||||
}
|
||||
|
||||
export type WebSpeechBatchParseResult =
|
||||
| { ok: true; batch: WebSpeechBatch }
|
||||
| { ok: false; error: string }
|
||||
|
||||
/**
|
||||
* Envelope only: the findings stay raw so one invalid entry becomes a failure
|
||||
* of that entry in the report instead of killing the whole batch.
|
||||
*/
|
||||
export const parseWebSpeechBatch = (value: unknown): WebSpeechBatchParseResult => {
|
||||
const envelope = z
|
||||
.object({
|
||||
generatedAt: z.string().trim().min(1).optional(),
|
||||
findings: z.array(z.unknown()),
|
||||
})
|
||||
.safeParse(value)
|
||||
if (!envelope.success) {
|
||||
return { ok: false, error: 'lote inválido: esperado { generatedAt?, findings: [...] }.' }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
batch: {
|
||||
generatedAt: envelope.data.generatedAt ?? null,
|
||||
findings: envelope.data.findings,
|
||||
},
|
||||
}
|
||||
}
|
||||
18613
src/migrations/20260924_042028_add_web_speech_origin.json
Normal file
18613
src/migrations/20260924_042028_add_web_speech_origin.json
Normal file
File diff suppressed because it is too large
Load diff
71
src/migrations/20260924_042028_add_web_speech_origin.ts
Normal file
71
src/migrations/20260924_042028_add_web_speech_origin.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_speech_origin" AS ENUM('camara', 'web');
|
||||
CREATE TYPE "public"."enum_speech_platform" AS ENUM('youtube', 'instagram', 'radio', 'audio');
|
||||
CREATE TABLE "internet_speech_media" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"alt" varchar NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"url" varchar,
|
||||
"thumbnail_u_r_l" varchar,
|
||||
"filename" varchar,
|
||||
"mime_type" varchar,
|
||||
"filesize" numeric,
|
||||
"width" numeric,
|
||||
"height" numeric,
|
||||
"focal_x" numeric,
|
||||
"focal_y" numeric
|
||||
);
|
||||
|
||||
ALTER TABLE "speech" ADD COLUMN "origin" "enum_speech_origin" DEFAULT 'camara' NOT NULL;
|
||||
ALTER TABLE "speech" ADD COLUMN "platform" "enum_speech_platform";
|
||||
ALTER TABLE "speech" ADD COLUMN "external_id" varchar;
|
||||
ALTER TABLE "speech" ADD COLUMN "source_url" varchar;
|
||||
ALTER TABLE "speech" ADD COLUMN "title" varchar;
|
||||
ALTER TABLE "speech" ADD COLUMN "channel" varchar;
|
||||
ALTER TABLE "speech" ADD COLUMN "mirrored_media_id" integer;
|
||||
ALTER TABLE "speech" ADD COLUMN "thumbnail_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "internet_speech_media_id" integer;
|
||||
CREATE INDEX "internet_speech_media_updated_at_idx" ON "internet_speech_media" USING btree ("updated_at");
|
||||
CREATE INDEX "internet_speech_media_created_at_idx" ON "internet_speech_media" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "internet_speech_media_filename_idx" ON "internet_speech_media" USING btree ("filename");
|
||||
ALTER TABLE "speech" ADD CONSTRAINT "speech_mirrored_media_id_internet_speech_media_id_fk" FOREIGN KEY ("mirrored_media_id") REFERENCES "public"."internet_speech_media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "speech" ADD CONSTRAINT "speech_thumbnail_id_internet_speech_media_id_fk" FOREIGN KEY ("thumbnail_id") REFERENCES "public"."internet_speech_media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_internet_speech_media_fk" FOREIGN KEY ("internet_speech_media_id") REFERENCES "public"."internet_speech_media"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "speech_origin_idx" ON "speech" USING btree ("origin");
|
||||
CREATE INDEX "speech_platform_idx" ON "speech" USING btree ("platform");
|
||||
CREATE INDEX "speech_mirrored_media_idx" ON "speech" USING btree ("mirrored_media_id");
|
||||
CREATE INDEX "speech_thumbnail_idx" ON "speech" USING btree ("thumbnail_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_internet_speech_media_id_idx" ON "payload_locked_documents_rels" USING btree ("internet_speech_media_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "internet_speech_media" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "internet_speech_media" CASCADE;
|
||||
ALTER TABLE "speech" DROP CONSTRAINT "speech_mirrored_media_id_internet_speech_media_id_fk";
|
||||
|
||||
ALTER TABLE "speech" DROP CONSTRAINT "speech_thumbnail_id_internet_speech_media_id_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_internet_speech_media_fk";
|
||||
|
||||
DROP INDEX "speech_origin_idx";
|
||||
DROP INDEX "speech_platform_idx";
|
||||
DROP INDEX "speech_mirrored_media_idx";
|
||||
DROP INDEX "speech_thumbnail_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_internet_speech_media_id_idx";
|
||||
ALTER TABLE "speech" DROP COLUMN "origin";
|
||||
ALTER TABLE "speech" DROP COLUMN "platform";
|
||||
ALTER TABLE "speech" DROP COLUMN "external_id";
|
||||
ALTER TABLE "speech" DROP COLUMN "source_url";
|
||||
ALTER TABLE "speech" DROP COLUMN "title";
|
||||
ALTER TABLE "speech" DROP COLUMN "channel";
|
||||
ALTER TABLE "speech" DROP COLUMN "mirrored_media_id";
|
||||
ALTER TABLE "speech" DROP COLUMN "thumbnail_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "internet_speech_media_id";
|
||||
DROP TYPE "public"."enum_speech_origin";
|
||||
DROP TYPE "public"."enum_speech_platform";`)
|
||||
}
|
||||
|
|
@ -82,6 +82,7 @@ import * as migration_20260923_032714_add_content_piece from './20260923_032714_
|
|||
import * as migration_20260923_054145_add_share_link_announcement from './20260923_054145_add_share_link_announcement';
|
||||
import * as migration_20260923_172437_add_content_events from './20260923_172437_add_content_events';
|
||||
import * as migration_20260923_190414_add_content_event_variant from './20260923_190414_add_content_event_variant';
|
||||
import * as migration_20260924_042028_add_web_speech_origin from './20260924_042028_add_web_speech_origin';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -502,6 +503,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260923_190414_add_content_event_variant.up,
|
||||
down: migration_20260923_190414_add_content_event_variant.down,
|
||||
name: '20260923_190414_add_content_event_variant'
|
||||
name: '20260923_190414_add_content_event_variant',
|
||||
},
|
||||
{
|
||||
up: migration_20260924_042028_add_web_speech_origin.up,
|
||||
down: migration_20260924_042028_add_web_speech_origin.down,
|
||||
name: '20260924_042028_add_web_speech_origin'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export interface Config {
|
|||
speech: Speech;
|
||||
speechSegment: SpeechSegment;
|
||||
speechCut: SpeechCut;
|
||||
internetSpeechMedia: InternetSpeechMedia;
|
||||
reel: Reel;
|
||||
reelMedia: ReelMedia;
|
||||
contentPiece: ContentPiece;
|
||||
|
|
@ -143,6 +144,7 @@ export interface Config {
|
|||
speech: SpeechSelect<false> | SpeechSelect<true>;
|
||||
speechSegment: SpeechSegmentSelect<false> | SpeechSegmentSelect<true>;
|
||||
speechCut: SpeechCutSelect<false> | SpeechCutSelect<true>;
|
||||
internetSpeechMedia: InternetSpeechMediaSelect<false> | InternetSpeechMediaSelect<true>;
|
||||
reel: ReelSelect<false> | ReelSelect<true>;
|
||||
reelMedia: ReelMediaSelect<false> | ReelMediaSelect<true>;
|
||||
contentPiece: ContentPieceSelect<false> | ContentPieceSelect<true>;
|
||||
|
|
@ -938,7 +940,7 @@ export interface MunicipalityUpdate {
|
|||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Acervo de falas do deputado na Câmara. Dados e vídeos da Câmara dos Deputados (CC BY 4.0).
|
||||
* Acervo de falas do deputado: discursos na Câmara (CC BY 4.0) e falas publicadas na internet (mídia espelhada privada).
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "speech".
|
||||
|
|
@ -946,9 +948,13 @@ export interface MunicipalityUpdate {
|
|||
export interface Speech {
|
||||
id: number;
|
||||
/**
|
||||
* Identidade da fala na API da Câmara (data/hora + tipo + fase).
|
||||
* Identidade da fala: API da Câmara ou web:<plataforma>:<id|url>.
|
||||
*/
|
||||
sourceKey: string;
|
||||
/**
|
||||
* Discurso da Câmara ou fala publicada na internet.
|
||||
*/
|
||||
origin: 'camara' | 'web';
|
||||
/**
|
||||
* Horário local de Brasília, como publicado pela Câmara.
|
||||
*/
|
||||
|
|
@ -988,6 +994,34 @@ export interface Speech {
|
|||
* Último link conhecido; regerável pelo VOD.
|
||||
*/
|
||||
vodDownloadUrl?: string | null;
|
||||
/**
|
||||
* Plataforma de origem da fala da internet.
|
||||
*/
|
||||
platform?: ('youtube' | 'instagram' | 'radio' | 'audio') | null;
|
||||
/**
|
||||
* Identificador da publicação na plataforma (quando houver).
|
||||
*/
|
||||
externalId?: string | null;
|
||||
/**
|
||||
* Link canônico da publicação original.
|
||||
*/
|
||||
sourceUrl?: string | null;
|
||||
/**
|
||||
* Título da publicação na plataforma.
|
||||
*/
|
||||
title?: string | null;
|
||||
/**
|
||||
* Quem publicou (canal, rádio, perfil) — texto de origem, não é contato.
|
||||
*/
|
||||
channel?: string | null;
|
||||
/**
|
||||
* Arquivo privado preservado para player, download e cortes.
|
||||
*/
|
||||
mirroredMedia?: (number | null) | InternetSpeechMedia;
|
||||
/**
|
||||
* Capa da origem quando capturada; sem ela a lista mostra placeholder.
|
||||
*/
|
||||
thumbnail?: (number | null) | InternetSpeechMedia;
|
||||
topics?:
|
||||
| (
|
||||
| 'saude'
|
||||
|
|
@ -1022,6 +1056,30 @@ export interface Speech {
|
|||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Arquivos privados das falas encontradas na internet (mídia espelhada e capa). Só abrem com login da campanha.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "internetSpeechMedia".
|
||||
*/
|
||||
export interface InternetSpeechMedia {
|
||||
id: number;
|
||||
/**
|
||||
* Descrição do arquivo para acessibilidade.
|
||||
*/
|
||||
alt: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
thumbnailURL?: string | null;
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "speechSegment".
|
||||
|
|
@ -2052,6 +2110,10 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'speechCut';
|
||||
value: number | SpeechCut;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'internetSpeechMedia';
|
||||
value: number | InternetSpeechMedia;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'reel';
|
||||
value: number | Reel;
|
||||
|
|
@ -2608,6 +2670,7 @@ export interface ActivitySelect<T extends boolean = true> {
|
|||
*/
|
||||
export interface SpeechSelect<T extends boolean = true> {
|
||||
sourceKey?: T;
|
||||
origin?: T;
|
||||
speechAt?: T;
|
||||
year?: T;
|
||||
legislature?: T;
|
||||
|
|
@ -2629,6 +2692,13 @@ export interface SpeechSelect<T extends boolean = true> {
|
|||
excerptTMs?: T;
|
||||
vodPlaybackUrl?: T;
|
||||
vodDownloadUrl?: T;
|
||||
platform?: T;
|
||||
externalId?: T;
|
||||
sourceUrl?: T;
|
||||
title?: T;
|
||||
channel?: T;
|
||||
mirroredMedia?: T;
|
||||
thumbnail?: T;
|
||||
topics?: T;
|
||||
scopes?: T;
|
||||
classifiedBy?: T;
|
||||
|
|
@ -2673,6 +2743,24 @@ export interface SpeechCutSelect<T extends boolean = true> {
|
|||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "internetSpeechMedia_select".
|
||||
*/
|
||||
export interface InternetSpeechMediaSelect<T extends boolean = true> {
|
||||
alt?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
url?: T;
|
||||
thumbnailURL?: T;
|
||||
filename?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
focalX?: T;
|
||||
focalY?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "reel_select".
|
||||
|
|
@ -3603,6 +3691,7 @@ export interface TaskCreateCollectionExport {
|
|||
| 'speech'
|
||||
| 'speechSegment'
|
||||
| 'speechCut'
|
||||
| 'internetSpeechMedia'
|
||||
| 'reel'
|
||||
| 'reelMedia'
|
||||
| 'contentPiece'
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { ElectionCandidate } from './collections/ElectionCandidate'
|
|||
import { ElectionCandidateVote } from './collections/ElectionCandidateVote'
|
||||
import { ElectionTally } from './collections/ElectionTally'
|
||||
import { GoogleCalendarSync } from './collections/GoogleCalendarSync'
|
||||
import { InternetSpeechMedia } from './collections/InternetSpeechMedia'
|
||||
import { Jingle } from './collections/Jingle'
|
||||
import { Leadership } from './collections/Leadership'
|
||||
import { Media } from './collections/Media'
|
||||
|
|
@ -127,6 +128,7 @@ export default buildConfig({
|
|||
Speech,
|
||||
SpeechSegment,
|
||||
SpeechCut,
|
||||
InternetSpeechMedia,
|
||||
Reel,
|
||||
ReelMedia,
|
||||
ContentPiece,
|
||||
|
|
@ -189,6 +191,8 @@ export default buildConfig({
|
|||
recordingMedia: true,
|
||||
// C211 — private content-piece files; same failure mode if omitted.
|
||||
contentMedia: true,
|
||||
// C215 — private mirrored web-speech files; same failure mode if omitted.
|
||||
internetSpeechMedia: true,
|
||||
},
|
||||
bucket: mediaStorage.bucket,
|
||||
config: {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ export const DEEPINFRA_TRANSCRIBE_URL = 'https://api.deepinfra.com/v1/openai/aud
|
|||
const DEEPINFRA_WHISPER_MODEL = 'openai/whisper-large-v3'
|
||||
const DEEPINFRA_LANGUAGE = 'pt'
|
||||
|
||||
/**
|
||||
* C215 — provider price (~US$ per audio minute, Whisper large-v3 at Deep
|
||||
* Infra) used by the ingestion report to state the ASR cost honestly. The
|
||||
* Câmara CLI keeps its own copy; this is the app-side owner.
|
||||
*/
|
||||
export const DEEPINFRA_TRANSCRIBE_COST_PER_MINUTE_USD = 0.00045
|
||||
|
||||
export type TranscribeAudioResult =
|
||||
| { ok: true; text: string }
|
||||
| { ok: false; error: string; status: number }
|
||||
|
|
|
|||
|
|
@ -95,7 +95,14 @@ export const findSpeechExcerpts = (ctx: AIToolContext) =>
|
|||
|
||||
const speeches = await ctx.payload.find({
|
||||
collection: 'speech',
|
||||
where: { and: terms.map((term) => ({ searchText: { like: term } })) },
|
||||
// C215 — the assistant keeps suggesting Câmara excerpts; the web
|
||||
// speeches (C216) are a different source with their own screen.
|
||||
where: {
|
||||
and: [
|
||||
{ origin: { equals: 'camara' } },
|
||||
...terms.map((term) => ({ searchText: { like: term } })),
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: SPEECH_CANDIDATE_LIMIT,
|
||||
page: 1,
|
||||
|
|
|
|||
63
src/utilities/media/downloadToFile.ts
Normal file
63
src/utilities/media/downloadToFile.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import 'server-only'
|
||||
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import { Readable, Transform } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import type { ReadableStream as NodeReadableStream } from 'node:stream/web'
|
||||
|
||||
/**
|
||||
* C215 — streams one public URL to a local file: the direct acquisition path
|
||||
* of radio/audio findings (yt-dlp owns the platform extractors). A non-2xx
|
||||
* answer fails before anything is written, the body is never buffered in
|
||||
* memory and both a timeout and a byte ceiling bound a stalled or oversized
|
||||
* server (a partial file is removed by the caller's temp dir).
|
||||
*/
|
||||
|
||||
/** Ceiling: hours of audio/video are the real case, 4 GiB is the guard. */
|
||||
const DOWNLOAD_MAX_BYTES = 4 * 1024 * 1024 * 1024
|
||||
const DOWNLOAD_TIMEOUT_MS = 60 * 60_000
|
||||
const TOO_LARGE_MESSAGE = 'Download excede o tamanho máximo permitido.'
|
||||
|
||||
export const downloadUrlToFile = async ({
|
||||
url,
|
||||
destinationPath,
|
||||
timeoutMs = DOWNLOAD_TIMEOUT_MS,
|
||||
maxBytes = DOWNLOAD_MAX_BYTES,
|
||||
fetchImpl = fetch,
|
||||
}: {
|
||||
url: string
|
||||
destinationPath: string
|
||||
timeoutMs?: number
|
||||
maxBytes?: number
|
||||
fetchImpl?: typeof fetch
|
||||
}): Promise<void> => {
|
||||
const response = await fetchImpl(url, {
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Download falhou (HTTP ${response.status}).`)
|
||||
if (!response.body) throw new Error('Download sem corpo.')
|
||||
|
||||
const declaredLength = Number(response.headers.get('content-length'))
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
throw new Error(TOO_LARGE_MESSAGE)
|
||||
}
|
||||
|
||||
let received = 0
|
||||
const ceiling = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
received += chunk.length
|
||||
if (received > maxBytes) {
|
||||
callback(new Error(TOO_LARGE_MESSAGE))
|
||||
return
|
||||
}
|
||||
callback(null, chunk)
|
||||
},
|
||||
})
|
||||
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body as unknown as NodeReadableStream<Uint8Array>),
|
||||
ceiling,
|
||||
createWriteStream(destinationPath),
|
||||
)
|
||||
}
|
||||
|
|
@ -16,7 +16,12 @@ const FFMPEG_MIN_TIMEOUT_MS = 30_000
|
|||
const FFMPEG_MAX_TIMEOUT_MS = 300_000
|
||||
const FFMPEG_MAX_BUFFER_BYTES = 8 * 1024 * 1024
|
||||
|
||||
const ffmpegBinary = (): string => process.env.FFMPEG_PATH?.trim() || 'ffmpeg'
|
||||
/**
|
||||
* The resolved binary (`FFMPEG_PATH` override or `ffmpeg` on PATH). Exported
|
||||
* so callers that must tell another tool where ffmpeg lives (yt-dlp's
|
||||
* `--ffmpeg-location`, C215) read the same resolution instead of the env.
|
||||
*/
|
||||
export const ffmpegBinary = (): string => process.env.FFMPEG_PATH?.trim() || 'ffmpeg'
|
||||
|
||||
/** Last-resort copy when an error carries no message; each job names its own. */
|
||||
export const messageOf = (error: unknown, fallback: string): string =>
|
||||
|
|
|
|||
170
src/utilities/media/ytdlp.ts
Normal file
170
src/utilities/media/ytdlp.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import 'server-only'
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
|
||||
/**
|
||||
* C215 — the single owner of yt-dlp execution for the web-speech ingestion:
|
||||
* binary resolution (`YTDLP_PATH` → `yt-dlp` on PATH, fail-closed), the
|
||||
* metadata probe (`--dump-json`) and the download of one URL to a local file.
|
||||
* YouTube and Instagram only; direct radio/audio files are fetched by
|
||||
* `downloadToFile`.
|
||||
*
|
||||
* There is no packaged yt-dlp: the operator installs it and the tests inject a
|
||||
* fake through `YTDLP_PATH`. The format cap keeps the mirrored artifact enough
|
||||
* for transcription, player and cut — never the master.
|
||||
*/
|
||||
|
||||
export const YTDLP_INSTALL_HINT =
|
||||
'Instale o yt-dlp (ex.: `pipx install yt-dlp`) ou defina YTDLP_PATH.'
|
||||
|
||||
/** H.264/AAC up to 720p when available, then the best single file. */
|
||||
export const YTDLP_FORMAT = 'bv*[height<=720][vcodec^=avc1]+ba[acodec^=mp4a]/b[height<=720]/b'
|
||||
|
||||
const VERSION_TIMEOUT_MS = 30_000
|
||||
const METADATA_TIMEOUT_MS = 120_000
|
||||
const DOWNLOAD_TIMEOUT_MS = 60 * 60_000
|
||||
const MAX_BUFFER_BYTES = 16 * 1024 * 1024
|
||||
|
||||
export type YtDlpResult =
|
||||
| { ok: true; stdout: string; stderr: string }
|
||||
| {
|
||||
ok: false
|
||||
stdout: string
|
||||
stderr: string
|
||||
error?: { code?: string | number | null; message?: string }
|
||||
}
|
||||
|
||||
export type YtDlpRunner = (
|
||||
bin: string,
|
||||
args: string[],
|
||||
options?: { timeoutMs?: number },
|
||||
) => Promise<YtDlpResult>
|
||||
|
||||
const defaultRunner: YtDlpRunner = (bin, args, { timeoutMs } = {}) =>
|
||||
new Promise((resolve) => {
|
||||
execFile(
|
||||
bin,
|
||||
args,
|
||||
{ timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES },
|
||||
(error, stdout, stderr) => {
|
||||
if (!error) {
|
||||
resolve({ ok: true, stdout: stdout ?? '', stderr: stderr ?? '' })
|
||||
return
|
||||
}
|
||||
resolve({ ok: false, stdout: stdout ?? '', stderr: stderr ?? '', error })
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const ytDlpBinary = (env: Record<string, string | undefined> = process.env): string =>
|
||||
env.YTDLP_PATH?.trim() || 'yt-dlp'
|
||||
|
||||
export type YtDlpResolution = { ok: true; bin: string } | { ok: false; reason: string }
|
||||
|
||||
/** Probe of the resolved binary; a missing/unusable yt-dlp never degrades silently. */
|
||||
export const resolveYtDlp = async ({
|
||||
env = process.env,
|
||||
run = defaultRunner,
|
||||
}: {
|
||||
env?: Record<string, string | undefined>
|
||||
run?: YtDlpRunner
|
||||
} = {}): Promise<YtDlpResolution> => {
|
||||
const bin = ytDlpBinary(env)
|
||||
const probe = await run(bin, ['--version'], { timeoutMs: VERSION_TIMEOUT_MS })
|
||||
if (!probe.ok) {
|
||||
const code = probe.error?.code
|
||||
return {
|
||||
ok: false,
|
||||
reason: code === 'ENOENT' ? `yt-dlp não encontrado (${bin})` : `yt-dlp não executou (${bin})`,
|
||||
}
|
||||
}
|
||||
return { ok: true, bin }
|
||||
}
|
||||
|
||||
export type YtDlpMetadata = {
|
||||
externalId: string | null
|
||||
title: string | null
|
||||
channel: string | null
|
||||
durationSeconds: number | null
|
||||
thumbnailUrl: string | null
|
||||
}
|
||||
|
||||
const stringOrNull = (value: unknown): string | null =>
|
||||
typeof value === 'string' && value.trim() !== '' ? value.trim() : null
|
||||
|
||||
const positiveNumberOrNull = (value: unknown): number | null => {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) && number > 0 ? number : null
|
||||
}
|
||||
|
||||
/** One `--dump-json` document; absent fields stay null instead of guessing. */
|
||||
export const parseYtDlpMetadata = (value: unknown): YtDlpMetadata => {
|
||||
const data = value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
|
||||
return {
|
||||
externalId: stringOrNull(data.id),
|
||||
title: stringOrNull(data.title),
|
||||
channel: stringOrNull(data.channel) ?? stringOrNull(data.uploader),
|
||||
durationSeconds: positiveNumberOrNull(data.duration),
|
||||
thumbnailUrl: stringOrNull(data.thumbnail),
|
||||
}
|
||||
}
|
||||
|
||||
const stderrTail = (result: Extract<YtDlpResult, { ok: false }>): string => {
|
||||
const tail = (result.stderr || result.error?.message || '').trim().slice(-400)
|
||||
return tail ? `: ${tail}` : ''
|
||||
}
|
||||
|
||||
export const readYtDlpMetadata = async ({
|
||||
bin,
|
||||
url,
|
||||
run = defaultRunner,
|
||||
}: {
|
||||
bin: string
|
||||
url: string
|
||||
run?: YtDlpRunner
|
||||
}): Promise<YtDlpMetadata> => {
|
||||
const result = await run(
|
||||
bin,
|
||||
['--dump-json', '--no-playlist', '--no-warnings', '--skip-download', url],
|
||||
{ timeoutMs: METADATA_TIMEOUT_MS },
|
||||
)
|
||||
if (!result.ok) throw new Error(`yt-dlp não leu os metadados${stderrTail(result)}`)
|
||||
try {
|
||||
return parseYtDlpMetadata(JSON.parse(result.stdout))
|
||||
} catch {
|
||||
throw new Error('yt-dlp devolveu metadados fora do formato.')
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadWithYtDlp = async ({
|
||||
bin,
|
||||
url,
|
||||
outputTemplate,
|
||||
ffmpegLocation = null,
|
||||
run = defaultRunner,
|
||||
}: {
|
||||
bin: string
|
||||
url: string
|
||||
outputTemplate: string
|
||||
ffmpegLocation?: string | null
|
||||
run?: YtDlpRunner
|
||||
}): Promise<void> => {
|
||||
const result = await run(
|
||||
bin,
|
||||
[
|
||||
'--no-playlist',
|
||||
'--no-warnings',
|
||||
'--no-progress',
|
||||
'-f',
|
||||
YTDLP_FORMAT,
|
||||
'--merge-output-format',
|
||||
'mp4',
|
||||
...(ffmpegLocation ? ['--ffmpeg-location', ffmpegLocation] : []),
|
||||
'-o',
|
||||
outputTemplate,
|
||||
url,
|
||||
],
|
||||
{ timeoutMs: DOWNLOAD_TIMEOUT_MS },
|
||||
)
|
||||
if (!result.ok) throw new Error(`yt-dlp falhou no download${stderrTail(result)}`)
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ const COVERAGE_QUERY = sql`
|
|||
)) AS "with_segments",
|
||||
COUNT(*) FILTER (WHERE s."audio_id" IS NULL AND s."youtube_url" IS NOT NULL) AS "fallback_youtube"
|
||||
FROM "speech" s
|
||||
WHERE s."origin" = 'camara'
|
||||
GROUP BY s."legislature"
|
||||
ORDER BY s."legislature" NULLS LAST
|
||||
`
|
||||
|
|
|
|||
|
|
@ -5,13 +5,22 @@ import type { Payload, RequiredDataFromCollectionSlug } from 'payload'
|
|||
import type { MergedSpeechFacets } from '@/lib/speechFacets'
|
||||
import { normalizeForSearch } from '@/lib/speechSearch'
|
||||
import type { Speech } from '@/payload-types'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import {
|
||||
withPayloadTransaction,
|
||||
type PayloadTransactionRequest,
|
||||
} from '@/utilities/payloadTransaction'
|
||||
|
||||
/**
|
||||
* Idempotent persistence of one imported speech (C153). Identity is
|
||||
* `sourceKey`; metadata is refreshed on every run, segments are replaced only
|
||||
* when the caller supplies them, and a `manual` facet curation is never
|
||||
* overwritten. Everything runs in one transaction on the Payload session.
|
||||
*
|
||||
* C215 — the same owner persists the web speeches: the optional origin fields
|
||||
* (`origin: 'web'`, platform, canonical URL, title, channel and the mirrored
|
||||
* media) travel in the bundle. The web pipeline already holds a transaction
|
||||
* (media upload + upsert) and passes it via `options.req`; the Câmara path
|
||||
* passes nothing and keeps opening its own.
|
||||
*/
|
||||
|
||||
type SpeechSegmentInput = {
|
||||
|
|
@ -42,6 +51,15 @@ export type SpeechImportBundle = {
|
|||
excerptTMs: number | null
|
||||
vodPlaybackUrl: string | null
|
||||
vodDownloadUrl: string | null
|
||||
/** C215 — web origin metadata (undefined on the Câmara path). */
|
||||
origin?: Speech['origin']
|
||||
platform?: Speech['platform']
|
||||
externalId?: string | null
|
||||
sourceUrl?: string | null
|
||||
title?: string | null
|
||||
channel?: string | null
|
||||
mirroredMedia?: number | null
|
||||
thumbnail?: number | null
|
||||
/** Undefined preserves the stored facets (e.g. a run that skipped classification). */
|
||||
facets?: MergedSpeechFacets
|
||||
/** Undefined preserves the stored segments (e.g. `--skip-transcribe`). */
|
||||
|
|
@ -60,6 +78,12 @@ export type SpeechImportState = {
|
|||
summary: string | null
|
||||
officialTranscript: string | null
|
||||
officialTextUrl: string | null
|
||||
origin: Speech['origin']
|
||||
platform: Speech['platform'] | null
|
||||
externalId: string | null
|
||||
sourceUrl: string | null
|
||||
mirroredMedia: number | null
|
||||
thumbnail: number | null
|
||||
}
|
||||
|
||||
export type SpeechUpsertCounts = {
|
||||
|
|
@ -69,6 +93,10 @@ export type SpeechUpsertCounts = {
|
|||
manualFacetsPreserved: boolean
|
||||
}
|
||||
|
||||
/** Depth-0 upload relations arrive as the id; normalize for the state contract. */
|
||||
const mediaIdOf = (media: Speech['mirroredMedia']): number | null =>
|
||||
typeof media === 'object' && media !== null ? media.id : (media ?? null)
|
||||
|
||||
/** Current stored state of one speech, for the importer's skip decisions. */
|
||||
export const findSpeechImportState = async (
|
||||
payload: Payload,
|
||||
|
|
@ -89,6 +117,12 @@ export const findSpeechImportState = async (
|
|||
summary: true,
|
||||
officialTranscript: true,
|
||||
officialTextUrl: true,
|
||||
origin: true,
|
||||
platform: true,
|
||||
externalId: true,
|
||||
sourceUrl: true,
|
||||
mirroredMedia: true,
|
||||
thumbnail: true,
|
||||
},
|
||||
// Intentional bypass: the import CLI is a trusted actor with no session.
|
||||
overrideAccess: true,
|
||||
|
|
@ -115,152 +149,189 @@ export const findSpeechImportState = async (
|
|||
summary: speech.summary ?? null,
|
||||
officialTranscript: speech.officialTranscript ?? null,
|
||||
officialTextUrl: speech.officialTextUrl ?? null,
|
||||
origin: speech.origin,
|
||||
platform: speech.platform ?? null,
|
||||
externalId: speech.externalId ?? null,
|
||||
sourceUrl: speech.sourceUrl ?? null,
|
||||
mirroredMedia: mediaIdOf(speech.mirroredMedia),
|
||||
thumbnail: mediaIdOf(speech.thumbnail),
|
||||
}
|
||||
}
|
||||
|
||||
type SpeechWriteData = RequiredDataFromCollectionSlug<'speech'>
|
||||
|
||||
const metadataData = (
|
||||
bundle: SpeechImportBundle,
|
||||
): Omit<SpeechWriteData, 'sourceKey' | 'classifiedBy' | 'searchText'> => ({
|
||||
speechAt: bundle.speechAt,
|
||||
year: bundle.year,
|
||||
legislature: bundle.legislature,
|
||||
type: bundle.type,
|
||||
phase: bundle.phase,
|
||||
durationSeconds: bundle.durationSeconds,
|
||||
summary: bundle.summary,
|
||||
officialTranscript: bundle.officialTranscript,
|
||||
officialTextUrl: bundle.officialTextUrl,
|
||||
keywords: bundle.keywords,
|
||||
eventId: bundle.eventId,
|
||||
eventType: bundle.eventType,
|
||||
eventStartAt: bundle.eventStartAt,
|
||||
eventEndAt: bundle.eventEndAt,
|
||||
youtubeUrl: bundle.youtubeUrl,
|
||||
presidingOfficer: bundle.presidingOfficer,
|
||||
audioId: bundle.audioId,
|
||||
excerptTMs: bundle.excerptTMs,
|
||||
vodPlaybackUrl: bundle.vodPlaybackUrl,
|
||||
vodDownloadUrl: bundle.vodDownloadUrl,
|
||||
})
|
||||
type SpeechMetadataData = Omit<SpeechWriteData, 'sourceKey' | 'classifiedBy' | 'searchText'>
|
||||
|
||||
export const upsertSpeechBundle = async (
|
||||
const metadataData = (bundle: SpeechImportBundle): SpeechMetadataData => {
|
||||
const data: SpeechMetadataData = {
|
||||
origin: bundle.origin ?? 'camara',
|
||||
speechAt: bundle.speechAt,
|
||||
year: bundle.year,
|
||||
legislature: bundle.legislature,
|
||||
type: bundle.type,
|
||||
phase: bundle.phase,
|
||||
durationSeconds: bundle.durationSeconds,
|
||||
summary: bundle.summary,
|
||||
officialTranscript: bundle.officialTranscript,
|
||||
officialTextUrl: bundle.officialTextUrl,
|
||||
keywords: bundle.keywords,
|
||||
eventId: bundle.eventId,
|
||||
eventType: bundle.eventType,
|
||||
eventStartAt: bundle.eventStartAt,
|
||||
eventEndAt: bundle.eventEndAt,
|
||||
youtubeUrl: bundle.youtubeUrl,
|
||||
presidingOfficer: bundle.presidingOfficer,
|
||||
audioId: bundle.audioId,
|
||||
excerptTMs: bundle.excerptTMs,
|
||||
vodPlaybackUrl: bundle.vodPlaybackUrl,
|
||||
vodDownloadUrl: bundle.vodDownloadUrl,
|
||||
}
|
||||
|
||||
// Web-origin fields travel only when the caller supplied them: the Câmara
|
||||
// bundle omits them and Payload leaves the stored values untouched.
|
||||
if (bundle.platform !== undefined) data.platform = bundle.platform
|
||||
if (bundle.externalId !== undefined) data.externalId = bundle.externalId
|
||||
if (bundle.sourceUrl !== undefined) data.sourceUrl = bundle.sourceUrl
|
||||
if (bundle.title !== undefined) data.title = bundle.title
|
||||
if (bundle.channel !== undefined) data.channel = bundle.channel
|
||||
if (bundle.mirroredMedia !== undefined) data.mirroredMedia = bundle.mirroredMedia
|
||||
if (bundle.thumbnail !== undefined) data.thumbnail = bundle.thumbnail
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export type SpeechUpsertOptions = {
|
||||
/** Existing transaction to ride (C215 media upload + upsert); absent opens one. */
|
||||
req?: PayloadTransactionRequest
|
||||
}
|
||||
|
||||
const upsertSpeechBundleInTransaction = async (
|
||||
payload: Payload,
|
||||
bundle: SpeechImportBundle,
|
||||
): Promise<SpeechUpsertCounts> =>
|
||||
withPayloadTransaction(payload, async ({ req }) => {
|
||||
const found = await payload.find({
|
||||
req: PayloadTransactionRequest,
|
||||
): Promise<SpeechUpsertCounts> => {
|
||||
const found = await payload.find({
|
||||
collection: 'speech',
|
||||
where: { sourceKey: { equals: bundle.sourceKey } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
select: { id: true, classifiedBy: true },
|
||||
req,
|
||||
// Intentional bypass: the import CLI is a trusted actor with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
const current = found.docs[0]
|
||||
const manualFacetsPreserved = current?.classifiedBy === 'manual'
|
||||
|
||||
// C154 — the speech-level search text mirrors the segments and is owned by
|
||||
// this bundle: `segments: undefined` (skip/ASR failure) preserves whatever
|
||||
// is stored, exactly like the segments themselves.
|
||||
const segmentSearchText =
|
||||
bundle.segments === undefined
|
||||
? undefined
|
||||
: normalizeForSearch(bundle.segments.map((segment) => segment.text).join(' '))
|
||||
|
||||
const metadata = metadataData(bundle)
|
||||
const facets: Partial<SpeechWriteData> = {}
|
||||
if (bundle.facets && !manualFacetsPreserved) {
|
||||
const municipalitySlugs = bundle.facets.municipalities.map((entry) => entry.slug)
|
||||
const municipalities =
|
||||
municipalitySlugs.length === 0
|
||||
? []
|
||||
: (
|
||||
await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { slug: { in: municipalitySlugs } },
|
||||
limit: municipalitySlugs.length,
|
||||
depth: 0,
|
||||
select: { slug: true },
|
||||
req,
|
||||
// Intentional bypass: resolving gazetteer slugs inside the import transaction.
|
||||
overrideAccess: true,
|
||||
})
|
||||
).docs
|
||||
facets.topics = bundle.facets.topics
|
||||
facets.scopes = bundle.facets.scopes
|
||||
facets.classifiedBy = bundle.facets.classifiedBy
|
||||
facets.mentionedMunicipalities = municipalities.map((doc) => doc.id)
|
||||
facets.mentionedPeople = bundle.facets.people
|
||||
facets.mentionedPrograms = bundle.facets.programs
|
||||
facets.mentionedProjects = bundle.facets.projects
|
||||
}
|
||||
const data = { ...metadata, ...facets }
|
||||
|
||||
let speechId: number
|
||||
let created = false
|
||||
if (current) {
|
||||
await payload.update({
|
||||
collection: 'speech',
|
||||
where: { sourceKey: { equals: bundle.sourceKey } },
|
||||
limit: 1,
|
||||
id: current.id,
|
||||
data: segmentSearchText === undefined ? data : { ...data, searchText: segmentSearchText },
|
||||
depth: 0,
|
||||
select: { id: true, classifiedBy: true },
|
||||
req,
|
||||
// Intentional bypass: the import CLI is a trusted actor with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
const current = found.docs[0]
|
||||
const manualFacetsPreserved = current?.classifiedBy === 'manual'
|
||||
speechId = current.id
|
||||
} else {
|
||||
const speech = await payload.create({
|
||||
collection: 'speech',
|
||||
data: {
|
||||
sourceKey: bundle.sourceKey,
|
||||
classifiedBy: bundle.facets?.classifiedBy ?? 'gazetteer',
|
||||
searchText: segmentSearchText ?? '',
|
||||
...data,
|
||||
},
|
||||
depth: 0,
|
||||
req,
|
||||
// Intentional bypass: the import CLI is a trusted actor with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
speechId = speech.id
|
||||
created = true
|
||||
}
|
||||
|
||||
// C154 — the speech-level search text mirrors the segments and is owned by
|
||||
// this bundle: `segments: undefined` (skip/ASR failure) preserves whatever
|
||||
// is stored, exactly like the segments themselves.
|
||||
const segmentSearchText =
|
||||
bundle.segments === undefined
|
||||
? undefined
|
||||
: normalizeForSearch(bundle.segments.map((segment) => segment.text).join(' '))
|
||||
let segmentsInserted = 0
|
||||
let segmentsDeleted = 0
|
||||
if (bundle.segments !== undefined) {
|
||||
const removed = await payload.delete({
|
||||
collection: 'speechSegment',
|
||||
where: { speech: { equals: speechId } },
|
||||
req,
|
||||
// Intentional bypass: replacing segments owned by the speech being imported.
|
||||
overrideAccess: true,
|
||||
})
|
||||
segmentsDeleted = removed.docs.length
|
||||
|
||||
const metadata = metadataData(bundle)
|
||||
const facets: Partial<SpeechWriteData> = {}
|
||||
if (bundle.facets && !manualFacetsPreserved) {
|
||||
const municipalitySlugs = bundle.facets.municipalities.map((entry) => entry.slug)
|
||||
const municipalities =
|
||||
municipalitySlugs.length === 0
|
||||
? []
|
||||
: (
|
||||
await payload.find({
|
||||
collection: 'municipality',
|
||||
where: { slug: { in: municipalitySlugs } },
|
||||
limit: municipalitySlugs.length,
|
||||
depth: 0,
|
||||
select: { slug: true },
|
||||
req,
|
||||
// Intentional bypass: resolving gazetteer slugs inside the import transaction.
|
||||
overrideAccess: true,
|
||||
})
|
||||
).docs
|
||||
facets.topics = bundle.facets.topics
|
||||
facets.scopes = bundle.facets.scopes
|
||||
facets.classifiedBy = bundle.facets.classifiedBy
|
||||
facets.mentionedMunicipalities = municipalities.map((doc) => doc.id)
|
||||
facets.mentionedPeople = bundle.facets.people
|
||||
facets.mentionedPrograms = bundle.facets.programs
|
||||
facets.mentionedProjects = bundle.facets.projects
|
||||
}
|
||||
const data = { ...metadata, ...facets }
|
||||
|
||||
let speechId: number
|
||||
let created = false
|
||||
if (current) {
|
||||
await payload.update({
|
||||
collection: 'speech',
|
||||
id: current.id,
|
||||
data: segmentSearchText === undefined ? data : { ...data, searchText: segmentSearchText },
|
||||
depth: 0,
|
||||
req,
|
||||
// Intentional bypass: the import CLI is a trusted actor with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
speechId = current.id
|
||||
} else {
|
||||
const speech = await payload.create({
|
||||
collection: 'speech',
|
||||
data: {
|
||||
sourceKey: bundle.sourceKey,
|
||||
classifiedBy: bundle.facets?.classifiedBy ?? 'gazetteer',
|
||||
searchText: segmentSearchText ?? '',
|
||||
...data,
|
||||
},
|
||||
depth: 0,
|
||||
req,
|
||||
// Intentional bypass: the import CLI is a trusted actor with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
speechId = speech.id
|
||||
created = true
|
||||
}
|
||||
|
||||
let segmentsInserted = 0
|
||||
let segmentsDeleted = 0
|
||||
if (bundle.segments !== undefined) {
|
||||
const removed = await payload.delete({
|
||||
for (const [index, segment] of bundle.segments.entries()) {
|
||||
await payload.create({
|
||||
collection: 'speechSegment',
|
||||
where: { speech: { equals: speechId } },
|
||||
data: {
|
||||
speech: speechId,
|
||||
order: index + 1,
|
||||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
text: segment.text,
|
||||
searchText: normalizeForSearch(segment.text),
|
||||
},
|
||||
req,
|
||||
// Intentional bypass: replacing segments owned by the speech being imported.
|
||||
// Intentional bypass: the import CLI is a trusted actor with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
segmentsDeleted = removed.docs.length
|
||||
|
||||
for (const [index, segment] of bundle.segments.entries()) {
|
||||
await payload.create({
|
||||
collection: 'speechSegment',
|
||||
data: {
|
||||
speech: speechId,
|
||||
order: index + 1,
|
||||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
text: segment.text,
|
||||
searchText: normalizeForSearch(segment.text),
|
||||
},
|
||||
req,
|
||||
// Intentional bypass: the import CLI is a trusted actor with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
segmentsInserted += 1
|
||||
}
|
||||
segmentsInserted += 1
|
||||
}
|
||||
}
|
||||
|
||||
return { created, segmentsInserted, segmentsDeleted, manualFacetsPreserved }
|
||||
})
|
||||
return { created, segmentsInserted, segmentsDeleted, manualFacetsPreserved }
|
||||
}
|
||||
|
||||
export const upsertSpeechBundle = async (
|
||||
payload: Payload,
|
||||
bundle: SpeechImportBundle,
|
||||
options: SpeechUpsertOptions = {},
|
||||
): Promise<SpeechUpsertCounts> => {
|
||||
if (options.req) return upsertSpeechBundleInTransaction(payload, bundle, options.req)
|
||||
return withPayloadTransaction(payload, ({ req }) =>
|
||||
upsertSpeechBundleInTransaction(payload, bundle, req),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ const durationBucketWhere = (bucket: SpeechDurationBucket): Where => {
|
|||
}
|
||||
|
||||
const buildSpeechFacetWhere = (state: SpeechListState): Where[] => {
|
||||
const filters: Where[] = []
|
||||
// C215 — this contract is the Câmara list: the web speeches share the table
|
||||
// but are served by their own source (C216), never mixed here.
|
||||
const filters: Where[] = [{ origin: { equals: 'camara' } }]
|
||||
|
||||
if (state.years?.length) filters.push({ year: { in: state.years } })
|
||||
if (state.topics?.length) filters.push({ topics: { in: state.topics } })
|
||||
|
|
|
|||
|
|
@ -91,6 +91,9 @@ const loadSpeechFilterOptions = async (
|
|||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
// C215 — options are the Câmara catalog's; the web source (C216) builds
|
||||
// its own options from its rows.
|
||||
where: { origin: { equals: 'camara' } },
|
||||
select: { year: true, phase: true, mentionedMunicipalities: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
|
|
|
|||
514
src/utilities/speech/webSpeechIngest.ts
Normal file
514
src/utilities/speech/webSpeechIngest.ts
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
import 'server-only'
|
||||
|
||||
import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import {
|
||||
buildRecordingAudioFfmpegArgs,
|
||||
mergeChunkTranscriptions,
|
||||
RECORDING_AUDIO_CHUNK_SECONDS,
|
||||
type RecordingChunkTranscription,
|
||||
type RecordingTranscriptSegment,
|
||||
} from '@/lib/recordingTranscription'
|
||||
import type { SpeechFacetInput } from '@/lib/speechGazetteer'
|
||||
import {
|
||||
canonicalWebSpeechUrl,
|
||||
INTERNET_SPEECH_MEDIA_SLUG,
|
||||
isCompleteWebSpeechState,
|
||||
requiresDirectMediaUrl,
|
||||
webSpeechAtFromPublishedAt,
|
||||
webSpeechPlatformLabel,
|
||||
webSpeechSourceKey,
|
||||
type WebSpeechFinding,
|
||||
} from '@/lib/webSpeech'
|
||||
import {
|
||||
DEEPINFRA_TRANSCRIBE_COST_PER_MINUTE_USD,
|
||||
deepInfraTranscribeSegments,
|
||||
type TranscribeSegmentsResult,
|
||||
} from '@/utilities/ai/deepInfraTranscribe'
|
||||
import { downloadUrlToFile } from '@/utilities/media/downloadToFile'
|
||||
import { ffmpegBinary, messageOf, runFfmpeg } from '@/utilities/media/ffmpeg'
|
||||
import {
|
||||
downloadWithYtDlp,
|
||||
readYtDlpMetadata,
|
||||
resolveYtDlp,
|
||||
YTDLP_INSTALL_HINT,
|
||||
type YtDlpMetadata,
|
||||
} from '@/utilities/media/ytdlp'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import {
|
||||
classifySpeech,
|
||||
type SpeechClassificationResult,
|
||||
} from '@/utilities/speech/speechClassifier'
|
||||
import {
|
||||
findSpeechImportState,
|
||||
upsertSpeechBundle,
|
||||
type SpeechImportBundle,
|
||||
} from '@/utilities/speech/speechImport'
|
||||
|
||||
/**
|
||||
* C215 — one web speech end to end: acquire the media (yt-dlp or direct HTTP),
|
||||
* transcribe with timestamps (the C199 chunk owner), classify (the C153 owner),
|
||||
* mirror the artifact in the private collection and upsert idempotently (the
|
||||
* Câmara import owner). Every dependency is injectable so the int specs run
|
||||
* offline; the CLI is the only caller.
|
||||
*/
|
||||
|
||||
/** Audio extraction has no duration to scale the timeout from; 30 min is ample. */
|
||||
const FFMPEG_TIMEOUT_MS = 30 * 60_000
|
||||
const ACQUISITION_FAILURE = 'Não foi possível baixar a mídia da fala.'
|
||||
const TRANSCRIPTION_FAILURE = 'Não foi possível transcrever a fala.'
|
||||
const MEDIA_FAILURE = 'Não foi possível guardar a mídia da fala.'
|
||||
const CLASSIFICATION_FAILURE = 'Não foi possível classificar a fala.'
|
||||
|
||||
type WebSpeechIngestStatus = 'created' | 'updated' | 'skipped' | 'failed'
|
||||
type WebSpeechIngestStage =
|
||||
| 'validation'
|
||||
| 'acquisition'
|
||||
| 'transcription'
|
||||
| 'classification'
|
||||
| 'media'
|
||||
| 'upsert'
|
||||
|
||||
export type WebSpeechIngestResult = {
|
||||
sourceKey: string
|
||||
status: WebSpeechIngestStatus
|
||||
stage: WebSpeechIngestStage | null
|
||||
error: string | null
|
||||
asrSeconds: number
|
||||
asrCostUsd: number
|
||||
llmCostUsd: number | null
|
||||
}
|
||||
|
||||
type WebSpeechAcquisition = {
|
||||
/** Produced file inside the temp dir, extension included. */
|
||||
fileName: string
|
||||
metadata: YtDlpMetadata | null
|
||||
}
|
||||
|
||||
export type WebSpeechAcquirer = (
|
||||
finding: WebSpeechFinding,
|
||||
tempDir: string,
|
||||
) => Promise<WebSpeechAcquisition>
|
||||
|
||||
export type WebSpeechTranscriber = (
|
||||
file: Blob,
|
||||
options?: { filename?: string },
|
||||
) => Promise<TranscribeSegmentsResult>
|
||||
|
||||
export type WebSpeechClassifier = (input: SpeechFacetInput) => Promise<SpeechClassificationResult>
|
||||
|
||||
export type WebSpeechIngestDeps = {
|
||||
acquire?: WebSpeechAcquirer
|
||||
transcribe?: WebSpeechTranscriber
|
||||
classify?: WebSpeechClassifier
|
||||
runFfmpeg?: typeof runFfmpeg
|
||||
downloadThumbnail?: (url: string, destinationPath: string) => Promise<void>
|
||||
}
|
||||
|
||||
class WebSpeechIngestError extends Error {
|
||||
readonly stage: WebSpeechIngestStage
|
||||
|
||||
constructor(stage: WebSpeechIngestStage, message: string) {
|
||||
super(message)
|
||||
this.stage = stage
|
||||
}
|
||||
}
|
||||
|
||||
const extensionOf = (url: string, fallback: string): string => {
|
||||
try {
|
||||
const name = new URL(url).pathname.split('/').pop() ?? ''
|
||||
const index = name.lastIndexOf('.')
|
||||
const extension = index >= 0 ? name.slice(index).toLowerCase() : ''
|
||||
return /^\.[a-z0-9]{1,5}$/.test(extension) ? extension : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/** yt-dlp for YouTube/Instagram; direct mediaUrl for radio/audio. */
|
||||
const defaultAcquire: WebSpeechAcquirer = async (finding, tempDir) => {
|
||||
if (requiresDirectMediaUrl(finding.platform)) {
|
||||
if (!finding.mediaUrl) throw new Error('Achado sem mediaUrl.')
|
||||
const fileName = `source${extensionOf(finding.mediaUrl, '.mp3')}`
|
||||
await downloadUrlToFile({ url: finding.mediaUrl, destinationPath: join(tempDir, fileName) })
|
||||
return { fileName, metadata: null }
|
||||
}
|
||||
|
||||
const resolved = await resolveYtDlp()
|
||||
if (!resolved.ok) throw new Error(`${resolved.reason}. ${YTDLP_INSTALL_HINT}`)
|
||||
|
||||
const metadata = await readYtDlpMetadata({ bin: resolved.bin, url: finding.url })
|
||||
await downloadWithYtDlp({
|
||||
bin: resolved.bin,
|
||||
url: finding.url,
|
||||
outputTemplate: join(tempDir, 'source.%(ext)s'),
|
||||
ffmpegLocation: ffmpegBinary(),
|
||||
})
|
||||
const fileName = (await readdir(tempDir)).find((name) => name.startsWith('source.'))
|
||||
if (!fileName) throw new Error('yt-dlp não produziu arquivo.')
|
||||
return { fileName, metadata }
|
||||
}
|
||||
|
||||
type BundleMetadata = {
|
||||
title?: string | null
|
||||
channel?: string | null
|
||||
externalId?: string | null
|
||||
durationSeconds?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The web bundle: Câmara-only fields stay null (no legislature, no VOD, no
|
||||
* official transcript) and the web metadata travels only when known —
|
||||
* `undefined` preserves the stored value on a metadata refresh.
|
||||
*/
|
||||
const baseBundle = (
|
||||
finding: WebSpeechFinding,
|
||||
sourceKey: string,
|
||||
speechAt: string,
|
||||
metadata: BundleMetadata,
|
||||
): SpeechImportBundle => ({
|
||||
sourceKey,
|
||||
origin: 'web',
|
||||
platform: finding.platform,
|
||||
externalId: metadata.externalId,
|
||||
sourceUrl: canonicalWebSpeechUrl(finding.platform, finding.url),
|
||||
title: metadata.title,
|
||||
channel: metadata.channel,
|
||||
speechAt,
|
||||
year: Number(speechAt.slice(0, 4)),
|
||||
legislature: null,
|
||||
type: null,
|
||||
phase: null,
|
||||
durationSeconds: metadata.durationSeconds ?? null,
|
||||
summary: null,
|
||||
officialTranscript: null,
|
||||
officialTextUrl: null,
|
||||
keywords: [],
|
||||
eventId: null,
|
||||
eventType: null,
|
||||
eventStartAt: null,
|
||||
eventEndAt: null,
|
||||
youtubeUrl: null,
|
||||
presidingOfficer: null,
|
||||
audioId: null,
|
||||
excerptTMs: null,
|
||||
vodPlaybackUrl: null,
|
||||
vodDownloadUrl: null,
|
||||
})
|
||||
|
||||
const emptyResult = (
|
||||
sourceKey: string,
|
||||
status: WebSpeechIngestStatus,
|
||||
stage: WebSpeechIngestStage | null,
|
||||
error: string | null,
|
||||
): WebSpeechIngestResult => ({
|
||||
sourceKey,
|
||||
status,
|
||||
stage,
|
||||
error,
|
||||
asrSeconds: 0,
|
||||
asrCostUsd: 0,
|
||||
llmCostUsd: null,
|
||||
})
|
||||
|
||||
type ExtractedTranscript = {
|
||||
segments: RecordingTranscriptSegment[]
|
||||
durationSeconds: number | null
|
||||
asrSeconds: number
|
||||
}
|
||||
|
||||
/** Extract → chunk → transcribe → merge (the C199 owners, relative offsets). */
|
||||
const extractTranscript = async ({
|
||||
ffmpeg,
|
||||
transcribe,
|
||||
inputPath,
|
||||
tempDir,
|
||||
declaredDuration,
|
||||
}: {
|
||||
ffmpeg: typeof runFfmpeg
|
||||
transcribe: WebSpeechTranscriber
|
||||
inputPath: string
|
||||
tempDir: string
|
||||
declaredDuration: number | null
|
||||
}): Promise<ExtractedTranscript> => {
|
||||
const outputPattern = join(tempDir, 'chunk-%03d.mp3')
|
||||
await ffmpeg(
|
||||
buildRecordingAudioFfmpegArgs({ inputPath, outputPattern }),
|
||||
declaredDuration ?? 0,
|
||||
TRANSCRIPTION_FAILURE,
|
||||
FFMPEG_TIMEOUT_MS,
|
||||
)
|
||||
const chunkFiles = (await readdir(tempDir)).filter((name) => /^chunk-\d+\.mp3$/.test(name)).sort()
|
||||
if (chunkFiles.length === 0) throw new Error(TRANSCRIPTION_FAILURE)
|
||||
|
||||
const chunks: RecordingChunkTranscription[] = []
|
||||
let asrSeconds = 0
|
||||
let measuredDuration = 0
|
||||
for (const [index, chunkFile] of chunkFiles.entries()) {
|
||||
const bytes = await readFile(join(tempDir, chunkFile))
|
||||
const result = await transcribe(new Blob([bytes], { type: 'audio/mpeg' }), {
|
||||
filename: chunkFile,
|
||||
})
|
||||
if (!result.ok) throw new Error(result.error)
|
||||
chunks.push({
|
||||
offsetSeconds: index * RECORDING_AUDIO_CHUNK_SECONDS,
|
||||
segments: result.segments,
|
||||
})
|
||||
asrSeconds += result.durationSeconds ?? RECORDING_AUDIO_CHUNK_SECONDS
|
||||
if (result.durationSeconds !== null) measuredDuration += result.durationSeconds
|
||||
}
|
||||
|
||||
const segments = mergeChunkTranscriptions(chunks)
|
||||
if (segments.length === 0) throw new Error(TRANSCRIPTION_FAILURE)
|
||||
|
||||
// Only measured data (or the declared duration / the last segment end) —
|
||||
// never the chunk count as a duration.
|
||||
const lastSegmentEnd = segments.at(-1)?.endSeconds
|
||||
const durationSeconds =
|
||||
measuredDuration > 0
|
||||
? Math.round(measuredDuration)
|
||||
: (declaredDuration ?? (lastSegmentEnd !== undefined ? Math.round(lastSegmentEnd) : null))
|
||||
|
||||
return { segments, durationSeconds, asrSeconds }
|
||||
}
|
||||
|
||||
/**
|
||||
* The artifact and the speech in ONE transaction (`req` threaded through the
|
||||
* upload and the upsert). Throws `WebSpeechIngestError('media')`; the caller
|
||||
* owns the best-effort cleanup of a thumbnail created before the failure.
|
||||
*/
|
||||
const persistIngestedSpeech = async ({
|
||||
payload,
|
||||
finding,
|
||||
sourceKey,
|
||||
speechAt,
|
||||
metadata,
|
||||
facets,
|
||||
segments,
|
||||
durationSeconds,
|
||||
inputPath,
|
||||
thumbnail,
|
||||
}: {
|
||||
payload: Payload
|
||||
finding: WebSpeechFinding
|
||||
sourceKey: string
|
||||
speechAt: string
|
||||
metadata: { title: string | null; channel: string | null; externalId: string | null }
|
||||
facets: SpeechClassificationResult['facets']
|
||||
segments: RecordingTranscriptSegment[]
|
||||
durationSeconds: number | null
|
||||
inputPath: string
|
||||
thumbnail: number | null
|
||||
}): Promise<void> => {
|
||||
const alt = `${webSpeechPlatformLabel(finding.platform)} — ${metadata.title ?? speechAt}`
|
||||
|
||||
try {
|
||||
await withPayloadTransaction(payload, async ({ req }) => {
|
||||
const media = await payload.create({
|
||||
collection: INTERNET_SPEECH_MEDIA_SLUG,
|
||||
data: { alt },
|
||||
filePath: inputPath,
|
||||
req,
|
||||
// Intentional bypass: the ingestion CLI is a trusted actor with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
await upsertSpeechBundle(
|
||||
payload,
|
||||
{
|
||||
...baseBundle(finding, sourceKey, speechAt, { ...metadata, durationSeconds }),
|
||||
facets,
|
||||
segments: segments.map((segment) => ({
|
||||
startSeconds: segment.startSeconds,
|
||||
endSeconds: segment.endSeconds,
|
||||
text: segment.text,
|
||||
})),
|
||||
mirroredMedia: media.id,
|
||||
thumbnail,
|
||||
},
|
||||
{ req },
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
throw new WebSpeechIngestError('media', messageOf(error, MEDIA_FAILURE))
|
||||
}
|
||||
}
|
||||
|
||||
export const ingestWebSpeech = async (
|
||||
payload: Payload,
|
||||
finding: WebSpeechFinding,
|
||||
{ reprocess = false, deps = {} }: { reprocess?: boolean; deps?: WebSpeechIngestDeps } = {},
|
||||
): Promise<WebSpeechIngestResult> => {
|
||||
const sourceKey = webSpeechSourceKey(finding)
|
||||
const speechAt = webSpeechAtFromPublishedAt(finding.publishedAt)
|
||||
if (!speechAt)
|
||||
return emptyResult(sourceKey, 'failed', 'validation', 'Data de publicação inválida.')
|
||||
|
||||
const acquire = deps.acquire ?? defaultAcquire
|
||||
const transcribe = deps.transcribe ?? deepInfraTranscribeSegments
|
||||
const classify = deps.classify ?? classifySpeech
|
||||
const ffmpeg = deps.runFfmpeg ?? runFfmpeg
|
||||
const downloadThumbnail =
|
||||
deps.downloadThumbnail ??
|
||||
((url, destinationPath) => downloadUrlToFile({ url, destinationPath }))
|
||||
|
||||
let asrSeconds = 0
|
||||
let tempDir: string | null = null
|
||||
|
||||
try {
|
||||
const state = await findSpeechImportState(payload, sourceKey)
|
||||
|
||||
if (isCompleteWebSpeechState(state) && !reprocess) {
|
||||
// Complete row: refresh only the metadata the finding brought (undefined
|
||||
// preserves the stored values); never re-download or re-transcribe.
|
||||
await upsertSpeechBundle(
|
||||
payload,
|
||||
baseBundle(finding, sourceKey, speechAt, {
|
||||
title: finding.title,
|
||||
channel: finding.channel,
|
||||
externalId: finding.externalId,
|
||||
durationSeconds: finding.durationSeconds ?? state?.durationSeconds ?? undefined,
|
||||
}),
|
||||
)
|
||||
return emptyResult(sourceKey, 'skipped', null, null)
|
||||
}
|
||||
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'web-speech-'))
|
||||
let acquisition: WebSpeechAcquisition
|
||||
try {
|
||||
acquisition = await acquire(finding, tempDir)
|
||||
} catch (error) {
|
||||
throw new WebSpeechIngestError('acquisition', messageOf(error, ACQUISITION_FAILURE))
|
||||
}
|
||||
|
||||
const inputPath = join(tempDir, acquisition.fileName)
|
||||
const metadata = acquisition.metadata
|
||||
const title = finding.title ?? metadata?.title ?? null
|
||||
const channel = finding.channel ?? metadata?.channel ?? null
|
||||
const externalId = finding.externalId ?? metadata?.externalId ?? null
|
||||
const declaredDuration = finding.durationSeconds ?? metadata?.durationSeconds ?? null
|
||||
const thumbnailUrl = finding.thumbnailUrl ?? metadata?.thumbnailUrl ?? null
|
||||
|
||||
let extracted: ExtractedTranscript
|
||||
try {
|
||||
extracted = await extractTranscript({
|
||||
ffmpeg,
|
||||
transcribe,
|
||||
inputPath,
|
||||
tempDir,
|
||||
declaredDuration,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new WebSpeechIngestError('transcription', messageOf(error, TRANSCRIPTION_FAILURE))
|
||||
}
|
||||
asrSeconds = extracted.asrSeconds
|
||||
|
||||
let classification: SpeechClassificationResult
|
||||
try {
|
||||
classification = await classify({
|
||||
transcript: extracted.segments.map((segment) => segment.text).join(' '),
|
||||
summary: title,
|
||||
keywords: [],
|
||||
})
|
||||
} catch (error) {
|
||||
throw new WebSpeechIngestError('classification', messageOf(error, CLASSIFICATION_FAILURE))
|
||||
}
|
||||
|
||||
// Thumbnail is best effort and lives OUTSIDE the main transaction: a broken
|
||||
// image (or a failed upload) must never lose the speech.
|
||||
let thumbnailId: number | null = null
|
||||
if (thumbnailUrl) {
|
||||
try {
|
||||
const destination = join(tempDir, `thumbnail${extensionOf(thumbnailUrl, '.jpg')}`)
|
||||
await downloadThumbnail(thumbnailUrl, destination)
|
||||
const thumbnail = await payload.create({
|
||||
collection: INTERNET_SPEECH_MEDIA_SLUG,
|
||||
data: { alt: `Capa — ${title ?? speechAt}` },
|
||||
filePath: destination,
|
||||
// Intentional bypass: the ingestion CLI is a trusted actor with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
thumbnailId = thumbnail.id
|
||||
} catch {
|
||||
thumbnailId = null
|
||||
}
|
||||
}
|
||||
|
||||
const previousThumbnail = state?.thumbnail ?? null
|
||||
const previousMediaIds = [
|
||||
...(typeof state?.mirroredMedia === 'number' ? [state.mirroredMedia] : []),
|
||||
// Only a replaced thumbnail is deleted; keeping it is the fallback when
|
||||
// the new download failed.
|
||||
...(thumbnailId !== null && typeof previousThumbnail === 'number' ? [previousThumbnail] : []),
|
||||
]
|
||||
|
||||
try {
|
||||
await persistIngestedSpeech({
|
||||
payload,
|
||||
finding,
|
||||
sourceKey,
|
||||
speechAt,
|
||||
metadata: { title, channel, externalId },
|
||||
facets: classification.facets,
|
||||
segments: extracted.segments,
|
||||
durationSeconds: extracted.durationSeconds,
|
||||
inputPath,
|
||||
thumbnail: thumbnailId ?? previousThumbnail,
|
||||
})
|
||||
} catch (error) {
|
||||
if (thumbnailId !== null) {
|
||||
await payload
|
||||
.delete({
|
||||
collection: INTERNET_SPEECH_MEDIA_SLUG,
|
||||
id: thumbnailId,
|
||||
// Intentional bypass: the pipeline cleans up the file it just created.
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Replaced artifacts are removed best effort: an orphan object in the
|
||||
// private bucket is harmless, a leaked row is not.
|
||||
if (previousMediaIds.length > 0) {
|
||||
await payload
|
||||
.delete({
|
||||
collection: INTERNET_SPEECH_MEDIA_SLUG,
|
||||
where: { id: { in: previousMediaIds } },
|
||||
// Intentional bypass: the pipeline replaces the files it owns.
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
sourceKey,
|
||||
status: state ? 'updated' : 'created',
|
||||
stage: null,
|
||||
error: null,
|
||||
asrSeconds,
|
||||
asrCostUsd: (asrSeconds * DEEPINFRA_TRANSCRIBE_COST_PER_MINUTE_USD) / 60,
|
||||
llmCostUsd: classification.llm.estimatedCostUsd,
|
||||
}
|
||||
} catch (error) {
|
||||
const asrCostUsd = (asrSeconds * DEEPINFRA_TRANSCRIBE_COST_PER_MINUTE_USD) / 60
|
||||
if (error instanceof WebSpeechIngestError) {
|
||||
return {
|
||||
...emptyResult(sourceKey, 'failed', error.stage, error.message),
|
||||
asrSeconds,
|
||||
asrCostUsd,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...emptyResult(sourceKey, 'failed', null, messageOf(error, 'Falha na ingestão da fala.')),
|
||||
asrSeconds,
|
||||
asrCostUsd,
|
||||
}
|
||||
} finally {
|
||||
if (tempDir) await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ const createSpeech = async (
|
|||
collection: 'speech',
|
||||
data: {
|
||||
sourceKey: campaign.fixtures.value('speech'),
|
||||
origin: 'camara',
|
||||
speechAt: input.speechAt ?? '2026-03-10T18:00',
|
||||
type: 'BREVES COMUNICAÇÕES',
|
||||
phase: 'Breves Comunicações',
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const createSpeech = async (
|
|||
collection: 'speech',
|
||||
data: {
|
||||
sourceKey: campaign.fixtures.value('speech'),
|
||||
origin: 'camara',
|
||||
speechAt: '2026-08-11T18:48',
|
||||
type: 'BREVES COMUNICAÇÕES',
|
||||
phase: 'Breves Comunicações',
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ describe('institution snapshot (C187)', () => {
|
|||
collection: 'speech',
|
||||
data: {
|
||||
sourceKey: `test-inst-${randomUUID()}`,
|
||||
origin: 'camara',
|
||||
speechAt: '2024-05-10T17:28',
|
||||
type: 'DISCURSO',
|
||||
phase: 'Discurso',
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const createSpeech = async (
|
|||
collection: 'speech',
|
||||
data: {
|
||||
sourceKey: `test-${randomUUID()}`,
|
||||
origin: 'camara',
|
||||
speechAt: '2023-02-07T17:28',
|
||||
type: 'BREVES COMUNICAÇÕES',
|
||||
phase: 'Breves Comunicações',
|
||||
|
|
@ -199,6 +200,7 @@ describe('speech catalog (C153)', () => {
|
|||
collection: 'speech',
|
||||
data: {
|
||||
sourceKey: `test-${randomUUID()}`,
|
||||
origin: 'camara',
|
||||
speechAt: '2023-02-08T10:00',
|
||||
classifiedBy: 'gazetteer',
|
||||
},
|
||||
|
|
@ -223,6 +225,7 @@ describe('speech catalog (C153)', () => {
|
|||
collection: 'speech',
|
||||
data: {
|
||||
sourceKey: `test-${randomUUID()}`,
|
||||
origin: 'camara',
|
||||
speechAt: '2023-02-08T10:00',
|
||||
classifiedBy: 'gazetteer',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,11 +20,12 @@ type SpeechCreateData = RequiredDataFromCollectionSlug<'speech'>
|
|||
|
||||
const createSpeech = (
|
||||
transactionID: number | string,
|
||||
data: SpeechCreateData,
|
||||
data: Omit<SpeechCreateData, 'origin'>,
|
||||
): Promise<{ id: number }> =>
|
||||
payload.create({
|
||||
collection: 'speech',
|
||||
data,
|
||||
// C215 — this spec measures the Câmara coverage.
|
||||
data: { origin: 'camara', ...data },
|
||||
depth: 0,
|
||||
req: { transactionID },
|
||||
// Intentional bypass: the spec seeds its own rows.
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ describe('theme snapshot (C190)', () => {
|
|||
collection: 'speech',
|
||||
data: {
|
||||
sourceKey: `test-theme-${randomUUID()}`,
|
||||
origin: 'camara',
|
||||
speechAt: '2024-05-10T17:28',
|
||||
type: 'DISCURSO',
|
||||
phase: 'Discurso',
|
||||
|
|
|
|||
205
tests/int/webSpeechImport.int.spec.ts
Normal file
205
tests/int/webSpeechImport.int.spec.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { matchMunicipalityMentions } from '@/lib/speechGazetteer'
|
||||
import { INTERNET_SPEECH_MEDIA_SLUG } from '@/lib/webSpeech'
|
||||
import type { InternetSpeechMedia } from '@/payload-types'
|
||||
import config from '@/payload.config'
|
||||
import {
|
||||
findSpeechImportState,
|
||||
upsertSpeechBundle,
|
||||
type SpeechImportBundle,
|
||||
} from '@/utilities/speech/speechImport'
|
||||
|
||||
// C215 — the web-speech fields of the shared upsert: round-trip, idempotency
|
||||
// (metadata refreshes, segments/facets preserved when omitted), manual
|
||||
// curation wins and the speech delete cascades over the mirrored media.
|
||||
|
||||
let payload: Payload
|
||||
const createdSourceKeys = new Set<string>()
|
||||
|
||||
const MEDIA_BYTES = Buffer.from('web-speech-media')
|
||||
|
||||
const createMedia = async (name: string): Promise<InternetSpeechMedia> =>
|
||||
payload.create({
|
||||
collection: INTERNET_SPEECH_MEDIA_SLUG,
|
||||
data: { alt: `Arquivo ${name}` },
|
||||
file: { data: MEDIA_BYTES, mimetype: 'video/mp4', name, size: MEDIA_BYTES.length },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const webBundle = (overrides: Partial<SpeechImportBundle> = {}): SpeechImportBundle => ({
|
||||
sourceKey: `web:test-${randomUUID()}`,
|
||||
origin: 'web',
|
||||
platform: 'youtube',
|
||||
externalId: 'abc123',
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
title: 'Entrevista na rádio',
|
||||
channel: 'Canal do Solla',
|
||||
speechAt: '2026-09-20T00:00',
|
||||
year: 2026,
|
||||
legislature: null,
|
||||
type: null,
|
||||
phase: null,
|
||||
durationSeconds: 180,
|
||||
summary: null,
|
||||
officialTranscript: null,
|
||||
officialTextUrl: null,
|
||||
keywords: [],
|
||||
eventId: null,
|
||||
eventType: null,
|
||||
eventStartAt: null,
|
||||
eventEndAt: null,
|
||||
youtubeUrl: null,
|
||||
presidingOfficer: null,
|
||||
audioId: null,
|
||||
excerptTMs: null,
|
||||
vodPlaybackUrl: null,
|
||||
vodDownloadUrl: null,
|
||||
facets: {
|
||||
topics: ['saude'],
|
||||
scopes: ['bahia'],
|
||||
municipalities: matchMunicipalityMentions('Feira de Santana'),
|
||||
people: ['Lula'],
|
||||
programs: [],
|
||||
projects: [],
|
||||
classifiedBy: 'gazetteer',
|
||||
},
|
||||
segments: [{ startSeconds: 0, endSeconds: 2.5, text: 'A saúde pública baiana' }],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const findBySourceKey = async (sourceKey: string) => {
|
||||
const found = await payload.find({
|
||||
collection: 'speech',
|
||||
where: { sourceKey: { equals: sourceKey } },
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return found.docs[0] ?? null
|
||||
}
|
||||
|
||||
describe('upsertSpeechBundle web origin (C215)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const sourceKey of createdSourceKeys) {
|
||||
const speech = await findBySourceKey(sourceKey)
|
||||
if (!speech) continue
|
||||
await payload.delete({ collection: 'speech', id: speech.id, depth: 0, overrideAccess: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('persists the origin fields and the mirrored media', async () => {
|
||||
const media = await createMedia('fala.mp4')
|
||||
const bundle = webBundle({ mirroredMedia: media.id })
|
||||
createdSourceKeys.add(bundle.sourceKey)
|
||||
|
||||
const counts = await upsertSpeechBundle(payload, bundle)
|
||||
expect(counts.created).toBe(true)
|
||||
|
||||
const speech = await findBySourceKey(bundle.sourceKey)
|
||||
expect(speech).toMatchObject({
|
||||
origin: 'web',
|
||||
platform: 'youtube',
|
||||
externalId: 'abc123',
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
title: 'Entrevista na rádio',
|
||||
channel: 'Canal do Solla',
|
||||
speechAt: '2026-09-20T00:00',
|
||||
year: 2026,
|
||||
durationSeconds: 180,
|
||||
classifiedBy: 'gazetteer',
|
||||
topics: ['saude'],
|
||||
})
|
||||
expect(speech?.mirroredMedia).toMatchObject({ id: media.id })
|
||||
expect(speech?.mentionedMunicipalities?.[0]).toMatchObject({ slug: 'feira-de-santana' })
|
||||
|
||||
const state = await findSpeechImportState(payload, bundle.sourceKey)
|
||||
expect(state).toMatchObject({
|
||||
origin: 'web',
|
||||
platform: 'youtube',
|
||||
externalId: 'abc123',
|
||||
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||
mirroredMedia: media.id,
|
||||
thumbnail: null,
|
||||
segmentCount: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes metadata on re-run and preserves omitted segments/facets', async () => {
|
||||
const bundle = webBundle()
|
||||
createdSourceKeys.add(bundle.sourceKey)
|
||||
await upsertSpeechBundle(payload, bundle)
|
||||
|
||||
const counts = await upsertSpeechBundle(
|
||||
payload,
|
||||
webBundle({
|
||||
sourceKey: bundle.sourceKey,
|
||||
title: 'Título atualizado',
|
||||
segments: undefined,
|
||||
facets: undefined,
|
||||
}),
|
||||
)
|
||||
expect(counts.created).toBe(false)
|
||||
expect(counts.segmentsInserted).toBe(0)
|
||||
|
||||
const speech = await findBySourceKey(bundle.sourceKey)
|
||||
expect(speech?.title).toBe('Título atualizado')
|
||||
expect(speech?.searchText).toBe('a saude publica baiana')
|
||||
expect(speech?.topics).toEqual(['saude'])
|
||||
const segments = await payload.count({
|
||||
collection: 'speechSegment',
|
||||
where: { speech: { equals: speech?.id } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(segments.totalDocs).toBe(1)
|
||||
})
|
||||
|
||||
it('never overwrites a manual facet curation with the web metadata', async () => {
|
||||
const bundle = webBundle()
|
||||
createdSourceKeys.add(bundle.sourceKey)
|
||||
await upsertSpeechBundle(payload, bundle)
|
||||
const speech = await findBySourceKey(bundle.sourceKey)
|
||||
|
||||
await payload.update({
|
||||
collection: 'speech',
|
||||
id: speech?.id,
|
||||
data: { classifiedBy: 'manual', topics: ['cultura'], title: 'Curado à mão' },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
await upsertSpeechBundle(payload, webBundle({ sourceKey: bundle.sourceKey }))
|
||||
const again = await findBySourceKey(bundle.sourceKey)
|
||||
expect(again?.topics).toEqual(['cultura'])
|
||||
expect(again?.classifiedBy).toBe('manual')
|
||||
// Manual curation is facets only: origin metadata still refreshes.
|
||||
expect(again?.title).toBe('Entrevista na rádio')
|
||||
})
|
||||
|
||||
it('deletes the mirrored media when the speech is deleted', async () => {
|
||||
const media = await createMedia('cascata.mp4')
|
||||
const bundle = webBundle({ mirroredMedia: media.id })
|
||||
createdSourceKeys.add(bundle.sourceKey)
|
||||
await upsertSpeechBundle(payload, bundle)
|
||||
const speech = await findBySourceKey(bundle.sourceKey)
|
||||
|
||||
await payload.delete({ collection: 'speech', id: speech?.id, depth: 0, overrideAccess: true })
|
||||
|
||||
const mediaAfter = await payload
|
||||
.findByID({
|
||||
collection: INTERNET_SPEECH_MEDIA_SLUG,
|
||||
id: media.id,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null)
|
||||
expect(mediaAfter).toBeNull()
|
||||
})
|
||||
})
|
||||
355
tests/int/webSpeechIngest.int.spec.ts
Normal file
355
tests/int/webSpeechIngest.int.spec.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
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 { INTERNET_SPEECH_MEDIA_SLUG, type WebSpeechFinding } from '@/lib/webSpeech'
|
||||
import type { Speech } from '@/payload-types'
|
||||
import config from '@/payload.config'
|
||||
import {
|
||||
ingestWebSpeech,
|
||||
type WebSpeechAcquirer,
|
||||
type WebSpeechClassifier,
|
||||
type WebSpeechIngestDeps,
|
||||
type WebSpeechTranscriber,
|
||||
} from '@/utilities/speech/webSpeechIngest'
|
||||
|
||||
// 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[] = []
|
||||
|
||||
// Real magic bytes so Payload's own detection derives the mimeType (the fake
|
||||
// pipeline writes files, but the upload must behave like production).
|
||||
const MP4_BYTES = Buffer.from('00000018667479706d703432000000006d70343269736f6d', 'hex')
|
||||
const MP3_BYTES = Buffer.from('fffb900000000000000000000000000000000000', 'hex')
|
||||
// Real 2x2 JPEG: Payload runs sharp over image uploads (focal point), so the
|
||||
// fake thumbnail must decode like a real one.
|
||||
const JPEG_BYTES = Buffer.from(
|
||||
'/9j/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZGNywtQFdBRkxOUlNSMj5aYVpQYEpRUk//2wBDAQ4ODhMREyYVFSZPNS01T09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0//wAARCAACAAIDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAX/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAABAb/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCGACsH/9k=',
|
||||
'base64',
|
||||
)
|
||||
|
||||
// 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: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('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('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')
|
||||
})
|
||||
})
|
||||
104
tests/unit/downloadToFile.unit.spec.ts
Normal file
104
tests/unit/downloadToFile.unit.spec.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { downloadUrlToFile } from '@/utilities/media/downloadToFile'
|
||||
|
||||
// C215 — the direct-acquisition streamer: non-2xx fails before writing, the
|
||||
// byte ceiling guards the disk and the happy path writes the exact bytes.
|
||||
|
||||
let tempDir: string
|
||||
|
||||
const response = ({
|
||||
body,
|
||||
status = 200,
|
||||
headers = {},
|
||||
}: {
|
||||
body: ReadableStream<Uint8Array> | null
|
||||
status?: number
|
||||
headers?: Record<string, string>
|
||||
}) =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
body,
|
||||
headers: new Headers(headers),
|
||||
}) as Response
|
||||
|
||||
const streamOf = (chunks: string[]): ReadableStream<Uint8Array> =>
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
const fetchOf = (value: Response) => (async () => value) as unknown as typeof fetch
|
||||
|
||||
describe('downloadUrlToFile', () => {
|
||||
beforeAll(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'download-to-file-'))
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('writes the streamed body to the destination', async () => {
|
||||
const destinationPath = join(tempDir, 'ok.mp3')
|
||||
await downloadUrlToFile({
|
||||
url: 'https://radio.example/a.mp3',
|
||||
destinationPath,
|
||||
fetchImpl: fetchOf(response({ body: streamOf(['fala', ' da ', 'radio']) })),
|
||||
})
|
||||
expect(await readFile(destinationPath, 'utf8')).toBe('fala da radio')
|
||||
})
|
||||
|
||||
it('fails on a non-2xx answer without writing the file', async () => {
|
||||
const destinationPath = join(tempDir, 'missing.mp3')
|
||||
await expect(
|
||||
downloadUrlToFile({
|
||||
url: 'https://radio.example/missing.mp3',
|
||||
destinationPath,
|
||||
fetchImpl: fetchOf(response({ body: null, status: 404 })),
|
||||
}),
|
||||
).rejects.toThrow(/HTTP 404/)
|
||||
})
|
||||
|
||||
it('fails when the response has no body', async () => {
|
||||
await expect(
|
||||
downloadUrlToFile({
|
||||
url: 'https://radio.example/empty.mp3',
|
||||
destinationPath: join(tempDir, 'empty.mp3'),
|
||||
fetchImpl: fetchOf(response({ body: null })),
|
||||
}),
|
||||
).rejects.toThrow(/sem corpo/)
|
||||
})
|
||||
|
||||
it('refuses a declared size above the ceiling', async () => {
|
||||
await expect(
|
||||
downloadUrlToFile({
|
||||
url: 'https://radio.example/big.mp3',
|
||||
destinationPath: join(tempDir, 'big.mp3'),
|
||||
fetchImpl: fetchOf(
|
||||
response({ body: streamOf(['x']), headers: { 'content-length': '99' } }),
|
||||
),
|
||||
maxBytes: 10,
|
||||
}),
|
||||
).rejects.toThrow(/tamanho máximo/)
|
||||
})
|
||||
|
||||
it('aborts mid-stream when the body crosses the ceiling', async () => {
|
||||
await expect(
|
||||
downloadUrlToFile({
|
||||
url: 'https://radio.example/stream.mp3',
|
||||
destinationPath: join(tempDir, 'stream.mp3'),
|
||||
fetchImpl: fetchOf(response({ body: streamOf(['12345', '67890', 'x']) })),
|
||||
maxBytes: 10,
|
||||
}),
|
||||
).rejects.toThrow(/tamanho máximo/)
|
||||
})
|
||||
})
|
||||
|
|
@ -48,7 +48,12 @@ const execute =
|
|||
(findSpeechExcerpts(ctxFor(actor, payload)) as unknown as ExecutableTool).execute(args)
|
||||
|
||||
const SPEECH_WHERE = {
|
||||
and: [{ searchText: { like: 'hospital' } }, { searchText: { like: 'suburbio' } }],
|
||||
and: [
|
||||
// C215 — the assistant keeps suggesting Câmara excerpts only.
|
||||
{ origin: { equals: 'camara' } },
|
||||
{ searchText: { like: 'hospital' } },
|
||||
{ searchText: { like: 'suburbio' } },
|
||||
],
|
||||
}
|
||||
|
||||
const reranked = (index: number, reason = 'Serve à intenção') => ({ index, reason })
|
||||
|
|
|
|||
|
|
@ -8,9 +8,12 @@ import {
|
|||
} from '@/utilities/speech/speechListFilters'
|
||||
import { parseSpeechListParams } from '@/utilities/speech/speechListUrl'
|
||||
|
||||
// C215 — the Câmara list contract always carries the source discriminator.
|
||||
const camara = { origin: { equals: 'camara' } }
|
||||
|
||||
describe('buildSpeechListWhere', () => {
|
||||
it('is empty without filters', () => {
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({}))).toEqual({})
|
||||
it('keeps only the Câmara discriminator without filters', () => {
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({}))).toEqual({ and: [camara] })
|
||||
})
|
||||
|
||||
it('assembles the facet filters', () => {
|
||||
|
|
@ -26,6 +29,7 @@ describe('buildSpeechListWhere', () => {
|
|||
|
||||
expect(where).toEqual({
|
||||
and: [
|
||||
camara,
|
||||
{ year: { in: [2026] } },
|
||||
{ topics: { in: ['saude'] } },
|
||||
{ scopes: { in: ['bahia'] } },
|
||||
|
|
@ -39,6 +43,7 @@ describe('buildSpeechListWhere', () => {
|
|||
const where = buildSpeechListWhere(parseSpeechListParams({ q: 'Farmácia Popular' }))
|
||||
expect(where).toEqual({
|
||||
and: [
|
||||
camara,
|
||||
{
|
||||
or: [
|
||||
{ searchText: { like: 'farmacia popular' } },
|
||||
|
|
@ -51,24 +56,26 @@ describe('buildSpeechListWhere', () => {
|
|||
|
||||
it('maps each duration bucket to its range and ORs them', () => {
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({ duration: 'curta' }))).toEqual({
|
||||
and: [{ durationSeconds: { less_than: 120 } }],
|
||||
and: [camara, { durationSeconds: { less_than: 120 } }],
|
||||
})
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({ duration: ['media'] }))).toEqual({
|
||||
and: [
|
||||
camara,
|
||||
{
|
||||
durationSeconds: { greater_than_equal: 120, less_than: 300 },
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({ duration: ['longa'] }))).toEqual({
|
||||
and: [{ durationSeconds: { greater_than_equal: 300 } }],
|
||||
and: [camara, { durationSeconds: { greater_than_equal: 300 } }],
|
||||
})
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({ duration: ['sem_duracao'] }))).toEqual({
|
||||
and: [{ durationSeconds: { exists: false } }],
|
||||
and: [camara, { durationSeconds: { exists: false } }],
|
||||
})
|
||||
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({ duration: ['curta', 'longa'] }))).toEqual({
|
||||
and: [
|
||||
camara,
|
||||
{
|
||||
or: [
|
||||
{ durationSeconds: { less_than: 120 } },
|
||||
|
|
@ -89,6 +96,7 @@ describe('buildSpeechListWhereIncludingCutOrigins (C174)', () => {
|
|||
|
||||
expect(where).toEqual({
|
||||
and: [
|
||||
camara,
|
||||
{
|
||||
or: [
|
||||
{ searchText: { like: 'reforma' } },
|
||||
|
|
@ -107,6 +115,7 @@ describe('buildSpeechListWhereIncludingCutOrigins (C174)', () => {
|
|||
)
|
||||
expect(withFacet).toEqual({
|
||||
and: [
|
||||
camara,
|
||||
{ topics: { in: ['saude'] } },
|
||||
{
|
||||
or: [
|
||||
|
|
@ -132,6 +141,7 @@ describe('buildSpeechListWhere theme expansion (C192)', () => {
|
|||
|
||||
expect(where).toEqual({
|
||||
and: [
|
||||
camara,
|
||||
{
|
||||
or: [
|
||||
{ searchText: { like: 'defesa do sus' } },
|
||||
|
|
@ -148,7 +158,7 @@ describe('buildSpeechListWhere theme expansion (C192)', () => {
|
|||
|
||||
it('dedupes a term that repeats the query', () => {
|
||||
expect(buildSpeechListWhere(parseSpeechListParams({ q: 'SUS' }), ['SUS'])).toEqual({
|
||||
and: [{ or: [{ searchText: { like: 'sus' } }, { keywords: { contains: 'SUS' } }] }],
|
||||
and: [camara, { or: [{ searchText: { like: 'sus' } }, { keywords: { contains: 'SUS' } }] }],
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -161,6 +171,7 @@ describe('buildSpeechListWhere theme expansion (C192)', () => {
|
|||
),
|
||||
).toEqual({
|
||||
and: [
|
||||
camara,
|
||||
{ topics: { in: ['saude'] } },
|
||||
{
|
||||
or: [
|
||||
|
|
|
|||
147
tests/unit/webSpeech.unit.spec.ts
Normal file
147
tests/unit/webSpeech.unit.spec.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
canonicalWebSpeechUrl,
|
||||
parseWebSpeechBatch,
|
||||
parseWebSpeechFinding,
|
||||
webSpeechAtFromPublishedAt,
|
||||
webSpeechPlatformLabel,
|
||||
webSpeechSourceKey,
|
||||
} from '@/lib/webSpeech'
|
||||
|
||||
// C215 — the pure web-speech contract: identity (sourceKey), canonical URL and
|
||||
// the finding-batch validation that fails one entry without killing the batch.
|
||||
|
||||
describe('canonicalWebSpeechUrl', () => {
|
||||
it('collapses every YouTube shape to the watch URL', () => {
|
||||
expect(canonicalWebSpeechUrl('youtube', 'https://youtu.be/dQw4w9WgXcQ?si=track')).toBe(
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
)
|
||||
expect(
|
||||
canonicalWebSpeechUrl('youtube', 'https://www.youtube.com/shorts/dQw4w9WgXcQ?feature=share'),
|
||||
).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
|
||||
expect(
|
||||
canonicalWebSpeechUrl('youtube', 'https://m.youtube.com/watch?v=dQw4w9WgXcQ&t=30s'),
|
||||
).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
|
||||
expect(canonicalWebSpeechUrl('youtube', 'https://www.youtube.com/live/dQw4w9WgXcQ')).toBe(
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
)
|
||||
})
|
||||
|
||||
it('collapses Instagram post shapes and normalizes reels', () => {
|
||||
expect(
|
||||
canonicalWebSpeechUrl('instagram', 'https://www.instagram.com/reels/ABC123/?igsh=xyz'),
|
||||
).toBe('https://www.instagram.com/reel/ABC123/')
|
||||
expect(canonicalWebSpeechUrl('instagram', 'https://instagram.com/p/ABC123')).toBe(
|
||||
'https://www.instagram.com/p/ABC123/',
|
||||
)
|
||||
})
|
||||
|
||||
it('drops tracking params, the fragment and the trailing slash for direct audio', () => {
|
||||
expect(
|
||||
canonicalWebSpeechUrl(
|
||||
'radio',
|
||||
'https://RadioBahia.com.br/audio/entrevista.mp3/?utm_source=share&id=7#t=1',
|
||||
),
|
||||
).toBe('https://radiobahia.com.br/audio/entrevista.mp3?id=7')
|
||||
})
|
||||
|
||||
it('keeps an unparseable URL trimmed instead of inventing one', () => {
|
||||
expect(canonicalWebSpeechUrl('audio', ' nao-e-url ')).toBe('nao-e-url')
|
||||
})
|
||||
})
|
||||
|
||||
describe('webSpeechSourceKey', () => {
|
||||
it('prefers the external id and falls back to the canonical URL', () => {
|
||||
expect(
|
||||
webSpeechSourceKey({
|
||||
platform: 'youtube',
|
||||
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
externalId: 'dQw4w9WgXcQ',
|
||||
}),
|
||||
).toBe('web:youtube:dQw4w9WgXcQ')
|
||||
expect(
|
||||
webSpeechSourceKey({ platform: 'audio', url: 'https://radio.example/a.mp3?utm_source=x' }),
|
||||
).toBe('web:audio:https://radio.example/a.mp3')
|
||||
})
|
||||
|
||||
it('is stable across YouTube URL shapes (same identity, no duplicate row)', () => {
|
||||
const short = webSpeechSourceKey({ platform: 'youtube', url: 'https://youtu.be/dQw4w9WgXcQ' })
|
||||
const long = webSpeechSourceKey({
|
||||
platform: 'youtube',
|
||||
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90',
|
||||
})
|
||||
expect(short).toBe(long)
|
||||
expect(short).toBe('web:youtube:https://www.youtube.com/watch?v=dQw4w9WgXcQ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('webSpeechAtFromPublishedAt', () => {
|
||||
it('normalizes date-only and converts an explicit timezone to Brasília', () => {
|
||||
expect(webSpeechAtFromPublishedAt('2026-09-20')).toBe('2026-09-20T00:00')
|
||||
// 14:35Z is 11:35 in America/Bahia (UTC-3, no DST).
|
||||
expect(webSpeechAtFromPublishedAt('2026-09-20T14:35:00Z')).toBe('2026-09-20T11:35')
|
||||
expect(webSpeechAtFromPublishedAt('2026-09-20T14:35:00-03:00')).toBe('2026-09-20T14:35')
|
||||
expect(webSpeechAtFromPublishedAt('2026-09-20 14:35')).toBe('2026-09-20T14:35')
|
||||
})
|
||||
|
||||
it('returns null for an unrecognized or impossible date', () => {
|
||||
expect(webSpeechAtFromPublishedAt('20/09/2026')).toBeNull()
|
||||
expect(webSpeechAtFromPublishedAt('2026-09-20lixo')).toBeNull()
|
||||
expect(webSpeechAtFromPublishedAt('2026-99-99')).toBeNull()
|
||||
expect(webSpeechAtFromPublishedAt('2026-09-20T25:00')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWebSpeechFinding', () => {
|
||||
const validYoutube = {
|
||||
platform: 'youtube',
|
||||
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
publishedAt: '2026-09-20',
|
||||
}
|
||||
|
||||
it('accepts a YouTube finding without mediaUrl', () => {
|
||||
const parsed = parseWebSpeechFinding(validYoutube)
|
||||
expect(parsed.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('requires mediaUrl for radio/audio and publishedAt everywhere', () => {
|
||||
const radio = parseWebSpeechFinding({ ...validYoutube, platform: 'radio' })
|
||||
expect(radio.ok).toBe(false)
|
||||
if (!radio.ok) expect(radio.error).toContain('mediaUrl')
|
||||
|
||||
const noDate = parseWebSpeechFinding({ ...validYoutube, publishedAt: undefined })
|
||||
expect(noDate.ok).toBe(false)
|
||||
|
||||
const badDate = parseWebSpeechFinding({ ...validYoutube, publishedAt: 'ontem' })
|
||||
expect(badDate.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an unknown platform and a non-URL', () => {
|
||||
expect(parseWebSpeechFinding({ ...validYoutube, platform: 'tiktok' }).ok).toBe(false)
|
||||
expect(parseWebSpeechFinding({ ...validYoutube, url: 'nada' }).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWebSpeechBatch', () => {
|
||||
it('validates only the envelope and keeps the findings raw', () => {
|
||||
const parsed = parseWebSpeechBatch({ generatedAt: '2026-09-24T10:00:00Z', findings: [{}] })
|
||||
expect(parsed.ok).toBe(true)
|
||||
if (parsed.ok) {
|
||||
expect(parsed.batch.generatedAt).toBe('2026-09-24T10:00:00Z')
|
||||
expect(parsed.batch.findings).toEqual([{}])
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed on a malformed envelope', () => {
|
||||
expect(parseWebSpeechBatch({}).ok).toBe(false)
|
||||
expect(parseWebSpeechBatch({ findings: 'nope' }).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('webSpeechPlatformLabel', () => {
|
||||
it('owns the pt-BR labels the acervo shows', () => {
|
||||
expect(webSpeechPlatformLabel('youtube')).toBe('YouTube')
|
||||
expect(webSpeechPlatformLabel('radio')).toBe('Rádio')
|
||||
})
|
||||
})
|
||||
147
tests/unit/webSpeeches.unit.spec.ts
Normal file
147
tests/unit/webSpeeches.unit.spec.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatWebSpeechReport,
|
||||
isCompleteWebSpeechState,
|
||||
planWebSpeechBatch,
|
||||
summarizePlannedActions,
|
||||
summarizeWebSpeechResults,
|
||||
webSpeechPlannedAction,
|
||||
} from '../../scripts/lib/webSpeeches.mjs'
|
||||
|
||||
type WebSpeechIngestResult =
|
||||
import('../../src/utilities/speech/webSpeechIngest.ts').WebSpeechIngestResult
|
||||
|
||||
// C215 — the CLI planning/reporting rules: one invalid finding fails only
|
||||
// itself, duplicates are dropped by natural key and the report never hides a
|
||||
// failure.
|
||||
|
||||
const youtube = (overrides = {}) => ({
|
||||
platform: 'youtube',
|
||||
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
publishedAt: '2026-09-20',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('planWebSpeechBatch', () => {
|
||||
it('keeps the valid entries and reports invalid/duplicates separately', () => {
|
||||
const plan = planWebSpeechBatch([
|
||||
youtube(),
|
||||
youtube({ url: 'https://youtu.be/dQw4w9WgXcQ' }),
|
||||
youtube({ platform: 'radio' }),
|
||||
youtube({ externalId: 'outro', url: 'https://www.youtube.com/watch?v=zzz' }),
|
||||
{ platform: 'youtube', url: 'nao-e-url', publishedAt: '2026-09-20' },
|
||||
])
|
||||
|
||||
expect(plan.entries.map((entry) => entry.sourceKey)).toEqual([
|
||||
'web:youtube:https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'web:youtube:outro',
|
||||
])
|
||||
expect(plan.duplicates).toEqual([
|
||||
{ index: 1, sourceKey: 'web:youtube:https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
|
||||
])
|
||||
expect(plan.invalid.map((entry) => entry.index)).toEqual([2, 4])
|
||||
})
|
||||
})
|
||||
|
||||
describe('webSpeechPlannedAction', () => {
|
||||
it('reads the stored state: new, incomplete, complete', () => {
|
||||
expect(webSpeechPlannedAction(null)).toBe('novo')
|
||||
expect(webSpeechPlannedAction({ segmentCount: 0, mirroredMedia: null })).toBe('atualizado')
|
||||
expect(webSpeechPlannedAction({ segmentCount: 3, mirroredMedia: null })).toBe('atualizado')
|
||||
expect(webSpeechPlannedAction({ segmentCount: 3, mirroredMedia: 9 })).toBe('ignorado')
|
||||
expect(webSpeechPlannedAction({ segmentCount: 3, mirroredMedia: 9 }, true)).toBe('reprocessado')
|
||||
})
|
||||
|
||||
it('isCompleteWebSpeechState requires transcript and mirrored file', () => {
|
||||
expect(isCompleteWebSpeechState(null)).toBe(false)
|
||||
expect(isCompleteWebSpeechState({ segmentCount: 0, mirroredMedia: 1 })).toBe(false)
|
||||
expect(isCompleteWebSpeechState({ segmentCount: 1, mirroredMedia: null })).toBe(false)
|
||||
expect(isCompleteWebSpeechState({ segmentCount: 1, mirroredMedia: 1 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeWebSpeechResults', () => {
|
||||
it('counts statuses, sums costs and lists every failure', () => {
|
||||
const result = (overrides: Partial<WebSpeechIngestResult>): WebSpeechIngestResult => ({
|
||||
sourceKey: 'web:youtube:x',
|
||||
status: 'skipped',
|
||||
stage: null,
|
||||
error: null,
|
||||
asrSeconds: 0,
|
||||
asrCostUsd: 0,
|
||||
llmCostUsd: null,
|
||||
...overrides,
|
||||
})
|
||||
const summary = summarizeWebSpeechResults([
|
||||
result({ status: 'created', asrSeconds: 60, asrCostUsd: 0.00045, llmCostUsd: 0.0001 }),
|
||||
result({ status: 'updated', asrSeconds: 120, asrCostUsd: 0.0009 }),
|
||||
result({}),
|
||||
result({
|
||||
status: 'failed',
|
||||
sourceKey: 'web:audio:https://radio.example/a.mp3',
|
||||
stage: 'acquisition',
|
||||
error: 'HTTP 404',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(summary).toMatchObject({ created: 1, updated: 1, skipped: 1, failed: 1 })
|
||||
expect(summary.asrSeconds).toBe(180)
|
||||
expect(summary.asrCostUsd).toBeCloseTo(0.00135, 6)
|
||||
expect(summary.llmCostUsd).toBeCloseTo(0.0001, 6)
|
||||
expect(summary.failures).toEqual([
|
||||
{
|
||||
sourceKey: 'web:audio:https://radio.example/a.mp3',
|
||||
stage: 'acquisition',
|
||||
error: 'HTTP 404',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatWebSpeechReport', () => {
|
||||
const base = {
|
||||
runAt: '2026-09-24T10:00:00.000Z',
|
||||
mode: 'import',
|
||||
findings: 4,
|
||||
planned: 3,
|
||||
invalid: [{ index: 3, error: 'url: inválida' }],
|
||||
duplicates: [{ index: 1, sourceKey: 'web:youtube:x' }],
|
||||
durationMs: 12_500,
|
||||
created: 1,
|
||||
updated: 1,
|
||||
skipped: 1,
|
||||
failed: 1,
|
||||
asrSeconds: 90,
|
||||
asrCostUsd: 0.000675,
|
||||
llmCostUsd: 0.0001,
|
||||
failures: [
|
||||
{
|
||||
sourceKey: 'web:audio:https://radio.example/a.mp3',
|
||||
stage: 'acquisition' as const,
|
||||
error: 'HTTP 404',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('prints counts, costs, the invalid entries and every failure', () => {
|
||||
const lines = formatWebSpeechReport(base).join('\n')
|
||||
expect(lines).toContain('novos: 1 · atualizados: 1 · ignorados: 1 · falhas: 1')
|
||||
expect(lines).toContain('ASR: 1min30s (~US$ 0.0007)')
|
||||
expect(lines).toContain('inválido #3')
|
||||
expect(lines).toContain('web:audio:https://radio.example/a.mp3 (acquisition): HTTP 404')
|
||||
})
|
||||
|
||||
it('prints the dry-run plan instead of the execution counts', () => {
|
||||
const lines = formatWebSpeechReport({
|
||||
...base,
|
||||
mode: 'dry-run',
|
||||
plannedActions: summarizePlannedActions([
|
||||
{ action: 'novo' },
|
||||
{ action: 'ignorado' },
|
||||
{ action: 'reprocessado' },
|
||||
]),
|
||||
}).join('\n')
|
||||
expect(lines).toContain('plano (dry-run): novos: 1 · atualizados: 0 · ignorados: 1')
|
||||
})
|
||||
})
|
||||
145
tests/unit/ytdlp.unit.spec.ts
Normal file
145
tests/unit/ytdlp.unit.spec.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
downloadWithYtDlp,
|
||||
parseYtDlpMetadata,
|
||||
readYtDlpMetadata,
|
||||
resolveYtDlp,
|
||||
YTDLP_FORMAT,
|
||||
type YtDlpResult,
|
||||
} from '@/utilities/media/ytdlp'
|
||||
|
||||
// C215 — the yt-dlp owner: fail-closed resolution, metadata parsing and the
|
||||
// deterministic download command (format cap, merge, ffmpeg location).
|
||||
|
||||
const okRun =
|
||||
(stdout = '') =>
|
||||
async (): Promise<YtDlpResult> => ({ ok: true, stdout, stderr: '' })
|
||||
|
||||
describe('resolveYtDlp', () => {
|
||||
it('accepts the explicit YTDLP_PATH and the PATH fallback', async () => {
|
||||
await expect(
|
||||
resolveYtDlp({ env: { YTDLP_PATH: '/custom/yt-dlp' }, run: okRun('2026.09.20') }),
|
||||
).resolves.toEqual({ ok: true, bin: '/custom/yt-dlp' })
|
||||
await expect(resolveYtDlp({ env: {}, run: okRun('2026.09.20') })).resolves.toEqual({
|
||||
ok: true,
|
||||
bin: 'yt-dlp',
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed with the reason when the binary is missing', async () => {
|
||||
const missing = async (): Promise<YtDlpResult> => ({
|
||||
ok: false,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
error: { code: 'ENOENT' },
|
||||
})
|
||||
const resolved = await resolveYtDlp({ env: {}, run: missing })
|
||||
expect(resolved.ok).toBe(false)
|
||||
if (!resolved.ok) expect(resolved.reason).toContain('não encontrado')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseYtDlpMetadata', () => {
|
||||
it('maps the dump-json document and falls back to uploader', () => {
|
||||
expect(
|
||||
parseYtDlpMetadata({
|
||||
id: 'dQw4w9WgXcQ',
|
||||
title: ' Entrevista ',
|
||||
uploader: 'Canal do Solla',
|
||||
duration: 123.4,
|
||||
thumbnail: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hq.jpg',
|
||||
}),
|
||||
).toEqual({
|
||||
externalId: 'dQw4w9WgXcQ',
|
||||
title: 'Entrevista',
|
||||
channel: 'Canal do Solla',
|
||||
durationSeconds: 123.4,
|
||||
thumbnailUrl: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hq.jpg',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps absent/invalid fields null instead of guessing', () => {
|
||||
expect(parseYtDlpMetadata({ duration: -5, title: '' })).toEqual({
|
||||
externalId: null,
|
||||
title: null,
|
||||
channel: null,
|
||||
durationSeconds: null,
|
||||
thumbnailUrl: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('readYtDlpMetadata', () => {
|
||||
it('parses the JSON stdout', async () => {
|
||||
const run = okRun(JSON.stringify({ id: 'x', title: 'Fala', duration: 60 }))
|
||||
await expect(
|
||||
readYtDlpMetadata({ bin: 'yt-dlp', url: 'https://x', run }),
|
||||
).resolves.toMatchObject({ externalId: 'x', title: 'Fala', durationSeconds: 60 })
|
||||
})
|
||||
|
||||
it('throws with the stderr tail on failure and on malformed JSON', async () => {
|
||||
const failing = async (): Promise<YtDlpResult> => ({
|
||||
ok: false,
|
||||
stdout: '',
|
||||
stderr: 'ERROR: Video unavailable',
|
||||
})
|
||||
await expect(
|
||||
readYtDlpMetadata({ bin: 'yt-dlp', url: 'https://x', run: failing }),
|
||||
).rejects.toThrow(/Video unavailable/)
|
||||
|
||||
await expect(
|
||||
readYtDlpMetadata({ bin: 'yt-dlp', url: 'https://x', run: okRun('not json') }),
|
||||
).rejects.toThrow(/fora do formato/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadWithYtDlp', () => {
|
||||
it('caps quality, merges to mp4 and forwards the ffmpeg location', async () => {
|
||||
const calls: string[][] = []
|
||||
const run = async (_bin: string, args: string[]): Promise<YtDlpResult> => {
|
||||
calls.push(args)
|
||||
return { ok: true, stdout: '', stderr: '' }
|
||||
}
|
||||
|
||||
await downloadWithYtDlp({
|
||||
bin: '/bin/yt-dlp',
|
||||
url: 'https://www.youtube.com/watch?v=x',
|
||||
outputTemplate: '/tmp/source.%(ext)s',
|
||||
ffmpegLocation: '/packed/ffmpeg',
|
||||
run,
|
||||
})
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const args = calls[0]
|
||||
expect(args).toContain('--no-playlist')
|
||||
expect(args.slice(args.indexOf('-f'), args.indexOf('-f') + 2)).toEqual(['-f', YTDLP_FORMAT])
|
||||
expect(
|
||||
args.slice(args.indexOf('--merge-output-format'), args.indexOf('--merge-output-format') + 2),
|
||||
).toEqual(['--merge-output-format', 'mp4'])
|
||||
expect(
|
||||
args.slice(args.indexOf('--ffmpeg-location'), args.indexOf('--ffmpeg-location') + 2),
|
||||
).toEqual(['--ffmpeg-location', '/packed/ffmpeg'])
|
||||
expect(args.slice(args.indexOf('-o'), args.indexOf('-o') + 2)).toEqual([
|
||||
'-o',
|
||||
'/tmp/source.%(ext)s',
|
||||
])
|
||||
expect(args.at(-1)).toBe('https://www.youtube.com/watch?v=x')
|
||||
})
|
||||
|
||||
it('throws with the stderr tail when yt-dlp fails', async () => {
|
||||
const failing = async (): Promise<YtDlpResult> => ({
|
||||
ok: false,
|
||||
stdout: '',
|
||||
stderr: 'ERROR: unable to download',
|
||||
})
|
||||
await expect(
|
||||
downloadWithYtDlp({
|
||||
bin: 'yt-dlp',
|
||||
url: 'https://x',
|
||||
outputTemplate: '/tmp/source.%(ext)s',
|
||||
run: failing,
|
||||
}),
|
||||
).rejects.toThrow(/unable to download/)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue