import { mkdirSync } from 'node:fs'
import { resolveSocketPath } from '@repo/ipc'
import { EventBus } from './@shared/events'
import {
AddChannelUseCase,
AddHarvestChannelUseCase,
ChannelDetailsUseCase,
DisableAutoRecordingUseCase,
DownloadVodUseCase,
EnableAutoRecordingUseCase,
FinalizeDownloadUseCase,
FinalizeRecordingUseCase,
ForceRecordUseCase,
ForceStopUseCase,
HarvestCdnHostsUseCase,
LinkVodUseCase,
ListChannelsUseCase,
ListHarvestChannelsUseCase,
RemoveChannelUseCase,
RemoveHarvestChannelUseCase,
ResumeOrphanedDownloadsUseCase,
StartRecordingUseCase,
StopRecordingUseCase,
} from './application/use-cases'
import { config } from './config'
import { CdnHostHarvester } from './infrastructure/cdn-host-harvester'
import { resolveViaCdn, seedKnownCdnHosts } from './infrastructure/cdn-recovery'
import {
DrizzleCdnHostRepository,
DrizzleChannelRepository,
DrizzleDownloadRepository,
DrizzleHarvestChannelRepository,
DrizzleRecordingRepository,
DrizzleStreamRepository,
} from './infrastructure/database/repositories'
import {
DownloadFailedEvent,
DownloadFinishedEvent,
HttpVodDownloader,
} from './infrastructure/downloader'
import { IpcServer } from './infrastructure/ipc'
import { MediaStorage, StreamMetaStorage } from './infrastructure/media-storage'
import {
ChannelLiveEvent,
ChannelMonitor,
ChannelOfflineEvent,
} from './infrastructure/monitor'
import { resolveViaOfficial } from './infrastructure/official-vod'
import {
RecordingFailedEvent,
RecordingFinishedEvent,
StreamRecorder,
} from './infrastructure/recorder'
import { TwitchClientImpl } from './infrastructure/twitch/client'
import { VodLinker } from './infrastructure/vod-linker'
import { applyMigrations, createDrizzle } from './lib/drizzle'
import { createDatabase } from './lib/sqlite'
console.log(`daemon started (pid ${process.pid})`)
async function main() {
// Ordem importa: o diretório precisa existir ANTES de abrir o banco —
// bun:sqlite cria o arquivo .db sozinho, mas não a pasta pai (SQLITE_CANTOPEN).
mkdirSync(config.dataDir, { recursive: true })
// Infra ────────────────────────────────────────────────────────────────
const db = createDrizzle(createDatabase(config.databasePath))
applyMigrations(db)
// Bus central — uma única instância compartilhada por todos os
// produtores/consumidores de eventos do daemon.
const bus = new EventBus()
// Persistência ─────────────────────────────────────────────────────────
const storage = new MediaStorage({ rootPath: config.dataDir })
const channelRepository = new DrizzleChannelRepository({ drizzle: db })
const streamRepository = new DrizzleStreamRepository({ drizzle: db })
const recordingRepository = new DrizzleRecordingRepository({ drizzle: db })
const downloadRepository = new DrizzleDownloadRepository({ drizzle: db })
const cdnHostRepository = new DrizzleCdnHostRepository({ drizzle: db })
// Idempotente — seguro de chamar em todo boot. Depois do seed inicial, o
// pool cresce organicamente (ver DownloadVodUseCase).
await seedKnownCdnHosts(cdnHostRepository)
// Sem seed, ao contrário de cdnHostRepository acima — nasce vazia,
// povoada só via add-harvest-channel (CLI).
const harvestChannelRepository = new DrizzleHarvestChannelRepository({
drizzle: db,
})
// Serviços externos ────────────────────────────────────────────────────
const twitch = new TwitchClientImpl()
// Executor de gravação — spawna streamlink por canal via child process.
// Recebe o bus pra publicar RecordingFinished/FailedEvent no handleExit.
const recorder = new StreamRecorder({
twitch,
storage,
streamlinkBinPath: config.streamlinkBinPath,
bus,
})
// Storage de arquivos locais 'meta.json'
const streamMetaStorage = new StreamMetaStorage()
// Baixa VODs recuperados via CDN (infrastructure/cdn-recovery) — worker
// HTTP dentro do processo, não um child process. Publica
// DownloadFinished/FailedEvent no bus quando termina, mesmo padrão do
// StreamRecorder pra gravação ao vivo.
const vodDownloader = new HttpVodDownloader({
bus,
downloadRepository,
segmentConcurrency: config.downloadSegmentConcurrency,
maxConcurrentDownloads: config.maxConcurrentDownloads,
})
// Use cases — instanciados no composition root. Comandos são chamados
// pelo IPC; reactions (start/stop recording) são acionadas via subscribe
// no bus logo abaixo.
const addChannel = new AddChannelUseCase({
twitch,
channelRepository,
storage,
})
const enableAutoRecording = new EnableAutoRecordingUseCase({
channelRepository,
})
const disableAutoRecording = new DisableAutoRecordingUseCase({
channelRepository,
})
const removeChannel = new RemoveChannelUseCase({
channelRepository,
recorder,
})
const listChannels = new ListChannelsUseCase({
channelRepository,
recorder,
})
const startRecording = new StartRecordingUseCase({
streamRepository,
recordingRepository,
storage,
recorder,
streamMetaStorage,
})
const stopRecording = new StopRecordingUseCase({ recorder })
const finalizeRecording = new FinalizeRecordingUseCase({
streamMetaStorage,
recordingRepository,
})
const startRecord = new ForceRecordUseCase({
channelRepository,
twitch,
startRecording,
})
const stopRecord = new ForceStopUseCase({
channelRepository,
recorder,
stopRecording,
})
const channelDetails = new ChannelDetailsUseCase({
channelRepository,
streamRepository,
recordingRepository,
downloadRepository,
})
// Reaproveitada por DownloadVodUseCase e HarvestCdnHostsUseCase — os dois
// só precisam da mesma resolução oficial (C), com a mesma instância de
// `twitch` já criada acima.
const resolveOfficial = (params: Parameters<typeof resolveViaOfficial>[0]) =>
resolveViaOfficial(params, { twitchClient: twitch })
// Tenta o caminho oficial (C) primeiro quando a stream já tem `vodId`,
// cai pro CDN (B) só se o oficial não resolver — ver
// docs/design/002-download-de-vods.md e DownloadVodUseCase.
const downloadVod = new DownloadVodUseCase({
streamRepository,
downloadRepository,
channelRepository,
cdnHostRepository,
storage,
downloader: vodDownloader,
// Pool de hosts vem da tabela `cdn_host` (harvesting orgânico, ver
// DownloadVodUseCase) — sem lista estática, ver
// infrastructure/cdn-recovery/resolver.ts.
resolveCdn: async params =>
resolveViaCdn(params, { hosts: await cdnHostRepository.listHosts() }),
resolveOfficial,
})
const finalizeDownload = new FinalizeDownloadUseCase({ downloadRepository })
// Caminho A: descoberta oficial de VOD via GQL — ver
// docs/design/002-download-de-vods.md, seção A.
const linkVod = new LinkVodUseCase({ streamRepository, twitchClient: twitch })
// Harvesting ATIVO de hosts (desacoplado de um download real) — ver
// infrastructure/cdn-host-harvester.
const harvestCdnHosts = new HarvestCdnHostsUseCase({
twitchClient: twitch,
cdnHostRepository,
resolveOfficial,
})
// Gestão da lista de terceiros usada pelo harvesting ativo acima — ver
// infrastructure/cdn-host-harvester.
const addHarvestChannel = new AddHarvestChannelUseCase({
harvestChannelRepository,
})
const removeHarvestChannel = new RemoveHarvestChannelUseCase({
harvestChannelRepository,
})
const listHarvestChannels = new ListHarvestChannelsUseCase({
harvestChannelRepository,
})
// Cold resume: retoma downloads órfãos de um restart anterior — ver
// infrastructure/vod-executor e past conversations/decisoes-downloader.md.
const resumeOrphanedDownloads = new ResumeOrphanedDownloadsUseCase({
downloadRepository,
downloader: vodDownloader,
})
// Detector — publica eventos no bus, não conhece consumidores
const monitor = new ChannelMonitor({
twitch,
channelRepository,
streamRepository,
storage,
streamMetaStorage,
bus,
})
// Job periódico que descobre o vodId oficial de streams pendentes —
// cadência bem mais lenta que o Monitor (default 10min, ver vod-linker.ts).
const vodLinker = new VodLinker({ streamRepository, linkVod })
// Job periódico de harvesting ativo de hosts — canais monitorados +
// lista manual de terceiros (ver infrastructure/cdn-host-harvester).
const cdnHostHarvester = new CdnHostHarvester({
channelRepository,
harvestChannelRepository,
harvestCdnHosts,
})
// ═════════════════════════════════════════════════════════════════════
// A PONTE Monitor → use cases (via bus)
// Monitor publica ChannelLiveEvent/ChannelOfflineEvent; os subscribers
// abaixo são thin adapters que traduzem evento → primitivos → use case.
// Adicionar novo consumidor (webhook, métrica, audit) = mais linhas
// aqui, zero mudança em Monitor ou nos use cases.
// ═════════════════════════════════════════════════════════════════════
bus.subscribe(ChannelLiveEvent, async event => {
// Evento é publicado pra TODO canal que fica ao vivo, independente de
// autoRecord — outros subscribers (webhook, métrica) podem querer saber
// disso mesmo sem gravar. Quem decide se grava é este handler.
const channel = await channelRepository.findChannel(event.username)
if (!channel?.autoRecord) return
const result = await startRecording.execute({
channelName: event.username,
streamId: event.streamId,
title: event.title,
startedAt: event.startedAt,
})
if (result.isFailure()) console.error('[start-recording]', result.value)
})
bus.subscribe(ChannelOfflineEvent, async event => {
const result = await stopRecording.execute({ channelName: event.username })
if (result.isFailure()) console.error('[stop-recording]', result.value)
})
bus.subscribe(RecordingFinishedEvent, async event => {
const result = await finalizeRecording.execute({
channelName: event.username,
streamId: event.streamId,
storagePath: event.storagePath,
endedAt: event.endedAt,
bytes: event.bytes,
status: 'finished',
})
if (result.isFailure()) console.error('[finalize-recording]', result.value)
})
bus.subscribe(RecordingFailedEvent, async event => {
const result = await finalizeRecording.execute({
channelName: event.username,
streamId: event.streamId,
storagePath: event.storagePath,
endedAt: event.endedAt,
bytes: event.bytes,
status: 'failed',
})
if (result.isFailure()) console.error('[finalize-recording]', result.value)
})
bus.subscribe(DownloadFinishedEvent, async event => {
const result = await finalizeDownload.execute({
streamId: event.streamId,
endedAt: event.endedAt,
status: 'completed',
})
if (result.isFailure()) console.error('[finalize-download]', result.value)
})
bus.subscribe(DownloadFailedEvent, async event => {
const result = await finalizeDownload.execute({
streamId: event.streamId,
endedAt: event.endedAt,
status: 'failed',
})
if (result.isFailure()) console.error('[finalize-download]', result.value)
})
// Antes de começar a aceitar comandos novos: retoma qualquer download
// que ficou `downloading` órfão de um restart anterior (crash ou
// shutdown que não conseguiu parar os executores a tempo).
const resumeResult = await resumeOrphanedDownloads.execute()
if (resumeResult.isFailure()) {
console.error('[resume-orphaned-downloads]', resumeResult.value)
}
monitor.startMonitoring()
vodLinker.start()
cdnHostHarvester.start()
// Camada de IPC: escuta o socket e traduz comandos do CLI em chamadas
// aos use cases. Cada use case ainda é agnóstico de quem chamou.
const socketPath = resolveSocketPath()
const ipc = new IpcServer({
deps: {
addChannel,
enableAutoRecording,
disableAutoRecording,
removeChannel,
listChannels,
startRecord,
stopRecord,
channelDetails,
downloadVod,
addHarvestChannel,
removeHarvestChannel,
listHarvestChannels,
},
socketPath,
})
await ipc.listen()
console.log(`ipc listening at ${socketPath}`)
// Shutdown limpo ───────────────────────────────────────────────────────
await new Promise<void>(resolve => {
const shutdown = async (signal: NodeJS.Signals) => {
console.log(`\nreceived ${signal}, shutting down...`)
monitor.stop()
vodLinker.stop()
cdnHostHarvester.stop()
// Para todos os streamlink filhos ANTES do IPC — evita deixar
// child process órfão se o kernel bater no daemon logo depois.
await recorder.stopAll()
// Mesma lógica pros executores de download — se um ficar
// `downloading` órfão mesmo assim (kill -9 no meio, sem tempo pro
// SIGTERM), o boot scan do próximo start resolve (ver
// ResumeOrphanedDownloadsUseCase).
await vodDownloader.stopAll()
// Fecha o listener e remove o arquivo de socket pra não deixar órfão.
await ipc.close()
resolve()
}
process.once('SIGINT', shutdown)
process.once('SIGTERM', shutdown)
})
}
await main()