, ,

Mediacenter no Debian sem Docker: Jellyfin, *arr, LVM e o detalhe que quase todo guia erra

13 min de leitura

Todo homelab tem aquele serviço que a família usa de verdade. No meu é o mediacenter: um servidor com Jellyfin e a turma dos *arr (Radarr, Sonarr, Lidarr, Prowlarr, Bazarr), organizando filmes, séries e música sozinho.

A primeira versão rodava em Docker Compose, e ela continua no GitHub em rmmarconi/rmediacenter. Hoje a máquina roda tudo nativamente no Debian 13, com pacotes, releases oficiais e units do systemd, em cima de um volume LVM que junta três discos. Este post conta as duas coisas: as decisões de projeto que valem para qualquer uma das versões e o que aprendi escrevendo um instalador nativo que dá para rodar de novo sem medo.

Antes de tudo: este post fala de configuração de software. BitTorrent é um protocolo com muitos usos legítimos (distribuições Linux, conteúdo em domínio público, seus próprios arquivos). O que você baixa, e se tem o direito de baixar, é responsabilidade sua e depende da lei do seu país. Nada aqui traz mídia, indexadores ou trackers.

O que roda na máquina

ServiçoPara que serveComo é instalado
JellyfinO servidor de mídia: a “Netflix de casa”Repositório APT oficial, com jellyfin-ffmpeg7
qBittorrentCliente de downloadqbittorrent-nox, direto do Debian
Radarr / Sonarr / LidarrAutomação de filmes, séries e músicaTarball oficial self-contained (.NET)
ProwlarrGerencia os indexadores e distribui para os outrosTarball oficial
BazarrLegendas automáticasZip da release num virtualenv Python próprio
Jellyseerr / SeerrPortal de pedidos para a famíliaCompilado do código-fonte, com Node 22 e pnpm
FlareSolverrResolve o desafio do Cloudflare para indexadoresBundle oficial com Chromium, ouvindo só em 127.0.0.1

O proxy reverso (Caddy) roda em outra máquina, e o mediacenter só serve as aplicações.

Por que saí do Docker

A versão em containers funciona bem, e continua sendo a que eu recomendo para quem está começando. Mas, para uma máquina dedicada só a isso, o Debian facilita:

  • o Jellyfin mantém um repositório APT oficial, com o próprio ffmpeg, e o qbittorrent-nox está no arquivo base do Debian. Nada precisa ser compilado, com exceção do Jellyseerr;
  • a transcodificação por hardware não depende de passar /dev/dri para dentro de container nem de acertar o GID do grupo render;
  • logs, restart e dependências ficam todos no systemd e no journalctl, como qualquer outro serviço do sistema;
  • o isolamento continua existindo, só que feito pelo próprio systemd (veja mais abaixo).

O preço é ter que escrever um instalador decente. É dele que trata metade deste post.

A decisão que a maioria dos guias erra: um ponto de montagem só

Esta vale para Docker e para instalação nativa. Downloads e biblioteca ficam no mesmo sistema de arquivos, como subpastas de /data:

/data/                   um sistema de arquivos só
├── torrents/            o qBittorrent escreve aqui
│   ├── movies/  tv/  music/  incomplete/
└── media/               o Jellyfin lê aqui
    ├── movies/  tv/  music/

Quando o Radarr importa um filme, ele cria um hardlink de /data/torrents/movies/... para /data/media/movies/...: dois nomes para o mesmo arquivo no disco. O torrent continua semeando, a biblioteca fica organizada, e o filme de 40 GB ocupa 40 GB, não 80. A importação é instantânea, porque mover dentro do mesmo sistema de arquivos é só trocar metadados.

A pegadinha: hardlink não atravessa sistema de arquivos. Se torrents e media estiverem em discos, partições ou volumes diferentes, os *arr passam a copiar em silêncio. Nenhum erro aparece, só o disco enchendo. A referência para tudo isso é o TRaSH Guides.

E quando você tem três discos de tamanhos diferentes?

Era o meu caso: um de 500 GB, um de 1 TB e um de 2 TB. Montar cada um numa pasta quebraria os hardlinks. A saída foi o LVM: os três discos viram physical volumes de um mesmo volume group, e um único logical volume linear de 3,2 TB vira o /data, com um ext4 só. O sistema operacional fica num NVMe à parte.

NAME                MAJ:MIN   SIZE TYPE MOUNTPOINTS
sda                   8:0  465,8G disk
└─sda1                8:1  465,8G part
  └─data_vg-data_lv 254:0    3,2T lvm  /data
sdb                   8:16 931,5G disk
└─sdb1                8:17 931,5G part
  └─data_vg-data_lv 254:0    3,2T lvm  /data
sdc                   8:32   1,8T disk
└─sdc1                8:33   1,8T part
  └─data_vg-data_lv 254:0    3,2T lvm  /data
nvme0n1             259:0  238,5G disk
├─nvme0n1p1         259:1    976M part /boot/efi
└─nvme0n1p2         259:2  105,1G part /

A receita, para discos novos e vazios (os comandos apagam o que houver neles):

sudo pvcreate /dev/sda1 /dev/sdb1 /dev/sdc1
sudo vgcreate data_vg /dev/sda1 /dev/sdb1 /dev/sdc1
sudo lvcreate -l 100%FREE -n data_lv data_vg
sudo mkfs.ext4 -m 0 -L data /dev/data_vg/data_lv   # -m 0: sem reserva de root num disco só de mídia
echo 'LABEL=data /data ext4 defaults,noatime 0 2' | sudo tee -a /etc/fstab
sudo mkdir -p /data && sudo mount /data

O bônus é que crescer é trivial: chegou um disco novo, basta um vgextend e um lvextend -r, e o /data aumenta sem desmontar nada.

O trade-off, sem enfeite: um LV linear não tem redundância nenhuma. Se qualquer um dos três discos morrer, o sistema de arquivos inteiro fica comprometido, e não só a parte que estava naquele disco. Para mídia que dá para recuperar, eu aceito o risco. O que não dá para perder (bancos de dados, configurações, histórico) fica fora do /data, no NVMe, e tem backup. Se a sua mídia for insubstituível, use RAID (mdadm, ZFS) em vez de LVM linear.

Um usuário só, e o setgid fazendo o papel do PUID/PGID

Nas imagens da LinuxServer.io, a mágica que evita “permission denied” é todo mundo usar o mesmo PUID/PGID com UMASK=002. Na instalação nativa, reproduzi o mesmo contrato:

  • um usuário de sistema mediacenter, sem shell, é dono de tudo e roda todos os serviços;
  • cada unit do systemd usa UMask=0002, então os arquivos saem com 664 e os diretórios com 775;
  • os diretórios de /data têm o bit setgid (chmod 2775), para que tudo o que for criado ali herde o grupo mediacenter, não importa quem criou;
  • o Jellyfin, que o pacote instala com usuário próprio, entra no grupo mediacenter. Ele só precisa ler a biblioteca.

O estado das aplicações fica em /var/lib/mediacenter, no NVMe, e os binários em /opt/mediacenter. O backup que importa é o do primeiro.

Isolamento sem container: o systemd faz o trabalho

Sair do Docker não significa rodar tudo solto. Cada serviço ganha uma unit gerada pelo instalador, com o hardening que o systemd oferece de graça:

[Service]
Type=simple
User=mediacenter
Group=mediacenter
UMask=0002
ExecStart=/opt/mediacenter/Radarr/Radarr -nobrowser -data=/var/lib/mediacenter/radarr
Restart=on-failure

NoNewPrivileges=true         # nada de sudo/setuid a partir daqui
PrivateTmp=true              # /tmp próprio
ProtectHome=true             # /home invisível
ProtectSystem=full           # /usr, /boot e /etc somente leitura
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
ReadWritePaths=/data /var/lib/mediacenter   # só isso é gravável

Se um dos apps for comprometido, ele consegue estragar a mídia e o próprio estado, mas não o sistema. Para conferir o quanto cada serviço está exposto, o systemd dá uma nota: systemd-analyze security radarr.

Um instalador que dá para rodar de novo

O install.sh instala tudo, mas a ideia principal é que ele seja idempotente: rodar de novo é o jeito de atualizar. Algumas escolhas que fizeram diferença:

  • Versões resolvidas na hora. A tag mais recente de cada projeto vem de git ls-remote --tags, e a URL do binário vem da API de releases do GitHub. A versão instalada fica gravada em /opt/mediacenter/.versions/. Se já estiver na última versão, o componente é pulado.
  • Cada componente isolado. Se o Jellyseerr falhar na compilação, o Bazarr ainda é instalado. No fim, sai um resumo com OK, WARN ou FAIL por componente.
  • Testa antes de instalar. Confere se torrents/ e media/ estão no mesmo sistema de arquivos (stat -c %d), faz um hardlink de verdade com ln e avisa se o /data não é um ponto de montagem próprio, o que significaria mídia enchendo o disco do sistema.
  • Modos seguros: --dry-run resolve as versões sem mudar nada, e --only radarr mexe num componente só.

As pegadinhas de shell script que apareceram no caminho

  • O apt traduz a própria saída. Num sistema em pt_BR, o “Candidate:” do apt-cache policy vira “Candidato:”, e qualquer grep nele quebra. Solução: export LC_ALL=C no topo e, sempre que possível, olhar só o código de saída (apt-cache show pacote).
  • O Debian renomeia bibliotecas a cada release. O .NET precisa da libicu, que já foi libicu72, 74 e 76. Em vez de manter uma lista que envelhece, o script pergunta ao apt qual existe: apt-cache pkgnames libicu | grep -E '^libicu[0-9]+$' | sort -V | tail -1. O mesmo vale para libasound2, que virou libasound2t64.
  • Repositório que dá 404 quebra todo apt seguinte. Um Debian recém-lançado pode ainda não ter pacotes no repositório do Jellyfin. O script testa se a suite existe (trixie, depois bookworm) antes de adicioná-la, e remove o .sources se a atualização falhar.
  • trap ... RETURN com set -u é uma armadilha. O trap de RETURN também dispara quando a função que chamou retorna, e aí a variável local do diretório temporário já saiu de escopo. Com set -u, isso aborta o script inteiro. A limpeza agora é explícita, em cada caminho de saída.
  • Firewall: SSH primeiro, sempre. O script lê a porta do sshd_config e libera o SSH antes do ufw enable. Configurar firewall remotamente na ordem errada é um jeito clássico de perder o acesso ao servidor.

Proxy em outra máquina, e o firewall como porteiro

O Caddy, com HTTPS pela CA interna (tls internal), roda em outro host da rede. O instalador gera um Caddyfile.for-proxy já apontando para o IP do mediacenter, pronto para copiar para o proxy. Cada serviço vira https://<nome>.media.lan, e um wildcard *.media.lan no DNS local (no Pi-hole, por exemplo) resolve todos de uma vez.

jellyfin.media.lan {
	import common
	reverse_proxy 10.1.1.100:8096 {
		flush_interval -1    # não bufferiza: é streaming de arquivo grande
	}
}

Como o proxy está em outra máquina, os serviços precisam ouvir em 0.0.0.0, e quem limita a exposição é o firewall. Por isso existe o --proxy-ip: com ele, o ufw só aceita conexões nas portas das interfaces web vindas do proxy.

Confissão: escrevendo este post, conferi o meu próprio servidor e as portas 8096, 8080, 7878 e as outras estavam liberadas para “Anywhere”. Eu tinha rodado o instalador sem o --proxy-ip. A correção é rodar de novo com a flag. Isso não quebra nada, porque ele pula o que já está instalado.

A ordem da configuração importa

  1. qBittorrent: a senha temporária está em journalctl -u qbittorrent. Configure para salvar em /data/torrents, crie as categorias movies, tv e music, defina uma senha de verdade e um limite de seed com ação de pausar. Atrás do proxy, desmarque o Enable Host header validation.
  2. Prowlarr: cadastre os indexadores uma vez e conecte o Radarr, o Sonarr e o Lidarr. Ele empurra os indexadores para os três. O FlareSolverr fica em http://127.0.0.1:8191, porque roda na mesma máquina e só ouve localmente.
  3. Radarr e Sonarr: pasta raiz em /data/media/movies e /data/media/tv, com Use Hardlinks instead of Copy ligado. Para perfis de qualidade e nomes, siga o TRaSH Guides.
  4. Lidarr: ligue o Rename Tracks, que vem desligado, e restrinja o perfil de qualidade, senão ele fica “melhorando” a biblioteca para sempre.
  5. Jellyfin: bibliotecas em /data/media/*.
  6. Jellyseerr: login com a conta do Jellyfin e conexão com o Radarr e o Sonarr. É o único serviço que faz sentido compartilhar com outras pessoas.
  7. Bazarr: idiomas (português, com inglês de fallback) e vários provedores. Nenhum path mapping é necessário, porque os caminhos são os mesmos para todos os serviços.

Depois da primeira importação, prove que o hardlink funcionou: o número de links (segunda coluna) tem que ser 2, com o mesmo inode nos dois caminhos.

ls -li /data/torrents/movies/*/*.mkv /data/media/movies/*/*.mkv

Troubleshooting: as que me pegaram

FlareSolverr reiniciando sem parar: “Could not find Xvfb”

O FlareSolverr usa um Chromium de verdade para passar pelo desafio do Cloudflare, e ele precisa de um display, mesmo num servidor sem monitor. No bundle nativo, esse display é o Xvfb (um X virtual), que não vem junto. O sintoma é o serviço em loop de activating com Failed to execute script 'flaresolverr' no journal. A correção é instalar o pacote:

sudo apt install -y xvfb
sudo systemctl restart flaresolverr
journalctl -u flaresolverr -n 20

qBittorrent respondendo só “Unauthorized”, sem tela de login

Esse texto é o corpo de um 403 numa chamada /api/v2/..., não a tela de login recusando a senha. Descubra de onde vem com curl -si http://127.0.0.1:8080/ | head -n 20:

  • Se voltar 200 OK com o HTML do login, o problema é o navegador: a interface é uma SPA, e uma cópia em cache mais um cookie SID expirado chamam a API com uma sessão morta. Teste numa janela anônima e depois limpe os dados do site.
  • Se voltar 403 também, o problema é o servidor. Até você definir a sua senha, o qBittorrent gera uma temporária nova a cada start. Se o log disser “banned”, as tentativas erradas bloquearam o seu IP por uma hora; um restart do serviço resolve.

Jellyseerr: compilado do código-fonte (e hoje, Seerr)

É o único componente compilado do código-fonte. Ele precisa de Node 22 (o Debian traz uma versão mais antiga, então o instalador usa o repositório da NodeSource), leva alguns minutos e usa algo em torno de 2 GB de RAM no pnpm build. Numa máquina com pouca memória, vale fechar o resto antes de rodar o --only jellyseerr. No meu servidor, o portal de pedidos hoje roda como seerr.service, o sucessor do Jellyseerr. Se você migrar também, desabilite a unit antiga para as duas não brigarem pela porta 5055.

Mais algumas, em uma linha cada

  • Os *arr copiando em vez de linkar: torrents e media estão em sistemas de arquivos diferentes. O teste do instalador existe exatamente para isso.
  • “Permission denied” na importação: algum arquivo ficou com outro dono. Rode sudo chown -R mediacenter:mediacenter /data e confira o setgid dos diretórios.
  • Torrent parado em 0 B/s: a porta 6881 não está encaminhada no roteador.
  • Jellyfin não vê arquivos novos: adicione uma conexão em Settings → Connect → Emby/Jellyfin no Radarr e no Sonarr.

No meu homelab

O mediacenter é uma máquina física separada do Proxmox, na VLAN de servidores. Por ser independente, ele ganhou um papel extra: é ele que recebe em tempo real os logs do Proxmox. E foi comparando os dois que descobri que os travamentos do Proxmox tinham a ver com a energia elétrica, porque as duas máquinas caíam no mesmo minuto. Essa história está no post sobre observabilidade do homelab.

Resumo

  • Um sistema de arquivos só para downloads e biblioteca: hardlinks funcionam e as importações são instantâneas. Com vários discos, o LVM resolve, mas sem redundância.
  • Um usuário só, com umask 002 e setgid, é o equivalente nativo do PUID/PGID dos containers.
  • O systemd isola bem: ProtectSystem, NoNewPrivileges e ReadWritePaths custam três linhas.
  • Instalador idempotente, que testa antes de instalar e é rodado de novo para atualizar.
  • Backup do estado, não da mídia, e nada exposto na internet sem VPN ou autenticação na frente.

A versão em Docker Compose, com o compose comentado, o Caddyfile e o bootstrap.sh, está em github.com/rmmarconi/rmediacenter.

Referências