# Encoding-Pipeline **Dokumenten-ID:** GNENC-002-PLAN **Titel:** Encoding-Pipeline: Komponenten, Konten, Verzeichnislayout und Datenfluss **Stand:** 2026-10-30 **Status:** Finaler Plan nach Betreiberentscheidungen vom 19.10.2026; externe Abhängigkeiten umgesetzt (GNAI-019) **Vorgänger:** [GNENC-001-PLAN_Architecture.md](GNENC-001-PLAN_Architecture.md) **Monitoring:** [GNMON-001-PLAN_Architecture.md](../monitoring/GNMON-001-PLAN_Architecture.md) **Samba/NFS:** [GNAI-017](../../proxmox-server/lxc/ct-101-samba/GNAI-017-PLAN_Samba-NFS-Export.md), [GNAI-019](../../proxmox-server/lxc/ct-101-samba/GNAI-019-DEC_Ansible-Migration.md) — SMB auf CT-101, Kernel-NFS auf dem PVE-Host ## 1. Architekturprinzip Jede Komponente führt einen Schritt aus und lebt in einem Podman-Container (rootless, eigener Store je Konto). Der Handover erfolgt ausschließlich über das Filesystem auf dem NFS-Mount `/var/lib/ghostnet-encoding`: Jede Komponente liest einen Input-Ordner, der der Output-Ordner des vorangegangenen Schritts ist. In-Progress-Daten liegen in Work-Ordnern; Übergaben erfolgen per atomarem `mv` (`rename` innerhalb desselben Mounts). Dadurch erkennt der nächste Schritt niemals halbfertige Dateien. ## 2. Komponenten | Komponente | Konto | Image | Aufgabe | Betrieb | | --- | --- | --- | --- | --- | | VPN-Sidecar | `gn-download` (983) | `qmcgaw/gluetun` (gepinnter Digest) | ProtonVPN-WireGuard-Tunnel mit Kill Switch; jDownloader2 teilt seinen Netzwerk-Namespace, gesamter Download-Verkehr läuft durch den Tunnel | dauerhaft | | Download | `gn-download` (983) | `jlesage/jdownloader-2` (gepinnter Digest) | liest `.dlc` aus `input//`, lädt über den VPN-Tunnel herunter, entpackt nach `jd/`, Event-Scripter verschiebt den Release-Ordner atomar nach `queue///` und räumt die `.dlc` auf | dauerhaft | | Encoding-Worker | `gn-encoding` (982) | Worker-Image (Eigenbau, gepinnt) | scannt `queue/`, filtert, encodet alle gültigen Videodateien, validiert, räumt auf, schreibt Status, Housekeeping | dauerhaft; Fenster-Check 00:00–06:00 Europe/Berlin | Dashboard und Metrics-Collector werden im Monitoring-Bereich betrieben (GNMON-001); das Dashboard liest `state/` read-only. ## 3. Konten und Berechtigungen | Konto | UID/GID | Sub-ID-Bereich | Home | Config | Gruppen | | --- | --- | --- | --- | --- | --- | | `gn-encoding` | 982 | 493216–558751 | `/var/lib/ghostnet-encoding` | `/etc/ghostnet-encoding` | `gn-media` | | `gn-download` | 983 | 558752–624287 | `/var/lib/ghostnet-download` | `/etc/ghostnet-download` | `gn-media` | | `gn-dashboard` | 984 | 624288–689823 | `/var/lib/ghostnet-dashboard` | `/etc/ghostnet-dashboard` | `gn-media` (nur lesend) | Gruppe `gn-media`: gemeinsame Gruppe der drei Konten für die Übergabe- Verzeichnisse und den lesenden Zugriff auf `state/`; GID 970. Alle Konten sind gesperrt (Passwort `!`, Shell `/usr/sbin/nologin`, Linger aktiv). **Anmerkung 30.10.2026 (primäre Gruppe):** Die drei Pipeline-Konten tragen `gn-media` als **primäre** Gruppe (die eigenen GID-Gruppen 982/983/984 bleiben für die Config-Verzeichnisse als Supplementary-Gruppe erhalten). Grund: AUTH_SYS über NFSv4 wirkte serverseitig nicht mit Supplementary-Gruppen — `mkdir` als 982/983 bekam EACCES, obwohl `gn-media` Gruppen-Schreibrechte hatte; mit primärer Gruppe 970 war der Zugriff sofort erfolgreich (Betriebsbefund 30.10.2026, Testreihe auf ghostnet-ki). Zudem gilt die LXC-idmap-Diskrepanz: die guest-seitige Gruppe `gn-media` (GID 970) in CT-101 erscheint auf dem Host als GID 100970 — die SMB-Schreibpfade laufen über World-Write (Mode 2777), nicht über Gruppen-Abgleich; siehe Abschnitt 4. ## 4. Verzeichnislayout ```text /var/lib/ghostnet-pipeline/ (NFS-Mount: 10.0.0.2:/media/datenhdd3/Encoding, PVE-Host; außerhalb der Konto-Homes) ├── input/ gn-media 2777 SMB-Drop ([Encode-In]) │ └── / gn-media 2777 nur .dlc ├── jd/ gn-media 2770 JD2-Arbeitsverzeichnis (gn-download) │ ├── / Download + Entpacken │ ├── dlc_done/ erfolgreich abgearbeitete .dlc │ └── failed/ .dlc mit Download-Fehler ├── queue/// gn-media 2770 Roh-Queue, atomar befüllt ├── work// gn-encoding 0750 Encode-Ziele in Arbeit ├── output/ gn-media 2777 Abholung ([Encode]) │ ├── // gn-media 2777 Erfolge bis Abholung │ └── _failed// gn-media 2777 Quarantäne └── state/ gn-media 2750 Status (Dashboard liest) ├── jobs/.json gn-encoding 0750 Status pro Videodatei ├── tmp/ gn-encoding 0750 atomare Schreib-Zwischenlage └── system.json Collector 0644 Systemmetriken (Monitoring) ``` `state/` ist `gn-encoding:gn-media` mit Mode `2750`, damit `gn-dashboard` lesen kann; `state/jobs/` und `state/tmp/` bleiben `0750` (nur Worker). `state/system.json` wird vom Metrics-Collector des Monitoring-Bereichs mit `0644` geschrieben. Handover-Regeln: - Übergabe immer per atomarem `mv` innerhalb des Mounts; Work-Dateien liegen nie in Übergabe-Ordnern. - `jd/`, `queue/` mit Mode `2770` (setgid); `state/` mit `2750` (setgid). - `input/` (inkl. Profil-Ordner) und der gesamte `output/`-Baum (inkl. Release- und `_failed/`-Verzeichnisse, vom Worker angelegt) mit Mode `2777` (setgid) — Welt-Schreibrecht für den SMB-Schreibpfad aus CT-101 (Betreiberentscheidung 30.10.2026, siehe unten). Neue Dateien erben die Gruppe `gn-media`. - jDownloader2 läuft mit `UMASK=022`, damit der Worker die Dateien lesen kann. - Der Worker löscht Quelldateien und geleerte Release-Ordner aus `queue/`; Löschrechte folgen aus dem Schreibrecht der Gruppe am Verzeichnis (kein Sticky-Bit). - `work/` wird pro Lauf geleert; `state/tmp/` dient nur atomaren Schreibzugriffen. **Berechtigungsmodell (Betreiberentscheidung 30.10.2026):** Die SMB-Zugriffe aus CT-101 laufen über guest-root bzw. beliebige Gast-Identitäten (Host-UIDs 100000+, da `admin users` Dateioperationen als root ausführt); die Pipeline-Verzeichnisse gehören via NFS den realen UIDs 982/983 mit primärer Gruppe 970. Da diese im LXC-idmap nicht abgebildet sind, tragen `input/` und der `output/`-Baum Mode `2777` (World-Write); `jd/`, `queue/`, `work/` und `state/` bleiben eng (2770/0750/2750). Der frühere Legacy-Cron auf dem PVE-Host (`chown -R root:root` + `chmod -R 0777` auf allen Medienplatten alle 10 Minuten, installiert 11.10.2026) hätte jedes echte Berechtigungsmodell plattgewalzt und wurde am 30.10.2026 entfernt (Rolle `pve_media_cron`, Backup der Crontab unter `/var/backups/ghostnet/pve/`). **Layout-Anlage:** Wegen `root_squash` auf dem Export kann Ansible als root keine chowned-Verzeichnisse anlegen; die Struktur wird als `gn-encoding` beziehungsweise `gn-download` angelegt (`become_user`; die Rolle `video_encoding` erzeugt und verifiziert das Layout vollständig — Stand 30.10.2026 umgesetzt, Verifikation bestanden). **Abholung:** Die fertigen Outputs werden über CT-101 per SMB abgeholt: Share `[Encode]` (Pfad `/media/datenhdd3/Encoding/output`, RW für `ghost`, lesend für `daidalaw`/`debugofen`). Die Entnahme per `mv` in die Bibliotheks-Shares (Filme/Serien/Anime) räumt `output/` auf. Ein optionaler Clean-Job (Worker-Housekeeping, Abschnitt 6) löscht Dateien aus `output/`, die älter als 30 Tage sind. Auf `ghostnet-ki` läuft kein smbd. Der `.dlc`-Drop erfolgt über Share `[Encode-In]` (Pfad `/media/datenhdd3/Encoding/input`, RW für `ghost`) — siehe GNAI-017. ## 5. Ablauf: Download (jDownloader2 über VPN) 1. `.dlc`-Datei in `input//` ablegen (Profil = Preset-Name) — per SMB über die Share `Encode-In` oder direkt auf dem Mount. 2. jDownloader2 lädt über den VPN-Tunnel herunter und entpackt nach `jd//`. 3. Event-Scripter (versioniert in git, ohne Secrets, per Ansible in die JD2-Config deployed): - Paket fertig: atomares `mv jd// queue///`; `.dlc` nach `jd/dlc_done/`. - Download-Fehler: `.dlc` nach `jd/failed/`. 4. Credentials liegen server-lokal in `/etc/ghostnet-download/jdownloader/` (root-owned, Gruppe `gn-download`, Mode 0750, als `/config` gemountet). Im Repository liegt nur ein Platzhalter/Beispiel; Ansible prüft Existenz, nie Inhalt. 5. Die Web-UI des Images wird nicht extern freigegeben; Zugriff nur über localhost/SSH-Tunnel. **VPN-Tunnel (gluetun-Sidecar):** - Image `qmcgaw/gluetun` (Digest gepinnt), Konto `gn-download`; JD2-Container mit `Network=container:` teilt den Netzwerk-Namespace — JD2 hat keine eigene IP, der gesamte Verkehr verlässt den Host über ProtonVPN. - ProtonVPN über WireGuard (`VPN_SERVICE_PROVIDER=protonvpn`); die Config (Private Key, Server, Land) stammt aus dem ProtonVPN-Account und liegt server-lokal in `/etc/ghostnet-download/vpn.env` (root-owned, `0640 root:gn-download`, per `EnvironmentFile=` geladen). Im Repository nur `vpn.env.example`. - Kill Switch aktiv: gluetuns Firewall blockiert jeglichen Nicht-VPN-Traffic, sobald der Tunnel fällt. - Quadlet: `NET_ADMIN` + `--device /dev/net/tun`. TUN in der rootless User- Namespace wird bei der Umsetzung auf dem Host verifiziert; Fallback ist ein host-seitiger WireGuard-Service mit Policy-Routing für den Download-Pfad. - Hinweis: Hoster limitieren gelegentlich VPN-IPs; bei stockenden Downloads Serverland in `vpn.env` wechseln. Die Management-VPN (CT-105) bleibt davon unberührt. ## 6. Ablauf: Encoding-Worker Pro Release (älteste zuerst, genau ein gleichzeitiger Encode), pro Videodatei sequenziell: 1. **Scan & Filter:** `queue///` durchsuchen. Video-Endungen (case-insensitiv): `mkv, mp4, m4v, avi, mov, wmv, flv, webm, mpg, mpeg, ts, m2ts, vob, 3gp, ogv`. Dateien < 100 MB und Nicht-Video-Dateien werden ignoriert. Es gibt keine Hauptdatei; alle gültigen Videodateien werden encodet. 2. **Status `queued` → `encoding`** in `state/jobs/.json` (inkl. Fortschritt aus dem HandBrakeCLI-Output). 3. **Encode:** `HandBrakeCLI --import-preset .json -Z "" -i -o work//.mkv`. Presets liegen unter `profiles/`, werden per Ansible auf den Host deployed und read-only gemountet. 4. **Validierung** (am Ziel in `work/`, vor der Übergabe); Status `verifying`: - HandBrakeCLI-Exit 0 - Zieldatei nicht leer - `ffprobe` findet mindestens einen Videostream - `ffmpeg -v error -i -f null -`: Exit 0 **und** leere stderr (ffmpeg dekodiert tolerant; Fehlerzeilen auf stderr gelten als Defekt) - ffprobe-Dauerabweichung Ziel vs. Quelle ≤ 1 s 5. **Erfolg:** atomares `mv` nach `output///`, Quelldatei löschen, Status `done`. **Fehler:** Zieldatei löschen, Quelldatei nach `output/_failed//` verschieben, Status `failed` mit Fehlermeldung. Übrige Dateien des Releases werden weiter verarbeitet. 6. Release-Ordner in `queue/` löschen, sobald leer. **Zeitfenster:** Vor jedem neuen Release prüft der Worker, ob 00:00–06:00 Uhr Europe/Berlin aktiv ist; außerhalb idlet er. Ein laufendes Release wird nicht abgebrochen und darf über 06:00 hinaus laufen. **Housekeeping (optional, standardmäßig aktiviert, deaktivierbar):** Im Idle-Durchlauf löscht der Worker Dateien in `output/`, die älter als 30 Tage sind (Clean-Job als Sicherheitsnetz; die Abholung per `mv` räumt regulär auf). ## 7. Statusmodell Eine JSON-Datei pro Videodatei unter `state/jobs/.json`, atomar geschrieben (Schreibzugriff in `state/tmp/`, dann `mv`): ```json { "job_id": "__", "release": "", "profile": "AV1 MKV 1080p30 RF25", "source": "queue///", "target": "output///.mkv", "status": "queued | encoding | verifying | done | failed", "progress_pct": 0.0, "source_duration_s": 7231.4, "target_duration_s": 7231.2, "source_bytes": 0, "target_bytes": 0, "started_at": "…", "finished_at": "…", "error": null } ``` Lebenszyklus: `queued` → `encoding` → `verifying` → `done` | `failed`. Aufbewahrung: `done` nach 30 Tagen gelöscht; `failed` bis zur manuellen Prüfung. `state/system.json` (Systemmetriken) wird vom Metrics-Collector des Monitoring-Bereichs bereitgestellt (GNMON-001). ## 8. Ressourcenlimits Quadlet-/cgroup-Limits, in der User-Session durchgereicht: | Ressource | Encoding-Worker | jDownloader2 | VPN-Sidecar | | --- | --- | --- | --- | | MemoryMax | 16G | 2G | 256M | | CPUWeight | 100 | 50 | 50 | | Nice | 10 | 10 | — | | IOWeight | 50 | 20 | — | Keine CPU-Obergrenze fürs Encoding. Feinjustierung nach einem repräsentativen Lasttest unter Inferenzlast (GNENC-001 Abschnitt 4). ## 9. Monitoring (Auslagerung) Dashboard und Metrics-Collector werden im Monitoring-Bereich geplant und betrieben (GNMON-001, `llm-server/monitoring/`). Die Encoding-Pipeline stellt `state/jobs/*.json` bereit; der Collector schreibt `state/system.json`; das Dashboard mountet `state/` und `queue/` read-only und zeigt Ressourcen sowie Encoding-Übersicht. Das Dashboard hat keinen GPU- und keinen Schreibzugriff. ## 10. Images | Image | Basis | Inhalt | Pinning | | --- | --- | --- | --- | | Worker | `linuxserver/handbrake:1.11.2-ls48` @ `sha256:fda92bd1…48a3c7` | + python3, ffmpeg | Digest nach Bau in Ansible-Vars gepinnt; Build rootless als `gn-encoding` | | jDownloader2 | `jlesage/jdownloader-2` | — | Digest gepinnt; Pull als `gn-download` | | VPN-Sidecar | `qmcgaw/gluetun` | — | Digest gepinnt; Pull als `gn-download` | Containerfiles unter `llm-server/encoding/images/`, Skripte unter `llm-server/encoding/scripts/`, Presets unter `llm-server/encoding/profiles/`. Skripte und Presets werden per Ansible auf den Host deployed und read-only in die Container gemountet (Updates ohne Image-Rebuild, versioniert über git). ## 11. Offene Punkte 1. **Output-Pickup über CT-101 (SMB) einmal als geschlossener Ablauf nachweisen:** Die Handover-Simulation (`verify-pipeline-handover.yml`) lief am 30.10.2026 vollständig durch (`.dlc`-Drop aus CT-101 → gn-download → queue → gn-encoding → output/state → gn-dashboard); das Lesen/Löschen des Output-Testreleases über CT-101 wurde durch den gleichzeitigen Server-Shutdown nicht mehr abgeschlossen und ist als kurzer Nachtest zu wiederholen. 2. Feinjustierung der Ressourcenlimits nach Lasttest unter Inferenzlast (GNENC-001 Abschnitt 4). 3. Rootless-TUN-Verifizierung für den VPN-Sidecar auf dem Host (Fallback: host-seitiger WireGuard mit Policy-Routing).