"""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", "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 `-db`, in de stack `-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()