Status: implementado (2026-08-25) — ver "Fatiado — v1 implementado" abaixo. Este documento captura o raciocínio de desenho discutido antes/durante a implementação. A seção D (execução do download) foi reescrita depois da v1 pra ficar retomável entre boots — ver ADR 006 pro raciocínio completo dessa segunda fatia; o texto da seção D abaixo já reflete o desenho atual, não o da v1 original.
Implementação faseada em 4 issues do Linear (CAI-74 a CAI-77), construída em cima de uma primeira fatia (B+D+E) que veio antes delas. Todas as partes descritas neste documento (A, B, C, D, E e o harvesting de hosts) estão implementadas:
LinkVodUseCase + VodLinker, job
periódico que casa videos() do GQL com stream.startedAt por
proximidade (não por ID direto — ver seção A) e popula stream.vodId +
vodLookupStatus.resolveViaCdn
(infrastructure/cdn-recovery), reconstrução determinística do path via
hash SHA1, probing contra um pool de hosts.resolveViaOfficial
(infrastructure/official-vod): videoPlaybackAccessToken via GQL +
usher + seleção de variante por channel.qualityPref, com fallback pra
próxima qualidade disponível.DownloadVodUseCase tenta o caminho
oficial primeiro quando stream.vodId já existe (melhor qualidade,
não contorna controle de acesso); cai pro CDN só se o oficial não
resolver.HttpVodDownloader
(infrastructure/downloader), pool de segments, retry, eventos no bus.
Reescrita depois da v1 (mesmo dia, 2026-08-25) pra virar um
despachante que spawna child process por download — ver "D. Execução do
download" abaixo (já atualizada) e ADR 006.DrizzleDownloadRepository, índice único
parcial pra idempotência sob concorrência.cdn_host (Drizzle), seedada no boot
do daemon via seedKnownCdnHosts (infrastructure/cdn-recovery/known-hosts.ts,
os mesmos hosts já confirmados empiricamente, idempotente — seguro
chamar em todo boot). Crescimento é orgânico: toda resolução
bem-sucedida (via B ou C) grava o host que funcionou (DownloadVodUseCase,
best-effort — falha ao gravar não derruba o download). Substituiu a
antiga lista estática em infrastructure/cdn-recovery/host-pool.ts
(removida).
Nota de crescimento futuro: esse mecanismo só cresce com o uso do
próprio daemon — canais que ele nunca tenta baixar não contribuem host
nenhum. Se um dia isso se mostrar insuficiente (pool pequeno demais,
taxa de acerto do fallback B caindo), o próximo passo natural é
harvesting ativo: um job que sai testando VODs de canais quaisquer
(mesma técnica do spike apps/daemon/spikes/04-cdn-host-harvest.sh) pra
engordar o pool além do que o uso orgânico descobre sozinho. Não
implementado — só registrado aqui como extensão possível.O daemon grava lives ao vivo (streamlink, ver ADR 004),
mas nem toda stream é capturada ao vivo — o daemon pode estar offline no
momento, a gravação pode falhar, ou o operador pode só querer recuperar uma
stream antiga sem ter acompanhado ao vivo. A premissa deste desenho: já
registramos channelName, streamId e startedAt de toda stream que o
Monitor já viu ao vivo (tabela stream), e esses três dados são exatamente
o que se precisa pra localizar e baixar o VOD correspondente depois — seja
pelo caminho oficial da Twitch, seja por recuperação direta na CDN quando o
caminho oficial falha.
A tabela download já existe no schema, dormente — mesma situação que a
tabela recording estava antes de ser ligada.
Documentos existentes (ADR 004,
overview.md,
recording-twitch-streams.md)
descrevem o Monitor como usuário de um "app access token da Helix"
(client_credentials, ~60 dias). Isso não corresponde ao código real.
client.ts e
gql.ts mostram
que o Monitor usa o GQL não-documentado da Twitch (gql.twitch.tv/gql) com
o Client-Id público do client web (kimne78kx3ncx6brgo4mv6wki5h1ko) — sem
registro de app, sem secret, sem token de client_credentials. Não há
Helix oficial em uso em lugar nenhum do projeto hoje.
Isso importa pra este desenho porque significa que não existe app
registrado na Helix disponível — a descoberta de VOD (seção A) precisa
continuar no mesmo GQL não-documentado, não pode assumir o endpoint oficial
Get Videos da Helix. Os três documentos foram corrigidos (2026-08-24)
pra descrever a credencial real do monitor (Client-Id público do GQL,
sem ciclo de vida) em vez do "app token da Helix" inexistente.
Requisitos:
stream já registrada ao seu vodId quando o VOD ficar
disponível.videoPlaybackAccessToken) sempre buscado fresco no
momento do download, nunca armazenado — mesma premissa da ADR 004, agora
aplicada ao token de VOD em vez de live.channel.qualityPref (já existe no
schema), com fallback pra próxima melhor disponível se a qualidade pedida
não existir naquele VOD específico.streamlink) — busca segments na ordem
certa, concatena, trata falha transiente por segment individual com
retry.storagePath já calculado pelo MediaStorage pra cada stream — não
cria árvore de artefato paralela..ts, alimentando o mesmo job futuro de rewrap pra MP4 já citado
no roadmap do projeto — um pipeline de conversão, não dois.Não-requisitos (fora de escopo agora):
streamId/startedAt confiáveis dos canais que ele mesmo acompanhou —
não há intenção de virar um serviço de descoberta genérico.streamId/startedAt. Desnecessário: o próprio app já é a fonte desses
dados pros canais que monitora (ver
past conversations/Claude-Voddownloader website inquiry,
mensagem de 7/7/2026 3:40).Job periódico (VodLinkerJob ou nome equivalente), com cadência própria —
mais lenta que o tick de ~30s do Monitor, já que VOD leva tempo pra
processar depois do fim da live. Fluxo:
vodLookupStatus = pending (campo
novo, não reaproveitar RecordingStatus — é uma dimensão diferente do
ciclo de vida).user(login) { videos(first: N, type: ARCHIVE) { edges { node { id createdAt lengthSeconds } } } }.
Validado empiricamente em 2026-08-24 contra apofigeaa (ver
spikes/FINDINGS.md) —
funciona como query raw, sem persisted query. Revisão importante: essa
query não expõe o stream_id do broadcast, só o id do próprio VOD,
createdAt e lengthSeconds. Então o match com a stream já persistida
não pode ser por join direto de ID — precisa ser por proximidade de
createdAt × stream.startedAt do mesmo channelName (mesmo canal,
timestamps próximos, dentro de uma tolerância pequena de segundos).stream.vodId, marca vodLookupStatus = linked.pending, tenta de novo no próximo tick.Existe uma rota alternativa especulativa — capturar o vodId durante a
própria live via um possível campo archiveVideo { id } no stream do
GQL, o que eliminaria a necessidade do job pós-live inteiramente. Não
validado, documentado separadamente como plano B em
apps/daemon/notes/speculation-vod-discovery-archivevideo-fallback.md —
só perseguir se esta abordagem principal (passos 1-6 acima) se mostrar
inviável.
Técnica usada por ferramentas da comunidade (TwitchRecover, VodRecovery):
reconstrução determinística da URL do VOD na CDN a partir de dados que já
temos persistidos, sem precisar de vodId nem de token algum:
streamId aqui é o ID do broadcast (o mesmo que já vai em
stream.streamId), não o vodId de twitch.tv/videos/....startedAt precisa de precisão de segundo — confirmado que
stream.startedAt já vem de user.stream.createdAt reportado pela
própria Twitch (ver monitor.ts:82),
não de Date.now() local, então a precisão já está garantida pelo código
atual — nenhuma mudança necessária aí.cdn_host é o elo fraco, confirmado na prática. No mesmo teste,
o host real (d3fi1amfgojobc.cloudfront.net) não bateu com nenhum
dos 3 hosts candidatos testados (vod-secure.twitch.tv e duas variantes
de CloudFront). O host parece ser atribuído por broadcast/CDN-node, não
vir de um pool pequeno e estável.04-cdn-host-harvest.sh):
extrair hosts reais de playlists de VODs existentes (em vez de confiar em
lista hardcoded) é viável e gera candidatos frescos. Testado com 10 VODs
de um mesmo canal: 9 caíram no mesmo host, 1 caiu num host diferente —
hosts variam mesmo dentro do mesmo canal, mas não aleatoriamente a cada
VOD.vodId existe)videoPlaybackAccessToken) buscado fresco a cada download via GQL,
nunca armazenado — mesma premissa da ADR 004, aplicada ao token de VOD.channel.qualityPref; fallback pra próxima melhor disponível se a
qualidade pedida não existir naquele VOD.videoPlaybackAccessToken válido → usher devolveu master playlist
completo com as 5 variantes de qualidade esperadas (1080p/720p/480p/360p/
audio_only). A resposta do token também expõe chansub.restricted_bitrates
e authorization.forbidden, então dá pra detectar VOD sub-only só pela
resposta do token, sem precisar tentar baixar antes. Risco de hash de
persisted query (mencionado nas versões anteriores deste doc) não se
aplica a este endpoint, pelo menos hoje.Atualizado (2026-08-25, segunda fatia) — ver ADR 006 pro raciocínio completo. A v1 rodava HTTP assíncrono dentro do próprio processo do daemon, sem child process. Reescrito pra ficar retomável entre boots:
infrastructure/downloader/ (HttpVodDownloader) é agora um
despachante central: spawna um child process "burro" por download
(Bun.spawn, código próprio via infrastructure/vod-executor/executor-entrypoint.ts)
— mesmo padrão de infrastructure/recorder/ pro streamlink (ADR 004),
só que rodando código nosso em vez de um binário de terceiros.infrastructure/vod-executor/protocol.ts), reaproveitando o framing
(encodeMessage/LineBuffer) de @repo/ipc.segmentConcurrency), com buffer de reordenação já que
a escrita final precisa respeitar a ordem do playlist mesmo se a rede
responder fora de ordem. Retry por segment individual antes de reportar
falha.(segmentIndex, byteOffset) reportado periodicamente
(mensagem progress) e persistido no download depois dos bytes
em disco — permite truncar e retomar exatamente de onde parou depois de
um restart do daemon (ResumeOrphanedDownloadsUseCase, disparado no
boot).RecordingFinished/RecordingFailed:
DownloadFinishedEvent, , publicados pelo
despachante quando o executor reporta /.DrizzleDownloadRepository, espelhando DrizzleRecordingRepository.progress = segmentsBaixados / totalSegments — valor exato, não
estimativa, já que o total de segments é conhecido assim que o playlist é
resolvido.storagePath reusa MediaStorage.createStreamPath — mesma pasta da
stream, sem árvore de artefato separada..ts, mesmo formato bruto do StreamRecorder — alimenta o
mesmo job futuro de rewrap pra MP4.PlaybackAccessToken (seção C)
validados empiricamente, ambos funcionam como query GQL raw, sem
persisted query. Ver spikes/. GQL continua
não-documentado oficialmente e pode mudar sem aviso — revalidar se muito
tempo passar antes de confiar de novo nesses resultados.hasCapacity() no topo do use case, sem lock
atômico) bastou porque o ChannelMonitor é comprovadamente sequencial —
só um gatilho de StartRecordingUseCase existe. Aqui, os dois gatilhos
propostos (job de descoberta rodando periodicamente + comando manual do
operador) não têm essa garantia de serialização entre si. Por isso a
idempotência aqui precisa de constraint real no banco (índice único
parcial) desde o início, não de uma checagem antecipada simples — não é
inconsistência de critério, é a mesma lógica do doc 001 aplicada a um
cenário de concorrência genuína.vodLookupStatus (pending | linked | unavailable) ao
schema stream — migração Drizzle.VodLinkerUseCase/job: consulta GQL (videos(type: ARCHIVE),
match por proximidade de createdAt/startedAt, não por ID direto —
revisão do passo 2), timeout de 48h, transição pending →
linked/unavailable, disparando o fallback de CDN (passo 4) antes do
estado terminal.DrizzleDownloadRepository, com índice único parcial
(UNIQUE(stream_id) WHERE status IN ('queued','downloading')) pra
idempotência sob os dois gatilhos.PlaybackAccessTokenvodLookupStatus = unavailable
(estado terminal — o job para de tentar essa stream).channelName+streamId+startedAt de um canal ao vivo sem
nenhuma VOD listada pela API (princessmariaaaaa, indício de VOD storage
desligado — o caso real que este fallback existe pra cobrir), testei
contra um pool de 5 hosts harvestados de dois canais diferentes dela,
e um deles (harvestado da apofigeaa) devolveu 200 com conteúdo real
confirmado (playlist com timestamps batendo com o início da stream dela).
É a primeira prova de recuperação de conteúdo que o caminho A
genuinamente não encontra. Detalhes completos em
spikes/FINDINGS.md §6.
Confirmado também pós-live (mesmo dia): depois do canal ficar offline
de verdade, o mesmo path continuou respondendo 200 e o playlist passou
a ter #EXT-X-ENDLIST com a transmissão completa (~4h14min, 1528
segments, início ao fim) — não é acesso transitório durante a live, é
recuperação persistente de verdade. Ver
spikes/PENDING-princessmariaaaaa.md
(apesar do nome do arquivo, o resultado final está lá: resolvido,
confirmado).
Isso também revisa a heurística de mitigação: não é "priorizar host do
mesmo canal" (não precisou disso aqui) — é acumular o maior pool de
hosts possível, de qualquer canal, e testar contra ele. Ver Risco #5,
revisado.-muted das URLs — tratar como refinamento posterior,
não bloqueante pra v1.DownloadFailedEventdonefailedcdn_hostVodDownloader (infrastructure/downloader/): pool de
segments, retry por segment, eventos no bus.DownloadVodUseCase: checa teto de capacidade, resolve token+playlist
(via vodId oficial se existir, senão via CDN), persiste download row,
dispara VodDownloader.download-vod, nome a confirmar) espelhando
start-record/stop-record.streamId não podem gerar dois downloads simultâneos.pending automaticamente no primeiro tick do job de
descoberta.hashable = f"{channelName}_{streamId}_{startedAt_unix}"
urlhash = sha1(hashable).hexdigest()[:20]
url = f"https://{cdn_host}/{urlhash}_{hashable}/chunked/index-dvr.m3u8"