Eu queria uma coisa aparentemente simples: pegar duas pastas de vídeo e transformá-las num canal de TV linear, tocando 24 horas por dia, transmitido pelo meu Owncast. Nada de "dar play num arquivo" — uma emissora de verdade, com grade, que o público assiste numa página e que eu ligo e desligo quando quiser.
O caminho até lá passou por uma ferramenta descartada, uma GPU que funciona num lugar e falha em outro, um servidor recusando conexões por um motivo que não era o óbvio, e sete arquivos podres sabotando tudo. Este texto documenta o que foi feito, por quê, e principalmente as dores resolvidas no meio do caminho.
O hardware: um servidor Unraid (apelidado Nyx), GPU AMD, Owncast já rodando como destino.
A arquitetura, e por que não foi a primeira escolha
A divisão de papéis que eu queria era clara:
[ a emissora ] [ a ponte ] [ a antena ]
monta a grade → empurra o stream → distribui pro público
A primeira tentativa foi o ffplayout — uma ferramenta de broadcast robusta, talvez a mais "profissional" pra isso. Ela morreu na praia da instalação. O ffplayout não publica imagem Docker pronta: o ffmpeg que ele usa contém componentes non-free que não podem ser redistribuídos legalmente, então a instalação oficial pressupõe que você compile a imagem do zero. No Unraid, isso significa um build pesado (Rust + frontend, imagem final na casa dos 15 GB) e virar mantenedor manual das atualizações. Esforço alto demais pra algo que, no fim, eu nem precisava em toda a sua complexidade.
Avaliei o que o Community Apps do Unraid oferecia. A maioria das opções com "ffmpeg" no nome é transcoder de arquivo em lote — não serve pra stream contínuo. Broadcaster e dizquetv são concorrentes diretos do ErsatzTV (geram canais), não pontes. O Restreamer (datarhei) era o único candidato real a fazer a ponte com interface gráfica — mas sobe o próprio servidor RTMP na porta 1935, que colidiria com o Owncast, além de ser uma stack pesada pra uma tarefa de um-para-um.
A escolha final:
- ErsatzTV como a emissora (monta e normaliza a grade, encoda na GPU).
- Um container ffmpeg cru como a ponte (pega o canal e empurra pro Owncast).
- Owncast como a antena (distribui pro público).
O ffmpeg cru ganhou do Restreamer por ser mínimo, não abrir porta nenhuma (só faz push de saída, zero conflito) e — detalhe importante — porque o Stop/Start desse container já é o liga/desliga da transmissão: parar a transmissão sem derrubar o ErsatzTV era um requisito, e isso saiu de graça na arquitetura.
ErsatzTV: a emissora
A imagem certa e a GPU
O template do Community Apps oferecia variantes -vaapi e -nvidia. Para AMD, VAAPI é o caminho. Logo de cara, dois detalhes:
-
A imagem unificou. O ErsatzTV passou a embutir VAAPI e NVIDIA na imagem padrão; os sufixos viraram legado. O Health Check avisa pra remover o
-vaapie usar a imagem default (ghcr.io/ersatztv/ersatztv:latest). A aceleração continua funcionando — só não precisa mais do sufixo. -
VAAPI exige passar a GPU. Sem o device
/dev/drino container, a aceleração simplesmente não acontece. O texto do template só menciona Intel e NVIDIA, mas AMD usa o mesmo caminho do Intel: adicionar/dev/dricomo Device.
Um aviso azul sobre "VAAPI Driver" sugeria os drivers iHD/i965 — esses são da Intel. Em AMD, o driver Default está correto (o Mesa autodetecta o radeonsi). Forçar um driver Intel quebraria. Ignorado com tranquilidade.
Só as pastas certas, e somente leitura
Eu não queria expor a pasta de mídia inteira ao ErsatzTV — apenas duas subpastas. A solução foi montar cada uma como um subdiretório, em modo somente-leitura:
/mnt/user/skull/Mídia/Strangeland/ → /media/Strangeland (RO)
/mnt/user/skull/Mídia/Melted Stuff/ → /media/Melted Stuff (RO)
Assim o container enxerga só essas duas pastas, e o ErsatzTV nunca consegue escrever nos arquivos — ele só precisa ler. Proteção sem custo.
Biblioteca, canal e grade
O fluxo dentro do ErsatzTV são três peças encadeadas, e a última é a que todo mundo esquece:
- Biblioteca (
Other Videos, tipo certo pra vídeos avulsos) — o ErsatzTV indexa os arquivos. - Collection — o balde com o que vai tocar.
- Schedule com Playout Mode em Flood — a lógica: enche o canal continuamente com os itens da collection.
- Playout — liga o Schedule ao Canal e gera a timeline. Sem o Playout, nada toca.
Canal criado (MadArabTV, número 1), modo HLS Segmenter, perfil 1080p h264 via VAAPI. Testei a URL .../iptv/channels.m3u no VLC: tocou. A emissora estava no ar.
A ponte: o container ffmpeg
Um container mínimo (jrottenberg/ffmpeg), em rede Host (pra alcançar ErsatzTV e Owncast na mesma máquina), com --restart=unless-stopped — que reinicia sozinho se cair por acidente, mas respeita o Stop manual. O comando pega o canal do ErsatzTV e empurra pro Owncast via RTMP.
A primeira versão copiava o vídeo e convertia só o áudio para AAC (porque o player web do Owncast não toca AC3). Era pra ser o fim. Não foi.
Dor nº 1: o Owncast recusando o stream
Subi a ponte e o ffmpeg morria na hora:
Error submitting a packet to the muxer: Connection reset by peer
A leitura óbvia seria "stream key errada". Era errada — a leitura, não a key. O log do próprio Owncast contou outra história:
Inbound stream connected from 192.168.50.7:...
Processing video using codec VA-API
amdgpu: unknown (family_id, chip_external_rev): (145, 16)
Failed to initialise VAAPI connection: resource allocation failed
your copy of ffmpeg may not support your selected codec of h264_vaapi
Inbound stream connected = a chave estava certa, o Owncast aceitou. O que derrubava era que o Owncast estava configurado para re-encodar o vídeo com VA-API — e o driver Mesa dentro do container do Owncast não reconhece a GPU AMD (amdgpu: unknown family_id). O transcode falhava, o Owncast desconectava, e o ffmpeg da ponte via isso como "connection reset".
A lição aqui foi a mais valiosa de todas: "connection reset" raramente é o que parece. Foi o log do destino, não o da origem, que revelou a causa. Sem olhar o log do Owncast, eu teria ficado horas recriando stream keys.
E o detalhe curioso: a mesma GPU funciona no ErsatzTV e falha no Owncast. A diferença é o Mesa de cada container — o do ErsatzTV é recente e reconhece a placa; o do Owncast é antigo demais pra ela.
A correção: passthrough
O ffmpeg já entrega H264 + AAC prontos. O Owncast não precisa re-encodar nada — basta repassar. Liguei o Video Passthrough no Owncast: ele usa o stream do jeito que chega. Sem transcode, sem VA-API, problema eliminado, e de quebra zero CPU de transcode no Owncast.
Dor nº 2: quedas nas transições
Com o passthrough, finalmente transmitiu — por uns 40 segundos. Depois caía. E caía "do nada", em momentos aparentemente aleatórios. O log mostrou duas pistas:
Stream #0:0: Video: h264 ..., 47.95 fps (em um clipe)
Stream #0:0: Video: h264 ..., 29.97 fps (em outro)
[mpegts] Packet corrupt (stream = 0, dts = ...)
Dois problemas distintos:
-
Framerate variável entre clipes. Vídeos com fps diferentes (30, 29.97, 48). Como tanto a ponte quanto o Owncast estavam só copiando, ninguém normalizava — o defeito atravessava a cadeia e o RTMP quebrava na troca. Tocava liso no VLC (que tolera mudança de fps) mas quebrava no streaming (que não tolera).
-
Arquivos com duração zero. O Health Check do ErsatzTV apontou 7 arquivos com duração zero — corrompidos, incompletos, ilegíveis. Quando a rotação do canal batia num deles, o transcode produzia lixo e o stream quebrava. Eram o gatilho das quedas "aleatórias": não eram aleatórias, eram os arquivos podres.
A decisão que limpou tudo: encode uma vez só, na GPU
Em vez de jogar um re-encode pesado na CPU da ponte, a jogada certa era aproveitar que o ErsatzTV já encoda na GPU com sucesso. Bastava deixar ele fazer todo o trabalho — incluindo a normalização de framerate, que ele faz nativamente no modo HLS Segmenter com Normalize Video ligado — e a ponte voltar a ser um copy puro:
ErsatzTV (encode + normalização na GPU AMD) → ffmpeg (copy, ~0 CPU) → Owncast (passthrough) → público
Um encode só, na placa. O resto é repasse. CPU da ponte praticamente em zero — o que importava, já que a ideia nunca foi espancar processador (ainda mais com a máquina já rodando quente em idle).
Matando os arquivos fantasmas
Faltava limpar os 7 arquivos de duração zero. Como estavam montados somente-leitura no ErsatzTV, a remoção tinha que ser no host. Um container ffmpeg temporário varreu as pastas com ffprobe, identificou todo arquivo com duração vazia, ilegível ou abaixo de um segundo, e apagou:
docker run --rm -v "/mnt/user/skull/Mídia:/mnt/user/skull/Mídia" \
--entrypoint /bin/bash jrottenberg/ffmpeg:latest -c '
shopt -s globstar nullglob
base="/mnt/user/skull/Mídia"
for f in "$base/Strangeland"/**/* "$base/Melted Stuff"/**/*; do
[ -f "$f" ] || continue
case "${f,,}" in
*.mp4|*.avi|*.mkv|*.mov|*.m4v|*.ts|*.wmv|*.flv|*.webm|*.mpg|*.mpeg) ;;
*) continue ;;
esac
d=$(ffprobe -v error -show_entries format=duration -of default=nokey=1:noprint_wrappers=1 "$f" 2>/dev/null)
if [ -z "$d" ] || [ "$d" = "N/A" ] || awk "BEGIN{exit !($d < 1)}"; then
rm -v -- "$f"
fi
done
'
(O truque do -v "/host:/host" — montar com o mesmo caminho dentro do container — faz o que o script imprime ser idêntico ao caminho real, sem tradução.)
Apagados os 7, faltava um detalhe que confunde: o aviso continuou aparecendo. Não porque os arquivos seguiam lá, mas porque o ErsatzTV lê esse Health Check do banco de dados interno, não do disco em tempo real. Um re-scan da biblioteca reconciliou o banco com o disco, removeu as entradas órfãs, e o aviso sumiu.
O que ficou de pé
Um canal de TV linear, 24/7, montado a partir de duas pastas de mídia, transmitindo pelo Owncast:
- Um único encode, feito na GPU AMD pelo ErsatzTV. A ponte e o Owncast só repassam.
- CPU da ponte em ~zero — é
copypuro. - Liga/desliga em um clique: parar a transmissão é dar Stop no container ffmpeg, na aba Docker. O ErsatzTV segue rodando o canal internamente; o Owncast volta a "offline". Start religa.
- Mídias protegidas: montadas somente-leitura, e só as duas pastas escolhidas — nunca a biblioteca inteira.
Lições que valem pra próxima
- O erro que você vê raramente é a causa. "Connection reset by peer" não era stream key — era o transcode VA-API do destino falhando. O log do outro lado resolveu.
- Driver dentro do container importa. A mesma GPU funciona num container (Mesa novo) e falha em outro (Mesa velho). Quando a aceleração falha, suspeite do ambiente, não só do hardware.
- Não re-encode o que já está pronto. Se a fonte já entrega o codec certo, passthrough e
copypoupam CPU e eliminam pontos de falha. Coloque o encode num lugar só — de preferência onde tem GPU. - Streams lineares não perdoam mídia ruim. Arquivos de duração zero ou framerate variável quebram a transmissão na troca de clipe, mesmo que toquem num player de mesa. Vale auditar a biblioteca.
- Cache invalidado é meio caminho do bug. O Health Check insistindo num arquivo já apagado era só banco desatualizado. Antes de achar que algo não funcionou, confira se a ferramenta não está só lendo um estado velho.
Did this resonate?
Related documents
- 001
- 002
- 003
- 004
- 005