Murad Library
Murad LibraryREF-0357MD

Montando um canal de TV 24/7 no Unraid: ErsatzTV, ffmpeg e Owncast

Catalogued
Reading
10 min read

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:

  1. 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 -vaapi e usar a imagem default (ghcr.io/ersatztv/ersatztv:latest). A aceleração continua funcionando — só não precisa mais do sufixo.

  2. VAAPI exige passar a GPU. Sem o device /dev/dri no 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/dri como 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:

  1. 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).

  2. 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 — é copy puro.
  • 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 copy poupam 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