server-up/tools/arr_sjablonen.py
Ramon c9b73d8b69
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 2m31s
v0.8.10-beta - Vervangers voor big tech, en sleutels in het juiste formaat
- Sjablonen kunnen nu secret_format ("hex" of "laravel") en secret_bytes
  opgeven. De generator maakte altijd url-veilige base64, en dat is precies wat
  Laravel, Homarr en LibreChat weigeren
- Daarmee kwamen twee fouten uit v0.8.00 aan het licht: Homarr en LibreChat
  kregen sleutels die hun containers niet accepteren. Rechtgezet
- Snipe-IT en Invoice Ninja alsnog toegevoegd; die vielen vorige ronde af omdat
  hun APP_KEY niet te genereren was
- 19 vervangers voor big tech erbij, van 197 naar 218 apps:
  Baikal en Radicale (Google Agenda/Contacten), LibreTranslate (Translate),
  CryptPad, Collabora en ONLYOFFICE (Google Docs), Formbricks (Forms),
  Jitsi Meet (Meet/Zoom), PeerTube, Invidious en Tube Archivist (YouTube),
  Owncast (Twitch), Mastodon (X), Pixelfed (Instagram), Lemmy (Reddit),
  Cal.com (Calendly), Rallly (Doodle), DocuSeal (DocuSign), Penpot (Figma)
- Communicatie van 12 naar 16 apps, productiviteit van 21 naar 31
- Nieuwe JS-test voor de sleutelformaten; alle 242 images in de catalogus
  opnieuw tegen hun registry gecontroleerd

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-08-01 03:49:10 +02:00

863 lines
48 KiB
Python

"""Bron van de *arr-sjablonen: de losse apps én de gecombineerde ARR-stack.
Elk onderdeel bestaat twee keer — als losse app in de store en als schakelaar in
`apps/arr-stack`. Dat zijn twee verpakkingen van dezelfde container, en ze horen
niet uiteen te lopen: hetzelfde image, dezelfde interne poort, dezelfde mounts.
Daarom komen ze allebei uit de tabel hieronder.
python3 tools/arr_sjablonen.py
`tests/test_arr_sjablonen.py` draait dit opnieuw en vergelijkt het resultaat met
wat er in `apps/` staat, zodat een handmatige wijziging aan één van de twee
opvalt in plaats van jarenlang mee te reizen.
"""
import json
from pathlib import Path
APPS = Path(__file__).resolve().parents[1] / "apps"
HELP = ("Media en downloads horen onder één map te staan, die als /data in elke "
"container komt. Alleen dan kan een voltooide download met een hardlink "
"naar je bibliotheek: geen kopie, geen dubbele opslag, en je torrent "
"blijft seeden. Zet je ze op gescheiden paden, dan kopieert elke "
"download opnieuw. Eronder: media/films, media/series, torrents/ en usenet/.")
TZ = "Europe/Amsterdam"
APPDATA = "/opt/serverup/appdata"
DATAROOT = "/mnt/data"
# naam, image, poorten, vlaggen
# poorten: lijst van (veld, titel, hostdefault, containerpoort, proto, omschrijving)
SPEC = [
# ── De *arr-bibliotheken ────────────────────────────────────────────────
dict(dir="sonarr", naam="Sonarr", icon="sonarr",
image="lscr.io/linuxserver/sonarr:latest",
omschrijving="Zoekt, downloadt en hernoemt series. Houdt bij welke afleveringen je mist en pakt ze op zodra ze verschijnen.",
tags=["media", "series", "automatisering"], cats=["media"],
poorten=[("port", "Webinterface", 8989, 8989, "tcp", "")],
lsio=True, data=True, sectie="Bibliotheken"),
dict(dir="radarr", naam="Radarr", icon="radarr",
image="lscr.io/linuxserver/radarr:latest",
omschrijving="Zoekt, downloadt en hernoemt films. Dezelfde aanpak als Sonarr, maar dan voor je filmbibliotheek.",
tags=["media", "films", "automatisering"], cats=["media"],
poorten=[("port", "Webinterface", 7878, 7878, "tcp", "")],
lsio=True, data=True, sectie="Bibliotheken"),
dict(dir="lidarr", naam="Lidarr", icon="lidarr",
image="lscr.io/linuxserver/lidarr:latest",
omschrijving="Beheert je muziekbibliotheek: volgt artiesten en haalt nieuwe albums binnen.",
tags=["media", "muziek", "automatisering"], cats=["media"],
poorten=[("port", "Webinterface", 8686, 8686, "tcp", "")],
lsio=True, data=True, sectie="Bibliotheken"),
dict(dir="readarr", naam="Readarr", icon="readarr",
image="lscr.io/linuxserver/readarr:develop",
omschrijving="Beheert e-boeken en luisterboeken. Let op: de ontwikkeling is stilgelegd; het draait nog wel, maar er komen geen nieuwe versies meer.",
tags=["media", "boeken", "automatisering"], cats=["media"],
poorten=[("port", "Webinterface", 8787, 8787, "tcp", "")],
lsio=True, data=True, sectie="Bibliotheken"),
dict(dir="whisparr", naam="Whisparr", icon="whisparr",
image="ghcr.io/hotio/whisparr:latest",
omschrijving="Dezelfde automatisering als Sonarr en Radarr, gericht op materiaal voor volwassenen.",
tags=["media", "automatisering"], cats=["media"],
poorten=[("port", "Webinterface", 6969, 6969, "tcp", "")],
lsio=True, data=True, sectie="Bibliotheken"),
dict(dir="mylar3", naam="Mylar3", icon="mylar",
image="lscr.io/linuxserver/mylar3:latest",
omschrijving="Volgt stripseries en haalt nieuwe uitgaven binnen. De *arr-aanpak, maar voor comics.",
tags=["media", "strips", "boeken"], cats=["media"],
poorten=[("port", "Webinterface", 8115, 8090, "tcp", "Standaard 8090; hier verplaatst omdat die poort al bezet is")],
lsio=True, data=True, sectie="Bibliotheken"),
dict(dir="kapowarr", naam="Kapowarr", icon="kapowarr",
image="mrcas/kapowarr:latest",
omschrijving="Bouwt en beheert een stripbibliotheek, met eigen bestandsnaamgeving en kwaliteitsregels.",
tags=["media", "strips", "boeken"], cats=["media"],
poorten=[("port", "Webinterface", 5656, 5656, "tcp", "")],
data=True, config_pad="/app/db", sectie="Bibliotheken"),
# ── Indexers ────────────────────────────────────────────────────────────
dict(dir="prowlarr", naam="Prowlarr", icon="prowlarr",
image="lscr.io/linuxserver/prowlarr:latest",
omschrijving="Beheert je indexers op één plek en duwt ze door naar Sonarr, Radarr en de rest. Scheelt overal apart instellen.",
tags=["media", "indexers", "downloaden"], cats=["downloaden"],
poorten=[("port", "Webinterface", 9696, 9696, "tcp", "")],
lsio=True, sectie="Indexers"),
dict(dir="jackett", naam="Jackett", icon="jackett",
image="lscr.io/linuxserver/jackett:latest",
omschrijving="Vertaalt trackers naar één API voor je *arr-apps. Ouder alternatief voor Prowlarr; meestal heb je er maar één nodig.",
tags=["media", "indexers", "downloaden"], cats=["downloaden"],
poorten=[("port", "Webinterface", 9117, 9117, "tcp", "")],
lsio=True, sectie="Indexers"),
dict(dir="nzbhydra2", naam="NZBHydra2", icon="nzbhydra2",
image="lscr.io/linuxserver/nzbhydra2:latest",
omschrijving="Bundelt meerdere usenet-indexers tot één zoekbron, met eigen statistieken per indexer.",
tags=["usenet", "indexers", "downloaden"], cats=["downloaden"],
poorten=[("port", "Webinterface", 5076, 5076, "tcp", "")],
lsio=True, sectie="Indexers"),
dict(dir="flaresolverr", naam="FlareSolverr", icon="flaresolverr",
image="ghcr.io/flaresolverr/flaresolverr:latest",
omschrijving="Haalt pagina's op die achter een Cloudflare-controle zitten, zodat je indexers er alsnog bij kunnen.",
tags=["indexers", "downloaden", "proxy"], cats=["downloaden"],
poorten=[("port", "Poort", 8191, 8191, "tcp", "")],
env_extra=["LOG_LEVEL=info"], sectie="Indexers"),
dict(dir="byparr", naam="Byparr", icon="byparr",
image="ghcr.io/thephaseless/byparr:latest",
omschrijving="Nieuwer alternatief voor FlareSolverr, gebouwd op SeleniumBase. Spreekt dezelfde API, dus je kunt het er zo voor in de plaats zetten.",
tags=["indexers", "downloaden", "proxy"], cats=["downloaden"],
poorten=[("port", "Poort", 8119, 8191, "tcp", "Byparr luistert intern op 8191, net als FlareSolverr")],
sectie="Indexers"),
# ── Downloadclients ─────────────────────────────────────────────────────
dict(dir="nzbget", naam="NZBGet", icon="nzbget",
image="lscr.io/linuxserver/nzbget:latest",
omschrijving="Zuinige usenet-downloader. Lichter dan SABnzbd en daarmee prettig op een kleine server.",
tags=["usenet", "downloaden"], cats=["downloaden"],
poorten=[("port", "Webinterface", 6789, 6789, "tcp", "")],
lsio=True, data=True, client=True, sectie="Downloadclients"),
dict(dir="deluge", naam="Deluge", icon="deluge",
image="lscr.io/linuxserver/deluge:latest",
omschrijving="Lichte torrentclient met webinterface en een uitgebreid plugin-systeem.",
tags=["torrents", "downloaden"], cats=["downloaden"],
poorten=[("port", "Webinterface", 8113, 8112, "tcp", "Standaard 8112; hier verplaatst omdat die poort al bezet is"),
("port_torrent", "Inkomende poort", 6882, 6881, "both", "Zet deze poort open in je router voor betere snelheden")],
lsio=True, data=True, client=True, sectie="Downloadclients"),
dict(dir="transmission", naam="Transmission", icon="transmission",
image="lscr.io/linuxserver/transmission:latest",
omschrijving="Sobere, stabiele torrentclient. Weinig knoppen, doet gewoon zijn werk.",
tags=["torrents", "downloaden"], cats=["downloaden"],
poorten=[("port", "Webinterface", 8114, 9091, "tcp", "Standaard 9091; hier verplaatst omdat die poort al bezet is"),
("port_torrent", "Inkomende poort", 6883, 51413, "both", "Zet deze poort open in je router voor betere snelheden")],
lsio=True, data=True, client=True, sectie="Downloadclients"),
dict(dir="slskd", naam="slskd", icon="slskd",
image="slskd/slskd:latest",
omschrijving="Soulseek-client met webinterface. In combinatie met Soularr vult het gaten in je muziekbibliotheek die de gewone indexers niet hebben.",
tags=["muziek", "downloaden"], cats=["downloaden"],
poorten=[("port", "Webinterface", 5030, 5030, "tcp", ""),
("port_listen", "Luisterpoort", 50300, 50300, "tcp", "Zet deze poort open in je router, anders kun je alleen downloaden van gebruikers die zelf bereikbaar zijn")],
data=True, client=True, config_pad="/app",
env_extra=["SLSKD_REMOTE_CONFIGURATION=true"], sectie="Downloadclients"),
dict(dir="gluetun", naam="Gluetun", icon="gluetun",
image="qmcgaw/gluetun:latest",
omschrijving="VPN-container waar je andere containers doorheen laat lopen. Zet je downloadclient erachter met `network_mode: service:gluetun`.",
tags=["vpn", "netwerk", "privacy"], cats=["netwerk", "beveiliging"],
poorten=[], speciaal="gluetun", sectie="Downloadclients"),
# ── Ondertitels en verwerking ───────────────────────────────────────────
dict(dir="bazarr", naam="Bazarr", icon="bazarr",
image="lscr.io/linuxserver/bazarr:latest",
omschrijving="Zoekt en downloadt ondertitels bij wat Sonarr en Radarr al hebben binnengehaald.",
tags=["media", "ondertitels"], cats=["media"],
poorten=[("port", "Webinterface", 6767, 6767, "tcp", "")],
lsio=True, data=True, sectie="Ondertitels en verwerking"),
dict(dir="lingarr", naam="Lingarr", icon="lingarr",
image="lingarr/lingarr:latest",
omschrijving="Vertaalt ondertitels automatisch, met een lokaal model of een vertaaldienst naar keuze.",
tags=["media", "ondertitels", "ai"], cats=["media"],
poorten=[("port", "Webinterface", 9876, 9876, "tcp", "")],
data=True, config_pad="/app/config", sectie="Ondertitels en verwerking"),
dict(dir="unpackerr", naam="Unpackerr", icon="unpackerr",
image="ghcr.io/unpackerr/unpackerr:latest",
omschrijving="Pakt gedownloade rar-archieven uit zodra ze klaar zijn, en ruimt de restanten op.",
tags=["downloaden", "automatisering"], cats=["downloaden"],
poorten=[], data=True, gebruiker=True, speciaal="unpackerr",
sectie="Ondertitels en verwerking"),
dict(dir="tdarr", naam="Tdarr", icon="tdarr",
image="ghcr.io/haveagitgat/tdarr:latest",
omschrijving="Zet je bibliotheek om naar een zuiniger formaat, verdeeld over meerdere machines als je wilt.",
tags=["media", "encoding"], cats=["media"],
poorten=[("port", "Webinterface", 8265, 8265, "tcp", ""),
("port_server", "Serverpoort", 8266, 8266, "tcp", "Waar de rekenknooppunten verbinding mee maken")],
lsio=True, data=True, speciaal="tdarr", sectie="Ondertitels en verwerking"),
# ── Verzoeken en gebruikers ─────────────────────────────────────────────
dict(dir="overseerr", naam="Overseerr", icon="overseerr",
image="sctx/overseerr:latest",
omschrijving="Laat huisgenoten films en series aanvragen, gekoppeld aan Plex. Voor Jellyfin en Emby gebruik je Jellyseerr.",
tags=["media", "verzoeken"], cats=["media"],
poorten=[("port", "Webinterface", 8120, 5055, "tcp", "Standaard 5055; hier verplaatst omdat Jellyseerr die al gebruikt")],
config_pad="/app/config", env_extra=["LOG_LEVEL=info"],
sectie="Verzoeken en gebruikers"),
dict(dir="wizarr", naam="Wizarr", icon="wizarr",
image="ghcr.io/wizarrrr/wizarr:latest",
omschrijving="Nodigt nieuwe gebruikers uit voor Plex, Jellyfin of Emby met een uitnodigingslink, inclusief uitleg voor de nieuwkomer.",
tags=["media", "gebruikers"], cats=["media", "beheer"],
poorten=[("port", "Webinterface", 5690, 5690, "tcp", "")],
config_pad="/data/database", sectie="Verzoeken en gebruikers"),
dict(dir="requestrr", naam="Requestrr", icon="requestrr",
image="thomst08/requestrr:latest",
omschrijving="Discord-bot waarmee je huisgenoten films en series kunnen aanvragen zonder een webinterface te openen.",
tags=["media", "verzoeken", "discord"], cats=["media", "communicatie"],
poorten=[("port", "Webinterface", 4545, 4545, "tcp", "")],
config_pad="/root/config", sectie="Verzoeken en gebruikers"),
dict(dir="suggestarr", naam="SuggestArr", icon="suggestarr",
image="ciuse99/suggestarr:latest",
omschrijving="Kijkt wat er gekeken is en zet automatisch aanbevelingen klaar in Sonarr en Radarr.",
tags=["media", "aanbevelingen"], cats=["media"],
poorten=[("port", "Webinterface", 8116, 5000, "tcp", "Standaard 5000; hier verplaatst omdat die poort al bezet is")],
config_pad="/app/config", sectie="Verzoeken en gebruikers"),
dict(dir="pulsarr", naam="Pulsarr", icon="pulsarr",
image="lakker/pulsarr:latest",
omschrijving="Koppelt Plex-watchlists aan Sonarr en Radarr: wat je op je lijst zet, wordt opgehaald.",
tags=["media", "watchlist"], cats=["media"],
poorten=[("port", "Webinterface", 8117, 3003, "tcp", "Standaard 3003; hier verplaatst omdat die poort al bezet is")],
config_pad="/app/data", sectie="Verzoeken en gebruikers"),
dict(dir="prefetcharr", naam="Prefetcharr", icon="sonarr",
image="phueber/prefetcharr:latest",
omschrijving="Laat Sonarr het volgende seizoen ophalen zodra je aan de laatste afleveringen van het huidige begint.",
tags=["media", "series", "automatisering"], cats=["media"],
poorten=[], config_pad="/config", sectie="Verzoeken en gebruikers"),
# ── Opruimen en onderhoud ───────────────────────────────────────────────
dict(dir="cleanuparr", naam="Cleanuparr", icon="cleanuparr",
image="ghcr.io/cleanuparr/cleanuparr:latest",
omschrijving="Ruimt dode en kwaadaardige torrents op, en downloads waar niemand meer naar verwijst.",
tags=["downloaden", "onderhoud"], cats=["downloaden", "beheer"],
poorten=[("port", "Webinterface", 11011, 11011, "tcp", "")],
data=True, config_pad="/config", sectie="Opruimen en onderhoud"),
dict(dir="decluttarr", naam="Decluttarr", icon="decluttarr",
image="ghcr.io/manimatter/decluttarr:latest",
omschrijving="Houdt de downloadwachtrij schoon: gooit weg wat vastloopt of niet meer nodig is.",
tags=["downloaden", "onderhoud"], cats=["downloaden", "beheer"],
poorten=[], speciaal="decluttarr", sectie="Opruimen en onderhoud"),
dict(dir="janitorr", naam="Janitorr", icon="janitorr",
image="ghcr.io/schaka/janitorr:native-stable",
omschrijving="Ruimt oude media op voordat je schijf volloopt, met regels per bibliotheek.",
tags=["media", "opruimen", "opslag"], cats=["media", "opslag"],
poorten=[("port", "Webinterface", 8978, 8978, "tcp", "")],
data=True, config_pad="/config", sectie="Opruimen en onderhoud"),
dict(dir="maintainerr", naam="Maintainerr", icon="maintainerr",
image="ghcr.io/maintainerr/maintainerr:latest",
omschrijving="Ruimt media op volgens regels die je zelf samenstelt, met een voorvertoning van wat er weg zou gaan.",
tags=["media", "opruimen"], cats=["media"],
poorten=[("port", "Webinterface", 6246, 6246, "tcp", "")],
gebruiker=True, config_pad="/opt/data", sectie="Opruimen en onderhoud"),
dict(dir="cross-seed", naam="cross-seed", icon="cross-seed",
image="ghcr.io/cross-seed/cross-seed:latest",
omschrijving="Zoekt dezelfde bestanden bij andere trackers, zodat je één download op meerdere plekken kunt seeden.",
tags=["torrents", "downloaden"], cats=["downloaden"],
poorten=[("port", "Poort", 2468, 2468, "tcp", "")],
data=True, config_pad="/config", commando="daemon",
sectie="Opruimen en onderhoud"),
# ── Instellingen synchroniseren ─────────────────────────────────────────
dict(dir="recyclarr", naam="Recyclarr", icon="recyclarr",
image="ghcr.io/recyclarr/recyclarr:latest",
omschrijving="Zet de kwaliteitsprofielen uit de TRaSH-gidsen in Sonarr en Radarr, en houdt ze bij.",
tags=["media", "configuratie"], cats=["media", "beheer"],
poorten=[], gebruiker=True, config_pad="/config",
sectie="Instellingen synchroniseren"),
dict(dir="configarr", naam="Configarr", icon="configarr",
image="ghcr.io/raydak-labs/configarr:latest",
omschrijving="Zelfde idee als Recyclarr, maar met eigen aanpassingen bovenop de TRaSH-gidsen.",
tags=["media", "configuratie"], cats=["media", "beheer"],
poorten=[], config_pad="/app/config", sectie="Instellingen synchroniseren"),
dict(dir="profilarr", naam="Profilarr", icon="profilarr",
image="santiagosayshey/profilarr:latest",
omschrijving="Beheert kwaliteitsprofielen en custom formats met versiebeheer, en deelt ze tussen instanties.",
tags=["media", "configuratie"], cats=["media", "beheer"],
poorten=[("port", "Webinterface", 6868, 6868, "tcp", "")],
config_pad="/config", sectie="Instellingen synchroniseren"),
# ── Metadata en artwork ─────────────────────────────────────────────────
dict(dir="kometa", naam="Kometa", icon="kometa",
image="kometateam/kometa:latest",
omschrijving="Bouwt collecties, overlays en metadata op in Plex, Jellyfin of Emby. Voorheen Plex Meta Manager.",
tags=["media", "metadata"], cats=["media"],
poorten=[], data=True, config_pad="/config",
env_extra=["KOMETA_RUN=false"], sectie="Metadata en artwork"),
dict(dir="posterizarr", naam="Posterizarr", icon="posterizarr",
image="ghcr.io/fscorrupt/posterizarr:latest",
omschrijving="Maakt automatisch posters en titelkaarten voor je bibliotheek, in een stijl die je zelf instelt.",
tags=["media", "artwork"], cats=["media"],
poorten=[], data=True, config_pad="/config",
env_extra=["RUN_TIME=disabled"], sectie="Metadata en artwork"),
dict(dir="trailarr", naam="Trailarr", icon="trailarr",
image="nandyalu/trailarr:latest",
omschrijving="Haalt trailers op bij de films en series in Radarr en Sonarr, en zet ze naast je media.",
tags=["media", "trailers"], cats=["media"],
poorten=[("port", "Webinterface", 7889, 7889, "tcp", "")],
lsio=True, data=True, config_pad="/config", sectie="Metadata en artwork"),
# ── Statistiek en meldingen ─────────────────────────────────────────────
dict(dir="tautulli", naam="Tautulli", icon="tautulli",
image="lscr.io/linuxserver/tautulli:latest",
omschrijving="Laat zien wie wat kijkt op je Plex-server, met grafieken en meldingen.",
tags=["media", "statistiek", "monitoring"], cats=["media", "monitoring"],
poorten=[("port", "Webinterface", 8181, 8181, "tcp", "")],
lsio=True, sectie="Statistiek en meldingen"),
dict(dir="notifiarr", naam="Notifiarr", icon="notifiarr",
image="golift/notifiarr:latest",
omschrijving="Stuurt meldingen van je *arr-apps naar Discord, en houdt tegelijk je server in de gaten.",
tags=["meldingen", "monitoring", "discord"], cats=["communicatie", "monitoring"],
poorten=[("port", "Webinterface", 5454, 5454, "tcp", "")],
speciaal="notifiarr", config_pad="/config", sectie="Statistiek en meldingen"),
dict(dir="exportarr", naam="Exportarr", icon="prometheus",
image="ghcr.io/onedr0p/exportarr:latest",
omschrijving="Zet de cijfers van één *arr-app om in Prometheus-metrics, zodat je er in Grafana naar kunt kijken.",
tags=["monitoring", "metrics"], cats=["monitoring"],
poorten=[("port", "Metrics-poort", 9707, 9707, "tcp", "")],
speciaal="exportarr", sectie="Statistiek en meldingen"),
dict(dir="autopulse", naam="Autopulse", icon="autopulse",
image="ghcr.io/dan-online/autopulse:latest",
omschrijving="Laat Plex of Jellyfin meteen een nieuwe aflevering zien, in plaats van te wachten op de volgende scan.",
tags=["media", "automatisering"], cats=["media"],
poorten=[("port", "Poort", 2875, 2875, "tcp", "")],
config_pad="/app/config", sectie="Statistiek en meldingen"),
# ── Muziek ──────────────────────────────────────────────────────────────
dict(dir="soularr", naam="Soularr", icon="soulseek",
image="mrusse08/soularr:latest",
omschrijving="Laat Lidarr zoeken via Soulseek (slskd) voor albums die op de gewone indexers niet te vinden zijn.",
tags=["muziek", "downloaden"], cats=["downloaden", "media"],
poorten=[], data=True, config_pad="/data", sectie="Muziek"),
dict(dir="neutarr", naam="NeutArr", icon="huntarr",
image="iampuid0/neutarr:latest",
omschrijving="Zoekt gericht naar ontbrekende afleveringen en naar wat beter kan. Opvolger van Huntarr, dat in februari 2026 offline ging na een reeks lekken; dit is de fork van de laatste schone versie.",
tags=["media", "automatisering"], cats=["media"],
poorten=[("port", "Webinterface", 9705, 9705, "tcp", "")],
sectie="Opruimen en onderhoud"),
dict(dir="homarr", naam="Homarr", icon="homarr",
image="ghcr.io/homarr-labs/homarr:latest",
omschrijving="Startpagina die je *arr-apps, downloadclients en mediaserver op één scherm laat zien, met live cijfers per app.",
tags=["dashboard", "overzicht"], cats=["beheer"],
poorten=[("port", "Webinterface", 7575, 7575, "tcp", "")],
config_pad="/appdata", speciaal="homarr",
sectie="Statistiek en meldingen"),
dict(dir="jellystat", naam="Jellystat", icon="jellystat",
image="cyfershepard/jellystat:latest",
omschrijving="Kijkstatistieken voor Jellyfin: wie keek wat, hoe lang en hoe vaak. Brengt zijn eigen database mee.",
tags=["media", "statistiek", "monitoring"], cats=["media", "monitoring"],
poorten=[("port", "Webinterface", 8118, 3000, "tcp", "Standaard 3000; hier verplaatst omdat die poort al bezet is")],
config_pad="/app/backend/backup-data", speciaal="jellystat",
sectie="Statistiek en meldingen"),
]
HELP_DATA = HELP
def _velden(app):
"""De variabelengroepen van één sjabloon."""
items = [{"name": "service_name", "type": "str", "title": "Servicenaam",
"default": app["dir"].replace("-", ""), "required": True}]
for veld, titel, host, _c, _p, oms in app["poorten"]:
it = {"name": veld, "type": "int", "title": titel,
"default": host, "required": True}
if oms:
it["description"] = oms
items.append(it)
items.append({"name": "appdata_dir", "type": "str", "title": "Appdata-map",
"default": APPDATA, "required": True,
"description": "Basismap voor de instellingen van deze app"})
if app.get("data"):
items.append({
"name": "data_root", "type": "str", "title": "Gedeelde datamap",
"default": DATAROOT, "required": True,
"description": "Eén map met je media én je downloads, gekoppeld als /data",
"help": HELP,
})
items.append({"name": "timezone", "type": "str", "title": "Tijdzone",
"default": TZ, "required": True})
groepen = [{"title": "Algemeen", "items": items}]
if app.get("lsio") or app.get("gebruiker"):
groepen.append({"title": "Gebruiker", "items": [
{"name": "puid", "type": "int", "title": "Gebruikers-ID (PUID)",
"default": 1000, "required": True,
"description": "`id -u` op je server; bepaalt de eigenaar van de bestanden"},
{"name": "pgid", "type": "int", "title": "Groeps-ID (PGID)",
"default": 1000, "required": True},
]})
groepen.extend(app.get("extra_groepen", []))
return groepen
def _compose(app):
"""Het compose-bestand van één sjabloon."""
r = ["services:", " << service_name >>:",
f" image: {app['image']}",
" container_name: << service_name >>"]
env = []
if app.get("lsio") or app.get("gebruiker"):
env += ["PUID=<< puid >>", "PGID=<< pgid >>"]
env.append("TZ=<< timezone >>")
if app.get("lsio"):
# Zonder group-write kunnen de andere *arr-apps niet bij de bestanden
# die deze aanmaakt.
env.append("UMASK=002")
env += app.get("env_extra", [])
env += app.get("env_speciaal", [])
if env:
r.append(" environment:")
r += [f" - {e}" for e in env]
vols = [f" - << appdata_dir >>/<< service_name >>:{app.get('config_pad', '/config')}"]
if app.get("data"):
vols.append(" - << data_root >>:/data")
vols += app.get("vol_extra", [])
r.append(" volumes:")
r += vols
if app.get("commando"):
r.append(f" command: {app['commando']}")
if app["poorten"]:
r.append(" ports:")
for veld, _t, _h, container, proto, _o in app["poorten"]:
if proto in ("tcp", "both"):
r.append(f' - "<< {veld} >>:{container}"')
if proto in ("udp", "both"):
r.append(f' - "<< {veld} >>:{container}/udp"')
r += app.get("compose_extra", [])
r.append(" restart: unless-stopped")
r += app.get("extra_diensten", [])
return "\n".join(r) + "\n"
# ── Sjablonen met een eigen vorm ─────────────────────────────────────────────
def _speciaal(app):
"""Aanvullingen voor apps die niet in het standaardpatroon passen."""
s = app.get("speciaal")
if s == "unpackerr":
app["extra_groepen"] = [{"title": "Koppelingen", "items": [
{"name": "sonarr_url", "type": "str", "title": "Sonarr",
"default": "http://sonarr:8989", "required": False,
"connect": {"app": "sonarr", "scheme": "http", "port": 8989},
"help": "Het adres waarop Unpackerr Sonarr kan bereiken. Draaien "
"ze allebei op hetzelfde gedeelde netwerk, dan is de "
"containernaam genoeg."},
{"name": "sonarr_apikey", "type": "str", "title": "Sonarr API-sleutel",
"default": "", "required": False, "secret": True,
"description": "Te vinden in Sonarr onder Settings → General"},
{"name": "radarr_url", "type": "str", "title": "Radarr",
"default": "http://radarr:7878", "required": False,
"connect": {"app": "radarr", "scheme": "http", "port": 7878},
"help": "Het adres waarop Unpackerr Radarr kan bereiken."},
{"name": "radarr_apikey", "type": "str", "title": "Radarr API-sleutel",
"default": "", "required": False, "secret": True,
"description": "Te vinden in Radarr onder Settings → General"},
]}]
app["env_speciaal"] = [
"UN_SONARR_0_URL=<< sonarr_url >>",
"UN_SONARR_0_API_KEY=<< sonarr_apikey >>",
"UN_RADARR_0_URL=<< radarr_url >>",
"UN_RADARR_0_API_KEY=<< radarr_apikey >>",
]
elif s == "decluttarr":
app["extra_groepen"] = [{"title": "Koppelingen", "items": [
{"name": "sonarr_url", "type": "str", "title": "Sonarr",
"default": "http://sonarr:8989", "required": False,
"connect": {"app": "sonarr", "scheme": "http", "port": 8989},
"help": "Adres van Sonarr. Laat leeg als je Sonarr niet gebruikt."},
{"name": "sonarr_apikey", "type": "str", "title": "Sonarr API-sleutel",
"default": "", "required": False, "secret": True,
"description": "Te vinden in Sonarr onder Settings → General"},
{"name": "radarr_url", "type": "str", "title": "Radarr",
"default": "http://radarr:7878", "required": False,
"connect": {"app": "radarr", "scheme": "http", "port": 7878},
"help": "Adres van Radarr. Laat leeg als je Radarr niet gebruikt."},
{"name": "radarr_apikey", "type": "str", "title": "Radarr API-sleutel",
"default": "", "required": False, "secret": True,
"description": "Te vinden in Radarr onder Settings → General"},
]}]
app["env_speciaal"] = [
"SONARR_URL=<< sonarr_url >>", "SONARR_KEY=<< sonarr_apikey >>",
"RADARR_URL=<< radarr_url >>", "RADARR_KEY=<< radarr_apikey >>",
"LOG_LEVEL=INFO",
]
app["config_pad"] = "/config"
elif s == "exportarr":
app["extra_groepen"] = [{"title": "Welke app meten we?", "items": [
{"name": "arr_soort", "type": "enum", "title": "Soort",
"default": "sonarr", "required": True,
"config": {"options": ["sonarr", "radarr", "lidarr", "readarr", "prowlarr"]},
"description": "Eén Exportarr meet één app; draai er meerdere voor meerdere apps"},
{"name": "arr_url", "type": "str", "title": "Adres van die app",
"default": "http://sonarr:8989", "required": True,
"connect": {"app": "sonarr", "scheme": "http", "port": 8989},
"help": "Het adres waarop Exportarr de *arr-app kan bereiken."},
{"name": "arr_apikey", "type": "str", "title": "API-sleutel",
"default": "", "required": True, "secret": True,
"description": "Te vinden in de app onder Settings → General"},
]}]
app["env_speciaal"] = ["URL=<< arr_url >>", "APIKEY=<< arr_apikey >>",
"PORT=<< port >>"]
app["commando"] = "<< arr_soort >>"
app["config_pad"] = "/config"
elif s == "notifiarr":
app["env_speciaal"] = ["DN_API_KEY=<< notifiarr_apikey >>"]
app["extra_groepen"] = [{"title": "Toegang", "items": [
{"name": "notifiarr_apikey", "type": "str", "title": "Notifiarr API-sleutel",
"default": "", "required": True, "secret": True,
"description": "Haal je op in je profiel op notifiarr.com"},
]}]
elif s == "tdarr":
app["env_speciaal"] = ["serverIP=0.0.0.0", "serverPort=8266",
"webUIPort=8265", "internalNode=true"]
app["config_pad"] = "/app/server"
app["vol_extra"] = [" - << appdata_dir >>/<< service_name >>-configs:/app/configs"]
elif s == "gluetun":
app["extra_groepen"] = [{"title": "VPN", "items": [
{"name": "vpn_provider", "type": "str", "title": "VPN-aanbieder",
"default": "mullvad", "required": True,
"description": "Bijvoorbeeld mullvad, nordvpn, protonvpn, airvpn of surfshark"},
{"name": "vpn_type", "type": "enum", "title": "Protocol",
"default": "wireguard", "required": True,
"config": {"options": ["wireguard", "openvpn"]}},
{"name": "vpn_landen", "type": "str", "title": "Landen",
"default": "Netherlands", "required": False,
"description": "Komma-gescheiden; leeg laten kiest willekeurig"},
{"name": "wireguard_private_key", "type": "str", "title": "WireGuard privésleutel",
"default": "", "required": False, "secret": True,
"needs": [{"name": "vpn_type", "value": "wireguard"}],
"config": {"placeholder": "alleen bij wireguard"}},
{"name": "wireguard_addresses", "type": "str", "title": "WireGuard adressen",
"default": "", "required": False,
"needs": [{"name": "vpn_type", "value": "wireguard"}],
"config": {"placeholder": "10.64.0.2/32"}},
{"name": "openvpn_user", "type": "str", "title": "OpenVPN gebruiker",
"default": "", "required": False,
"needs": [{"name": "vpn_type", "value": "openvpn"}]},
{"name": "openvpn_password", "type": "str", "title": "OpenVPN wachtwoord",
"default": "", "required": False, "secret": True,
"needs": [{"name": "vpn_type", "value": "openvpn"}]},
]}]
app["env_speciaal"] = [
"VPN_SERVICE_PROVIDER=<< vpn_provider >>",
"VPN_TYPE=<< vpn_type >>",
"SERVER_COUNTRIES=<< vpn_landen >>",
"WIREGUARD_PRIVATE_KEY=<< wireguard_private_key >>",
"WIREGUARD_ADDRESSES=<< wireguard_addresses >>",
"OPENVPN_USER=<< openvpn_user >>",
"OPENVPN_PASSWORD=<< openvpn_password >>",
]
app["config_pad"] = "/gluetun"
app["compose_extra"] = [
" cap_add:", " - NET_ADMIN",
" devices:", " - /dev/net/tun:/dev/net/tun",
]
elif s == "homarr":
app["extra_groepen"] = [{"title": "Toegang", "items": [
{"name": "homarr_encryption_key", "type": "str",
"title": "Versleutelingssleutel", "default": "", "required": True,
"secret": True, "generate": "auto",
# Homarr accepteert alleen 64 hexadecimale tekens; een gewone
# willekeurige string weigert hij bij het opstarten.
"secret_format": "hex", "secret_bytes": 32,
"description": "64 tekens; versleutelt de opgeslagen API-sleutels van je apps"},
]}]
app["env_speciaal"] = ["SECRET_ENCRYPTION_KEY=<< homarr_encryption_key >>"]
elif s == "jellystat":
app["extra_groepen"] = [{"title": "Database", "items": [
{"name": "db_password", "type": "str", "title": "Databasewachtwoord",
"default": "", "required": True, "secret": True, "generate": "auto"},
{"name": "jwt_secret", "type": "str", "title": "JWT-sleutel",
"default": "", "required": True, "secret": True, "generate": "auto",
"description": "Ondertekent de inlogsessies"},
]}]
app["env_speciaal"] = [
"POSTGRES_USER=jellystat",
"POSTGRES_PASSWORD=<< db_password >>",
"POSTGRES_IP=<< service_name >>-db",
"POSTGRES_PORT=5432",
"JWT_SECRET=<< jwt_secret >>",
]
app["extra_diensten"] = [
" << service_name >>-db:",
" image: postgres:16-alpine",
" container_name: << service_name >>-db",
" environment:",
" - POSTGRES_USER=jellystat",
" - POSTGRES_PASSWORD=<< db_password >>",
" - POSTGRES_DB=jfstat",
" volumes:",
" - << appdata_dir >>/<< service_name >>-db:/var/lib/postgresql/data",
" restart: unless-stopped",
]
app["compose_extra"] = [" depends_on:", " - << service_name >>-db"]
return app
def schrijf_los(app):
app = _speciaal(dict(app))
if True:
d = APPS / app["dir"]
(d / "files").mkdir(parents=True, exist_ok=True)
tmpl = {
"kind": "compose",
"metadata": {
"name": app["naam"],
"description": app["omschrijving"],
"tags": app["tags"],
"icon": {"provider": "selfhst", "id": app["icon"]},
"version": {"name": "latest"},
"categories": app["cats"],
},
"variables": _velden(app),
}
(d / "template.json").write_text(
json.dumps(tmpl, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
(d / "files" / "compose.yaml").write_text(_compose(app), encoding="utf-8")
# ── De gecombineerde ARR-stack ───────────────────────────────────────────────
# Onderdelen die al als los sjabloon bestonden en dus niet in SPEC staan.
BESTAAND = [
dict(dir="qbittorrent", naam="qBittorrent",
image="lscr.io/linuxserver/qbittorrent:latest",
omschrijving="Torrentclient met webinterface. De meest gebruikte keuze naast de *arr-apps.",
poorten=[("port", "Webinterface", 8097, 8090, "tcp", ""),
("port_torrent", "Inkomende poort", 6881, 6881, "both", "")],
lsio=True, data=True, client=True, sectie="Downloadclients",
env_extra=["WEBUI_PORT=8090"]),
dict(dir="sabnzbd", naam="SABnzbd",
image="lscr.io/linuxserver/sabnzbd:latest",
omschrijving="Usenet-downloader met webinterface.",
poorten=[("port", "Webinterface", 8098, 8080, "tcp", "")],
lsio=True, data=True, client=True, sectie="Downloadclients"),
dict(dir="jellyseerr", naam="Jellyseerr",
image="fallenbagel/jellyseerr:latest",
omschrijving="Laat huisgenoten films en series aanvragen, gekoppeld aan Jellyfin, Emby of Plex.",
poorten=[("port", "Webinterface", 5055, 5055, "tcp", "")],
config_pad="/app/config", env_extra=["LOG_LEVEL=info"],
sectie="Verzoeken en gebruikers"),
]
# Containers die achter de VPN gaan zodra Gluetun aan staat.
CLIENTS = ("qbittorrent", "sabnzbd", "nzbget", "deluge", "transmission", "slskd")
VOLGORDE = ["Bibliotheken", "Indexers", "Downloadclients",
"Ondertitels en verwerking", "Verzoeken en gebruikers",
"Opruimen en onderhoud", "Instellingen synchroniseren",
"Metadata en artwork", "Statistiek en meldingen", "Muziek"]
# Standaard aan: genoeg om meteen series en films binnen te halen.
STANDAARD_AAN = {"sonarr", "radarr", "prowlarr", "bazarr", "qbittorrent",
"jellyseerr"}
def onderdelen():
uit = [_speciaal(dict(a)) for a in SPEC] + [_speciaal(dict(a)) for a in BESTAAND]
uit.sort(key=lambda a: (VOLGORDE.index(a["sectie"]), a["dir"]))
return uit
def _naam(app):
return app["dir"].replace("-", "")
def _poortveld(app, veld):
"""Veldnaam in de stack voor een poortveld uit het losse sjabloon."""
achter = "" if veld == "port" else "_" + veld[len("port_"):]
return f"port_{_naam(app)}{achter}"
def _hernoem(regels, app):
"""Verwijzingen uit het losse sjabloon omzetten naar de stack.
Twee dingen verschuiven. Poortvelden: los heet Exportarrs poort `port`, in de
stack `port_exportarr` — zonder vertaling rendert dat leeg. En verwijzingen
naar een buurcontainer: los heet de database van Jellystat
`<service_name>-db`, in de stack `<service_name>-jellystat-db`.
"""
naam = _naam(app)
uit = []
for r in regels:
for veld, *_ in app["poorten"]:
r = r.replace(f"<< {veld} >>", f"<< {_poortveld(app, veld)} >>")
r = r.replace("<< service_name >>-", f"<< service_name >>-{naam}-")
uit.append(r)
return uit
def stack_template():
algemeen = [
{"name": "service_name", "type": "str", "title": "Naam van de stack",
"default": "arr", "required": True,
"description": "Wordt het voorvoegsel van elke container, bv. arr-sonarr"},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map",
"default": APPDATA, "required": True,
"description": "Basismap voor de instellingen van elk onderdeel"},
{"name": "data_root", "type": "str", "title": "Gedeelde datamap",
"default": DATAROOT, "required": True,
"description": "Eén map met je media én je downloads, gekoppeld als /data",
"help": HELP_DATA},
{"name": "timezone", "type": "str", "title": "Tijdzone",
"default": TZ, "required": True},
{"name": "puid", "type": "int", "title": "Gebruikers-ID (PUID)",
"default": 1000, "required": True,
"description": "`id -u` op je server; bepaalt de eigenaar van de bestanden"},
{"name": "pgid", "type": "int", "title": "Groeps-ID (PGID)",
"default": 1000, "required": True},
]
groepen = [{"title": "Algemeen", "items": algemeen}]
for app in onderdelen():
items = []
for veld, titel, host, _c, _p, oms in app["poorten"]:
it = {"name": _poortveld(app, veld), "type": "int", "title": titel,
"default": host, "required": True,
# De poorten staan al goed; ze horen niet in de weg te zitten
# als je vijftig onderdelen doorloopt.
"advanced": True, "step": "instellingen"}
if oms:
it["description"] = oms
items.append(it)
# Eigen velden van een onderdeel (Gluetun, Unpackerr, …) komen mee uit
# het losse sjabloon, met dezelfde namen.
for grp in app.get("extra_groepen", []):
for it in grp.get("items", []):
items.append(dict(it))
groepen.append({
"title": app["naam"],
"section": app["sectie"],
"toggle": f"enable_{_naam(app)}",
"toggle_default": app["dir"] in STANDAARD_AAN,
"description": app["omschrijving"],
"items": items,
})
return {
"kind": "compose",
"metadata": {
"name": "ARR-stack",
"description": (
"Alles voor media-automatisering in één keer, met een schakelaar "
"per onderdeel: de *arr-apps, indexers, downloadclients, "
"ondertitels, verzoeken, opruimen en statistiek. Elk onderdeel "
"bestaat ook als losse app in de store; hier zet je in één keer "
"neer wat je nodig hebt, op één gedeelde /data-map zodat "
"hardlinks werken."),
"tags": ["media", "automatisering", "films", "series", "downloaden"],
"icon": {"provider": "selfhst", "id": "sonarr"},
"version": {"name": "latest"},
"categories": ["media", "downloaden"],
},
"variables": groepen,
}
def _stack_dienst(app):
"""Het compose-blok van één onderdeel binnen de stack."""
naam = _naam(app)
achter_vpn = app["dir"] in CLIENTS
r = [f" << service_name >>-{naam}:",
f" image: {app['image']}",
f" container_name: << service_name >>-{naam}"]
env = []
if app.get("lsio") or app.get("gebruiker"):
env += ["PUID=<< puid >>", "PGID=<< pgid >>"]
env.append("TZ=<< timezone >>")
if app.get("lsio"):
env.append("UMASK=002")
env += _hernoem(app.get("env_extra", []) + app.get("env_speciaal", []), app)
if env:
r.append(" environment:")
r += [f" - {e}" for e in env]
r.append(" volumes:")
r.append(f" - << appdata_dir >>/<< service_name >>/{naam}:"
f"{app.get('config_pad', '/config')}")
if app.get("data"):
r.append(" - << data_root >>:/data")
for extra in app.get("vol_extra", []):
r.append(extra.replace("<< service_name >>", f"<< service_name >>/{naam}"))
if app.get("commando"):
r += _hernoem([f" command: {app['commando']}"], app)
poorten = _hernoem([f' - "<< {veld} >>:{container}"'
+ ("" if proto != "udp" else "")
for veld, _t, _h, container, proto, _o in app["poorten"]
for _ in [0]], app)
poorten = []
for veld, _t, _h, container, proto, _o in app["poorten"]:
v = _poortveld(app, veld)
if proto in ("tcp", "both"):
poorten.append(f' - "<< {v} >>:{container}"')
if proto in ("udp", "both"):
poorten.append(f' - "<< {v} >>:{container}/udp"')
if achter_vpn:
# Achter de VPN deelt de client de netwerknamespace van Gluetun; zijn
# poorten staan daar, en `ports` naast `network_mode` weigert compose.
r.append("<%- if enable_gluetun %>")
r.append(' network_mode: "service:<< service_name >>-gluetun"')
r.append(" depends_on:")
r.append(" - << service_name >>-gluetun")
r.append("<%- endif %>")
if poorten:
r.append("<%- if not enable_gluetun %>")
r.append(" ports:")
r += poorten
r.append("<%- endif %>")
elif poorten:
r.append(" ports:")
r += poorten
r += _hernoem(app.get("compose_extra", []), app)
r.append(" restart: unless-stopped")
# Een onderdeel mag meer dan één container zijn; Jellystat brengt zijn eigen
# database mee. Die krijgt hetzelfde voorvoegsel en dezelfde schakelaar.
r += _hernoem(app.get("extra_diensten", []), app)
return r
def stack_compose():
r = ["# ARR-stack — kies per onderdeel of je het wilt.",
"#",
"# Alles deelt één map, gekoppeld als /data. Dat is geen smaakkwestie:",
"# staan je downloads en je bibliotheek op verschillende mounts, dan kan",
"# Sonarr geen hardlink maken en kopieert elke voltooide download naar je",
"# bibliotheek. Traag, en je hebt alles tijdelijk dubbel op de schijf.",
"#",
"# /data/media/{films,series,muziek,boeken} je bibliotheek",
"# /data/torrents/ torrents (blijven seeden)",
"# /data/usenet/ usenet-downloads",
"#",
"# Draai je met Gluetun, dan zitten de downloadclients in het netwerk van",
"# die container en bereik je hun webinterface via de Gluetun-poorten.",
"services:", ""]
for app in onderdelen():
if app["dir"] == "gluetun":
continue # die krijgt onderaan een eigen blok
r.append(f"<%- if enable_{_naam(app)} %>")
r += _stack_dienst(app)
r.append("<%- endif %>")
r.append("")
r += _gluetun_blok()
return "\n".join(r) + "\n"
def _gluetun_blok():
"""Gluetun publiceert de webinterfaces van elke client die erachter zit."""
g = next(a for a in onderdelen() if a["dir"] == "gluetun")
r = ["<%- if enable_gluetun %>"] + _stack_dienst(g)
r.remove(" restart: unless-stopped")
r.append(" ports:")
r.append(" # De downloadclients delen dit netwerk, dus hun webinterfaces")
r.append(" # staan hier. Inkomende torrentpoorten niet: die regelt je")
r.append(" # VPN-aanbieder met port forwarding.")
for app in onderdelen():
if app["dir"] not in CLIENTS:
continue
r.append(f"<%- if enable_{_naam(app)} %>")
for veld, _t, _h, container, proto, _o in app["poorten"]:
if veld != "port":
continue # inkomende poorten gaan niet via de host
r.append(f' - "<< {_poortveld(app, veld)} >>:{container}"')
r.append("<%- endif %>")
r.append(" restart: unless-stopped")
r.append("<%- endif %>")
return r
def schrijf_stack():
d = APPS / "arr-stack"
(d / "files").mkdir(parents=True, exist_ok=True)
(d / "template.json").write_text(
json.dumps(stack_template(), indent=2, ensure_ascii=False) + "\n",
encoding="utf-8")
(d / "files" / "compose.yaml").write_text(stack_compose(), encoding="utf-8")
return len(onderdelen())
def main():
for app in SPEC:
schrijf_los(app)
n = schrijf_stack()
print(f"{len(SPEC)} losse sjablonen en arr-stack ({n} onderdelen) geschreven")
if __name__ == "__main__":
main()