# đŸ› ïž Kapitel 13 – Clipper (Tutorial) --- ## Einleitung Clips sind der beste Weg, lange Streams in kurze, teilbare Highlights zu verwandeln. Unser Ziel in diesem Kapitel: Wir bauen ein System, das neue Videos automatisch erkennt, sinnvolle Stellen analysiert, passende Highlights schneidet und die Ergebnisse in Nextcloud ablegt – inklusive Titeln und Hashtags fĂŒr jede Plattform. Der Clipper selbst ĂŒbernimmt dabei die technische Verarbeitung, wĂ€hrend **n8n** fĂŒr Steuerung, Analyse und Benachrichtigungen sorgt. Das System bleibt dadurch flexibel, ressourcenschonend und jederzeit erweiterbar. --- ## Voraussetzungen - **Proxmox LXC** mit Debian 12 (Bookworm) - **Nextcloud** (Pflicht, Zielort fĂŒr Clips & Metadaten) - **n8n-Instanz** (Automatisierung, Steuerung Clipper, Analyse, Metadaten-Erzeugung) - **Twitch-Entwickler-Account** inkl. API-Key (fĂŒr VOD- und Clip-Zugriff) - **Optional**: RTMP-Server, falls VODs lokal aufgezeichnet werden - **Ressourcen fĂŒr den LXC**: 1 vCPU, 1–2 GB RAM, 100 GB Speicher reichen aus - **Grundwissen**: SSH-Verbindung, Nano-Editor, Basiskenntnisse in n8n --- ## Vorbereitung Wir beginnen mit einem frischen Debian‑12‑LXC in Proxmox, benennen ihn `clipper` und vergeben die im Abschnitt oben genannten Ressourcen. Danach bringen wir das System auf Stand und installieren die Grundwerkzeuge: ```bash apt update && apt upgrade -y apt install -y curl unzip ffmpeg inotify-tools pv bc git ``` Eine korrekte Systemzeit ist entscheidend, da Schnittmarken spĂ€ter auf exakten Sekunden basieren. PrĂŒfe die Zeit mit: ```bash timedatectl status ``` Wenn hier UTC steht und du lieber „Europe/Berlin“ nutzen willst: ```bash timedatectl list-timezones | grep Europe timedatectl set-timezone Europe/Berlin timedatectl status ``` Die Zeit wird sofort angepasst, Logs und Schnittzeiten passen damit zur lokalen Umgebung. Zum Schluss legen wir die Arbeitsordner an: ```bash mkdir -p /srv/clipper/{watch,out,temp,logs} ``` - `watch` – Eingangsordner fĂŒr neue Videos (egal ob von Twitch oder RTMP) - `out` – fertige Clips und Metadaten - `temp` – Zwischenspeicher fĂŒr Analyse - `logs` – Protokolle aller AblĂ€ufe Damit ist das Fundament gelegt. --- ## Abschnitt 2 – Clipper‑LXC einrichten (Benutzer, Verzeichnisse, Pakete, Skripte) In diesem Abschnitt richten wir den **Clipper‑Container** so ein, dass **SSH‑SchlĂŒssel**, **Downloads** und **n8n‑Aufrufe** ohne Berechtigungsfehler funktionieren. Wir arbeiten jetzt **im Terminal deines Clipper‑LXC als root**. > **Entscheidung & BegrĂŒndung – Benutzer mit Home & Bash** > Der Benutzer **clipper** bekommt ein **Homeverzeichnis** (`/home/clipper`) und eine **Login‑Shell**. So können SSH‑SchlĂŒssel sauber in `~clipper/.ssh` landen und n8n spĂ€ter per SSH Befehle ausfĂŒhren. Varianten ohne Home (System‑User) fĂŒhren bei `ssh-copy-id` zu Fehlern. ### 2.1 Benutzer und Verzeichnisse anlegen (Terminal, als root) ```bash adduser --home /home/clipper --shell /bin/bash clipper ``` Vergib ein Passwort und bestĂ€tige die Abfragen. Danach legst du die Arbeitsordner an und ĂŒbertrĂ€gst den Besitz an `clipper`: ```bash mkdir -p /srv/clipper/{inbox,watch,out,temp,logs,bin} chown -R clipper:clipper /srv/clipper chmod 750 /srv/clipper ``` (optional, aber hilfreich fĂŒr `ssh-copy-id` spĂ€ter): ```bash install -d -m 700 -o clipper -g clipper /home/clipper/.ssh ``` > **Falls du den Benutzer bereits ohne Home angelegt hast:** > Richte ihn so nach: > `mkdir -p /home/clipper/.ssh && chown -R clipper:clipper /home/clipper && chmod 700 /home/clipper/.ssh` ### 2.2 Pakete installieren (Terminal, als root) ```bash apt update && apt install -y yt-dlp jq python3 python3-venv curl unzip inotify-tools sudo ``` - **ffmpeg**: Analyse & Schnitt - **yt-dlp**: Twitch‑VOD/Clip‑Downloads (HLS) - **jq**: JSON‑Handling - **python3/venv**: spĂ€tere Analyse‑Tools - **inotify-tools**: Dateisystem‑Events (optional) - **sudo**: fĂŒr gezielte Rechteerhöhungen falls nötig ### 2.3 Zentrale Konfiguration (Terminal, als root) Bevor wir die Umgebungsdatei anlegen, brauchen wir ein eigenes Konfigurationsverzeichnis. Das existiert standardmĂ€ĂŸig nicht, daher legen wir es einmalig an: ```bash mkdir -p /etc/clipper chown root:clipper /etc/clipper chmod 750 /etc/clipper ``` > **Entscheidung & BegrĂŒndung – eigenes /etc/clipper** > Konfiguration gehört nach `/etc`. Mit einem eigenen Ordner `/etc/clipper` bleibt alles ĂŒbersichtlich getrennt. > Besitzer ist `root`, die Gruppe `clipper`. So kann der Clipper-User die Datei lesen, aber nicht verĂ€ndern – genau die Balance zwischen Sicherheit und Funktion. Entscheidung & BegrĂŒndung – eigenes /etc/clipper Konfiguration gehört nach /etc. Mit einem eigenen Ordner /etc/clipper bleibt alles ĂŒbersichtlich getrennt. Besitzer ist root, die Gruppe clipper. So kann der Clipper-User die Datei lesen, aber nicht verĂ€ndern – genau die Balance zwischen Sicherheit und Funktion. Lege eine Umgebungsdatei an, die beide Skripte laden: ```bash nano /etc/clipper/clipper.env ``` Inhalt: ``` SFTP_HOST=192.168.51.2 SFTP_PORT=22 SFTP_USER=sftp_uploader SFTP_KEY=/home/clipper/.ssh/nc_sftp_ed25519 DROP_BASE="/mnt/hdd/incoming" CLIPPER_PEAK_THRESHOLD=0.85 CLIPPER_MATCH_TOLERANCE=4.0 WHISPER_MODEL=small CLIPPER_IN=/srv/clipper/watch CLIPPER_OUT=/srv/clipper/out CLIPPER_TMP=/srv/clipper/temp CLIPPER_LOG=/srv/clipper/logs ``` Dateirechte setzen, damit `clipper` sie lesen darf: ```bash chown root:clipper /etc/clipper/clipper.env chmod 640 /etc/clipper/clipper.env ``` ### 2.4 Python‑Umgebung vorbereiten (Wechsel zu Benutzer *clipper*) Wechsle jetzt **zum Benutzer clipper**: ```bash su - clipper ``` Erzeuge und fĂŒlle eine virtuelle Umgebung fĂŒr die spĂ€tere Analyse: ```bash python3 -m venv /srv/clipper/.venv source /srv/clipper/.venv/bin/activate pip install --upgrade pip pip install librosa soundfile numpy scipy git+https://github.com/openai/whisper.git deactivate ``` Wechsle fĂŒr die nĂ€chsten Schritte **im Benutzer clipper** weiter. ### 2.6 Logrotation (zurĂŒck zu root) Beende die Session (`exit`) und kehre zu **root** zurĂŒck. Richte Logrotation ein: ```bash nano /etc/logrotate.d/clipper ``` Inhalt: ``` /srv/clipper/logs/*.log { rotate 14 daily missingok notifempty compress delaycompress copytruncate } ``` > **Schnelltest (optional):** > ZurĂŒck im **Benutzer clipper**: > `/srv/clipper/bin/clipper-analyze /srv/clipper/watch/demo.mp4 job-001` > `/srv/clipper/bin/clipper-cut /srv/clipper/watch/demo.mp4 /srv/clipper/temp/job-001/ranges.json job-001` > `tail -n 50 /srv/clipper/logs/clipper.log` Mit dieser Einrichtung sind **SSH‑SchlĂŒssel**, **Berechtigungen** und **Pfade** konsistent. `ssh-copy-id` aus Abschnitt 3 funktioniert dadurch ohne Fehlermeldungen – und n8n kann die Skripte stabil starten. --- ## Abschnitt 3 – n8n ↔ Twitch: VOD & Clips importieren, in Nextcloud ablegen, Clipper starten In diesem Abschnitt verbinden wir n8n mit Twitch, Nextcloud und dem Clipper. Das Ziel: n8n erkennt automatisch neue VODs auf Twitch, lĂ€dt sie zusammen mit Clips herunter, legt sie in Nextcloud ab und startet dann die Analyse auf dem Clipper. Wir gehen Schritt fĂŒr Schritt vor – immer mit klaren Hinweisen, ob wir uns gerade in der **n8n-WeboberflĂ€che**, im **Terminal** oder in **Nextcloud** befinden. > [!NOTE] > **Rollenverteilung** > **n8n** steuert (APIs, Logik, Benachrichtigungen). **Clipper** arbeitet (Download, Analyse, Schnitt). **Nextcloud** speichert (Archiv & Übergabe). So bleibt n8n schlank und ausfallsicher, wĂ€hrend Clipper CPU/IO fĂŒr Medienjobs bekommt. --- ### Schritt 1: Zugriff zwischen den Containern vorbereiten (Terminal) **SSH-SchlĂŒssel fĂŒr Clipper hinterlegen (zwingend):** ```bash ssh-keygen -t rsa -b 4096 -m PEM -f ~/.ssh/id_n8n_clipper -N "" ssh-copy-id -i ~/.ssh/id_n8n_clipper.pub clipper@ # << unbedingt ausfĂŒhren ssh -i ~/.ssh/id_n8n_clipper clipper@ "echo OK" ``` Ersetze `` durch die IP/den Hostnamen deines Clipper-LXC (z. B. `10.0.0.42`). Wenn **OK** erscheint, kann n8n ohne Passwort auf den Clipper zugreifen. > [!NOTE] > RSA (PEM) statt ed25519: n8n akzeptiert nur PEM-SchlĂŒssel zuverlĂ€ssig. Ed25519 erzeugt oft OpenSSH-Keys, die von n8n nicht geparst werden können. Mit `-m PEM` stellen wir sicher, dass das Format passt. --- ### Nextcloud SFTP einrichten WebDAV ist in Nextcloud eingebaut, aber bei großen Dateien wie VODs oft unzuverlĂ€ssig. Gerade Streams oder lange Aufnahmen können abbrechen. Darum richten wir hier eine stabile Alternative ein: SFTP mit eigenem Upload-Benutzer. So landet jedes VOD automatisch in einem eigenen Sammelordner in Nextcloud – sauber sortiert, sicher ĂŒbertragen und sofort sichtbar. 1. Logge dich in den Nextcloud Container per ssh ein: 2. Neuen SFTP-Benutzer anlegen Mit den folgenden Befehlen legen wir uns den user an, der auf Linuxebene den Upload ĂŒbernimmt. Jetzt wird der ein oder andere fragen warum nicht mein Nextclouduser? Die Antwort darauf ist so einfach wie simpel. Wir arbeiten zwar im Nect Container, aber nicht in Nextcloud, sondern auf Linux. Das heißt wir haben keinen Zugriff auf unseren Nextclouduser. ```bash useradd -m -s /usr/sbin/nologin sftp_uploader passwd -l sftp_uploader ``` Damit bekommt er ein Home-Verzeichnis, aber keine Shell. Passwort ist gesperrt – wir nutzen gleich SchlĂŒssel. 3. Um eine unkomplizierte Verbindung zu garantieren nutzen wir statt der Passwort abfrage einen SSH SchlĂŒssel. Das erhöht zusĂ€tzlich die Sicherheit. Logge dich hierfĂŒr per SSH auf deinem Clipper LXC ein. Mit `ssh-keygen -t ed25519 -C "sftp_uploader@nextcloud" -f ~/.ssh/nc_sftp_ed25519` erzeugen wir die nötigen Dateien und SchlĂŒssel fĂŒr die Verbindung. Wenn du Enter drĂŒckst, legt er die Datei standardmĂ€ĂŸig im Ordner ~/.ssh/ an. Wenn nach einer Passphrase gefragt wird, einfach leer lassen (Enter). Damit kann Clipper spĂ€ter automatisiert hochladen. Da wir den Key gleich benötigen, lĂ€sst du ihen dir mit `cat ~/.ssh/nc_sftp_ed25519.pub` anzeigen. Kopiere die gesamte Zeile. Sie sollte mit `ssh-ed25519` beginnen und mit `sftp_uploader@nextcloud enden`. 4. Wechsel nun wieder zurĂŒck zum Nextcloud LXC. Dort mĂŒssen wir zunĂ€chst einen Ordner anlegen in dem die Daten empfangen werden können. Dies machen wir mit ```bash sudo -u sftp_uploader mkdir -p ~sftp_uploader/.ssh ``` Mit dem nĂ€chsten Befehl legen wir entsprechend eine Datei die den erzeugten SchlĂŒssel enthĂ€lt. ```bash nano /home/sftp_uploader/.ssh/authorized_keys ``` FĂŒge die voerher kopierte Zeile ein, speichere die Datei mit `STRG + O ` ab und verlasse den Editor mit `STRG + X`. Als nĂ€chstes gibst du ```bash sudo chown -R sftp_uploader:sftp_uploader /home/sftp_uploader/.ssh sudo chmod 700 /home/sftp_uploader/.ssh sudo chmod 600 /home/sftp_uploader/.ssh/authorized_keys ``` ein. Hierdurch werden die Rechte korrekt gesetzt und die SFTP Verbindung wĂ€re bereits möglich. Als letztes mĂŒssen wir nur noch dafĂŒr sorgen, dass der Login fehlerfrei durchlĂ€uft. Öffne hierzu die `sshd_config` mit `nano /etc/ssh/sshd_config`. Suche nach `Subsystem sftp /usr/lib/openssh/sftp-server` und ersetze sie mit `Subsystem sftp internal-sftp`. Nach dem Speicher mit `STRG + O` kannst du die Datei einfach mit `STRG + X` schließen und mit `systemctl restart ssh` ssh neustarten. Jetzt sind wir bereit fĂŒr den ersten Test der Verbindung. `sftp -i ~/.ssh/nc_sftp_ed25519 sftp_uploader@` sollte ```bash Connected to 192.168.51.2. sftp> ``` anzeigen. Mit `quit`beendst du die Verbindung wieder. Leider reicht dies jedoch noch nicht ganz und wir mĂŒssen ein paar weitere Schritte unternehmen. 5. Wir wĂ€ren in der Lage jetzt bereits ĂŒber die SFTP Verbindung Dateien zu senden. Allerdings wĂŒrden sie nicht sichtbar fĂŒr uns sein. Daher mĂŒssen wir zunĂ€chst einen weiteren User anlegen, der die Indizierung in der Nextcloud anstoßen darf. Im Großen und Ganzen gehen wir zunĂ€chst genauso vor wie beim letzten User. Logge dich hierzu zunĂ€chst wieder per ssh in deinen Nextcloud LXC ein. ```bash ssh root@ ``` Dort legen wir nun unseren neuen Benutzer `nc_runner` an. Im Gegensatz zum SFTP Benutzer bekommt er diesmal eine Shell, damit er spĂ€ter auch Befehle ausfĂŒhren kann. Ein Passwort vergeben wir trotzdem nicht, da wir wieder mit einem SchlĂŒssel arbeiten werden. ```bash useradd -m -s /bin/bash nc_runner passwd -l nc_runner ``` Damit ist der Benutzer erstellt und bereit fĂŒr den nĂ€chsten Schritt. Dort erzeugen wir fĂŒr den neuen Benutzer ein SchlĂŒsselpaar, das fĂŒr die Verbindung genutzt wird. ```bash ssh-keygen -t ed25519 -C "nc_runner@nextcloud" -f ~/.ssh/nc_runner_ed25519 ``` Wenn du Enter drĂŒckst, legt er die Datei standardmĂ€ĂŸig im Ordner `~/.ssh/` an. Wenn nach einer Passphrase gefragt wird, einfach leer lassen (Enter). Damit kann spĂ€ter n8n automatisiert die Indizierung auslösen. Lasse dir nun den Public Key anzeigen und kopiere die gesamte Zeile. ```bash cat ~/.ssh/nc_runner_ed25519.pub ``` Sie sollte mit `ssh-ed25519` beginnen und mit `nc_runner@nextcloud` enden. Wechsel nun wieder zurĂŒck in den Nextcloud LXC. Dort legen wir auch fĂŒr diesen Benutzer den `.ssh` Ordner an und tragen den eben kopierten SchlĂŒssel in die `authorized_keys` Datei ein. ```bash sudo -u nc_runner mkdir -p /home/nc_runner/.ssh nano /home/nc_runner/.ssh/authorized_keys ``` FĂŒge die kopierte Zeile ein, speichere die Datei mit `STRG + O`, bestĂ€tige mit Enter und verlasse den Editor mit `STRG + X`. Damit die Datei auch wirklich korrekt funktioniert, setzen wir nun noch die Rechte: ```bash chown -R nc_runner:nc_runner /home/nc_runner/.ssh chmod 700 /home/nc_runner/.ssh chmod 600 /home/nc_runner/.ssh/authorized_keys ``` Damit kann sich `nc_runner` bereits mit dem SchlĂŒssel anmelden. Damit er aber wirklich in der Lage ist die Indizierung anzustoßen, mĂŒssen wir ihm noch die Rechte geben, bestimmte Befehle als `www-data` auszufĂŒhren. Nur `www-data` darf nĂ€mlich das `occ`-Kommando in Nextcloud starten. DafĂŒr öffnen wir die sudo-Konfiguration: ```bash visudo ``` Ganz am Ende fĂŒgen wir folgende Zeile ein: ``` nc_runner ALL=(www-data) NOPASSWD: /usr/bin/php /srv/nextcloud/app/nextcloud/occ * nc_runner ALL=(root) NOPASSWD: /usr/local/bin/nc_finalize_vod.sh ``` Hierdurch erlauben wir dem Benutzer `nc_runner`, genau diese Befehle als `www-data` auszufĂŒhren – und nichts anderes. Zum Abschluss testen wir, ob alles funktioniert. Wechsle dazu wieder auf deinen Clipper LXC und rufe folgenden Befehl auf: ```bash ssh -i ~/.ssh/nc_runner_ed25519 nc_runner@ "sudo -u www-data php /srv/nextcloud/app/nextcloud/occ -V" ``` Wenn alles korrekt eingerichtet ist, bekommst du die aktuelle Nextcloud Version angezeigt. --- ### Twitch-API Zugang (Developer Console) 1. Gehe auf [Twitch Developer Console](https://dev.twitch.tv/console/apps) → **Applications** → **Register Your Application**. 2. Felder ausfĂŒllen: * **Name:** frei wĂ€hlbar * **OAuth Redirect URL:** z. B. `https://localhost/` * **Category:** passend wĂ€hlen 3. App speichern → **Manage** öffnen. 4. **Client ID** notieren, **New Secret** klicken und **Client Secret** sichern. ### Twitch OAuth2 Credential in n8n (Client Credentials Flow) * **Name:** Twitch API * **Grant Type:** `Client Credentials` * **Authorization URL:** `https://id.twitch.tv/oauth2/authorize` * **Access Token URL:** `https://id.twitch.tv/oauth2/token` * **Client ID / Secret:** aus Twitch Developer Console * **Scope:** leer lassen * **Authentication:** `Body` --- ### Schritt 2: Twitch-User-ID herausfinden (n8n-WeboberflĂ€che) HTTP Request Node: * Methode: GET * URL: ``` https://api.twitch.tv/helix/users?login= ``` * Auth: OAuth2 Credential * Header: `Client-Id: ` Im Ergebnis findest du im Feld `data[0].id` deine **User-ID** (z. B. `123456789`). --- ### Schritt 3: Workflow bauen (n8n-WeboberflĂ€che) In diesem Schritt erstellen wir den eigentlichen Workflow in **n8n**. Er sorgt dafĂŒr, dass regelmĂ€ĂŸig neue VODs bei Twitch abgefragt, geprĂŒft und anschließend verarbeitet werden. > [!NOTE] > **Ziel dieses Schrittes** > Wir bauen den Ablauf in n8n: Cron-Trigger → Twitch API → PrĂŒfung der State-Datei → Entscheidung ĂŒber neue VODs → Download und Upload in Nextcloud. --- 1. **Cron-Trigger – Zeitplan festlegen** (Node-Name: `Cron – Alle 10 Min`) - Dieser Node löst den Workflow regelmĂ€ĂŸig aus. - Stelle ein: alle **10 Minuten** ausfĂŒhren. 2. **HTTP Request – Get Videos** (Node-Name: `Get Twitch VOD IDs`) - **Node-Typ:** HTTP Request - **Methode:** GET - **URL:** `https://api.twitch.tv/helix/videos?user_id=&type=archive&first=20` - **Authentifizierung:** OAuth2 (Credential: *Twitch API*) - **Header:** Client-Id: 2.1 **SSH Credentials in n8n anlegen** - Damit n8n mit dem Clipper kommunizieren kann, brauchst du SSH-Zugangsdaten. - Trage in n8n ein: * Name: `SSH Clipper` * Host: `` (z. B. `10.0.0.42`) * Port: `22` * Username: `clipper` * Private Key: Inhalt von `~/.ssh/id_n8n_clipper` * Working Directory: `/srv/clipper` 3. **Split Out – vods** (Node-Name: `Split Out – Twitch VOD`) - Node-Typ: Split Out - Field to Split Out: `data` 4. **SSH Node – State-Datei prĂŒfen** (Node-Name: `SSH – Check State`) - Dieser Schritt prĂŒft, ob die Datei `/srv/clipper/state/vod_seen.list` bereits existiert und ob VOD-IDs eingetragen sind. - Command (als Expression): ```js {{`set -euo pipefail; STATE_FILE="/srv/clipper/state/vod_seen.list"; fe=false; ne=false; arr='[]'; if [ -f "$STATE_FILE" ]; then fe=true; if [ -s "$STATE_FILE" ]; then ne=true; mapfile -t L < "$STATE_FILE"; json='['; sep=''; for id in "\${L[@]}"; do id_trim="$(printf '%s' "$id" | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"; [ -n "$id_trim" ] || continue; json+="$sep\"$id_trim\""; sep=','; done; json+=']'; arr="$json"; fi; fi; printf '{"file_exists":%s,"non_empty":%s,"vods":%s}\n' "$fe" "$ne" "$arr"`}} ``` - Typische Ergebnisse: ```json {"file_exists":false,"non_empty":false,"vods":[]} {"file_exists":true,"non_empty":false,"vods":[]} {"file_exists":true,"non_empty":true,"vods":["123456789","987654321"]} ``` 5. **Set – File Information** (Node-Name: `Set – File Information`) - Node-Typ: Set - Field: file_exists - Type: Boolean - Expression: `{{ JSON.parse($json.stdout).file_exists }}` - Field: non_empty - Type: Boolean - Expression: `{{ JSON.parse($json.stdout).non_empty }}` - Field: vods - Type: Array - Expression: `{{ JSON.parse($json.stdout).vods }} ` - Add Option: Ignore Type Conversion Errors -> "ON" 6. **Set – vods in Array** (Node-Name: `Set – vods in Array`) - Node-Typ: Set - Field: vods - Type: Array - Expression: ```js {{ (typeof $json.vods === 'string' ? $json.vods : String($json.vods)) .split(',') .map(s => s.trim()) .filter(Boolean) }} ``` 7. **Split Out – vods** (Node-Name: `Split Out – vods`) - Node-Typ: Split Out - Field to Split Out: `vods` 8. **Vorbereitungen fĂŒr VOD Download und Speicherung** **8.1 Vorbereitung (einmalig) – SFTP-Verbindung testen Ort: Clipper-LXC Shell EInmalig auf dem Nextcloud LXC: ```bash mkdir -p /mnt/hdd/incoming chown sftp_uploader:sftp_uploader /mnt/hdd/incoming chmod 700 /mnt/hdd/incoming ``` Wir prĂŒfen, ob Clipper mit dem Key des sftp_uploader auf den Nextcloud-Host kommt und die Drop-Zone erreichbar ist. ```bash mkdir -p /home/sftp_uploader/incoming echo "/mnt/hdd/incoming /home/sftp_uploader/incoming none bind 0 0" >> /etc/fstab mount /home/sftp_uploader/incoming systemctl daemon-reload ``` > [!NOTE] > Nun sieht der SFTP-Prozess weiterhin nur /home/sftp_uploader/incoming, speichert aber tatsĂ€chlich auf der HDD. > Vorteil: Keine Änderung an Pfaden nötig – alle Uploads landen automatisch auf dem großen Speicher. ```bash su - clipper sftp -i ~/.ssh/nc_sftp_ed25519 -oBatchMode=yes -oStrictHostKeyChecking=accept-new -P 22 sftp_uploader@ mkdir /mnt/hdd/incoming/_test ls /mnt/hdd/incoming rmdir /mnt/hdd/incoming/_test exit ``` Erwartung: die Ausgabe zeigt incoming. Dann ist die Verbindung korrekt. **8.2 Download/Upload Skript erstellen** **Ort:** Clipper-LXC Shell Wir erstellen ein Skript, das ein Twitch-VOD mit yt-dlp lĂ€dt und per SFTP nach incoming//.mp4 auf dem Nextcloud-Host hochlĂ€dt. Voraussetzung: In /etc/clipper/clipper.env sind gesetzt: SFTP_HOST, SFTP_PORT, SFTP_USER, SFTP_KEY, SFTP_DROP_BASE, außerdem CLIPPER_TMP, CLIPPER_OUT. ```bash nano /bin/clipper-vod-get ``` Nun befĂŒllst du sie mit: ```bash #!/usr/bin/env bash set -euo pipefail ENV_FILE="/etc/clipper/clipper.env" [[ -r "$ENV_FILE" ]] || { echo "ENV nicht lesbar: $ENV_FILE" >&2; exit 1; } source "$ENV_FILE" ID="${1:?need VOD id}" URL="${2:-https://www.twitch.tv/videos/${ID}}" TMP="${CLIPPER_TMP:-/srv/clipper/temp}" OUT_BASE="${CLIPPER_OUT:-/srv/clipper/out}/${ID}" LOGDIR="${CLIPPER_LOG:-/srv/clipper/logs}/${ID}" FILE="${TMP}/${ID}.mp4" TEMP="${TMP}/${ID}.temp.mp4" PART="${TMP}/${ID}.mp4.part" LOCK="${TMP}/${ID}.lock" FINAL_DIR="${OUT_BASE}/original" FINAL_FILE="${FINAL_DIR}/${ID}.mp4" mkdir -p "$TMP" "$LOGDIR" "$FINAL_DIR" LOG="${LOGDIR}/download.log" log(){ printf '[%(%F %T)T] %s\n' -1 "$*" ; } exec > >(tee -a "$LOG") 2>&1 exec 9>"$LOCK" if ! flock -n 9; then log "LOCK: $ID wird bereits verarbeitet" exit 0 fi trap 'flock -u 9; rm -f "$LOCK"' EXIT log "=== START: VOD ${ID} ===" log "URL: ${URL}" log "TMP: ${TMP}" # Wenn Zieldatei schon vorhanden → ĂŒberspringen if [[ -s "$FINAL_FILE" ]]; then log "SKIP: Finaldatei existiert bereits → $FINAL_FILE" exit 0 fi # Falls temp-Datei noch da ist → wiederaufnehmen if [[ -s "$TEMP" && ! -s "$FILE" ]]; then log "RESUME: $TEMP -> $FILE" mv -f "$TEMP" "$FILE" fi OUT="${TMP}/${ID}.%(ext)s" yt-dlp --newline \ --progress \ --progress-template "progress: %(progress._percent_str)s" \ --retries 20 \ --fragment-retries 50 \ --retry-sleep 5 \ --socket-timeout 30 \ --hls-prefer-ffmpeg \ --remux-video mp4 \ -o "$OUT" "$URL" # Fallback fĂŒr .temp-Datei [[ -s "$FILE" ]] || { [[ -s "$TEMP" ]] && mv -f "$TEMP" "$FILE"; } if [[ ! -s "$FILE" ]]; then log "ERROR: Download fehlgeschlagen ($FILE fehlt/leer)" exit 10 fi # Datei verschieben → Zielstruktur mv -f "$FILE" "$FINAL_FILE" log "MOVE: ${FILE} -> ${FINAL_FILE}" log "=== DONE: VOD ${ID} erfolgreich geladen ===" ``` ```bash chmod 755 /bin/clipper-vod-get chown clipper:clipper /bin/clipper-vod-get ``` > [!NOTE] > Pro VOD entsteht ein Logfile in `/logs/.log`. Du kannst es live mit `tail -f /logs/.log` verfolgen. Wer aufmerksam gelesn hat, stellt fest, das dieses Skript nur die halbe MIete ist. Warum machen wird das? Gaz einfach. Im weiteren Verlauf werden wir ein wenig mit den VODs und Clips arbeiten. Wenn wir sie direkt in unserer Nextcloud ablegen wĂŒrden, mĂŒssten wir sie umstĂ€nfdlich wieder zurĂŒck laden. Also legen wir mit ``nano /srv/clipper/bin/cleanup``an und fĂŒllen die Datei mit: ```bash #!/usr/bin/env bash # /srv/clipper/bin/cleanup # Clipper-Cleanup fĂŒr eine einzelne VOD_ID: # Erstellt Archiv, lĂ€dt es via SFTP hoch, löscht OUT und TEMP immer, LOGS nur wenn Ă€lter als 30 Tage set -euo pipefail # ------------------------------- # Argument prĂŒfen (VOD_ID) # ------------------------------- VOD_ID="${1:?❌ VOD_ID fehlt beim Aufruf. Syntax: cleanup VOD123}" # ------------------------------- # .env einbinden # ------------------------------- ENV_FILE="/etc/clipper/clipper.env" [[ -f "$ENV_FILE" ]] || { echo "❌ .env fehlt: $ENV_FILE" >&2; exit 1; } # shellcheck disable=SC1090 source "$ENV_FILE" # TMP konsistent benennen CLIPPER_TEMP="${CLIPPER_TMP:?CLIPPER_TMP fehlt in .env}" # ------------------------------- # Logging vorbereiten # ------------------------------- TODAY="$(date +%F)" LOG_DIR="${CLIPPER_LOG}/${VOD_ID}" mkdir -p "$LOG_DIR" LOGFILE="${LOG_DIR}/clipper_cleanup_${TODAY}.log" exec > >(tee -a "$LOGFILE") 2>&1 echo "===== đŸ§č Starte Cleanup fĂŒr VOD: $VOD_ID – $TODAY =====" # ------------------------------- # Pfade definieren # ------------------------------- OUT_DIR="${CLIPPER_OUT}/${VOD_ID}" TEMP_DIR="${CLIPPER_TEMP}/${VOD_ID}" LOG_VOD_DIR="${CLIPPER_LOG}/${VOD_ID}" ARCHIVE_TEMP_PATH="${CLIPPER_OUT}/${VOD_ID}.tar.gz" ARCHIVE_FINAL_PATH="${OUT_DIR}/${VOD_ID}.tar.gz" # ------------------------------- # Archiv erstellen (außerhalb, dann verschieben) # ------------------------------- if [[ -d "$OUT_DIR" ]]; then echo "📩 Erstelle temporĂ€res Archiv (ohne Selbstreferenz): $ARCHIVE_TEMP_PATH" tar -czf "$ARCHIVE_TEMP_PATH" -C "$CLIPPER_OUT" "$VOD_ID" echo "📂 Verschiebe Archiv in VOD-Ordner: $ARCHIVE_FINAL_PATH" mv "$ARCHIVE_TEMP_PATH" "$ARCHIVE_FINAL_PATH" else echo "⚠ OUT-Verzeichnis fehlt: $OUT_DIR" exit 2 fi # ------------------------------- # Archiv hochladen per SFTP # ------------------------------- echo "đŸ”Œ Lade Archiv hoch: ${SFTP_DROP_BASE}/${VOD_ID}.tar.gz" sftp -i "$SFTP_KEY" -P "$SFTP_PORT" -oStrictHostKeyChecking=no "$SFTP_USER@$SFTP_HOST" < 30 )); then echo "đŸ§č Entferne LOGS (Ă€lter als 30 Tage): $LOG_VOD_DIR" rm -rf "$LOG_VOD_DIR" else echo "â„č LOG-Verzeichnis ist nur $AGE_DAYS Tage alt – wird behalten." fi else echo "â„č Kein LOG-Verzeichnis vorhanden fĂŒr VOD: $VOD_ID" fi echo "✅ Cleanup abgeschlossen fĂŒr VOD: $VOD_ID" ``` Wie zuvor auch, muss auch dieses Skript wieder die richtigen Berechtigungen zugewiesen bekommen. Der Befehl sotte zwar allen berrits bekannt sein, dennoch wiederholen wir ihn: ```bash chmod 755 /bin/cleanup chown clipper:clipper /bin/cleanup ``` 8.3 **Finalize-Skript auf dem Nextcloud-Host** Nachdem der Clipper die VOD-Datei in die Drop-Zone incoming/ hochgeladen hat, mĂŒssen wir diese Dateien an den endgĂŒltigen Platz in Nextcloud verschieben. Nur so erscheinen sie auch im Web-Interface. Genau dafĂŒr legen wir jetzt ein Skript an, das automatisch aufgerufen werden kann. Das Skript ĂŒbernimmt drei Aufgaben: - Dateien aus der Drop-Zone verschieben in den Zielordner innerhalb von Nextcloud. - Rechte setzen, damit Nextcloud (Benutzer www-data) die Dateien verwalten kann. - Den Nextcloud-Dateibaum mit occ files:scan aktualisieren, damit die Dateien sofort sichtbar werden. Die Datei `/etc/nc_uploader.conf` wird lokal auf dem Nextcloud-LXC abgelegt. Sie enthĂ€lt alle Pfade und Parameter, die das Skript `nc_finalize_vod.sh` benötigt, um Dateien aus der SFTP-Dropzone korrekt zu verschieben und in Nextcloud zu integrieren. ```bash nano /etc/nc_uploader.conf ``` FĂŒlle sie mit: ```bash NC_USER=DEIN_NC_USER # Nextcloud-Benutzer, dem die Dateien gehören sollen NC_TARGET_SUBPATH="Medien/VODs" # Zielordner innerhalb von Nextcloud (wie er im Web erscheint) NC_DATA="/mnt/hdd/nextcloud_data" # Basis-Datenverzeichnis deiner Nextcloud-Instanz DROP_BASE="/home/sftp_uploader/incoming" # SFTP-Drop-Zone von sftp_uploader PHP="/usr/bin/php" OCC="/srv/nextcloud/app/nextcloud/occ" ``` Speichere die Datei wieder mit `STRG + O` und schließe den Editor mit `STRG + x`. Das folgende Skript sorgt dann dafĂŒr. dass unser eben hoch geladendes VOD an der richtigen Stelle zu finden ist und ĂŒber die WeboberflĂ€che erreichbar sein wird. ```bash nano /usr/local/bin/nc_finalize_vod ``` Inhalt: ```bash #!/usr/bin/env bash # /usr/local/bin/nc_finalize_vod set -euo pipefail CONF="/etc/nc_uploader.conf" [[ -f "$CONF" ]] || { echo "Config fehlt: $CONF" >&2; exit 1; } # shellcheck disable=SC1090 source "$CONF" VOD_ID="${1:?need VOD id}" # ---- Eingaben & Pfade DROP_BASE="${DROP_BASE:?DROP_BASE fehlt in Config}" NC_DATA="${NC_DATA:?NC_DATA fehlt in Config}" NC_USER="${NC_USER:?NC_USER fehlt in Config}" NC_TARGET_SUBPATH="${NC_TARGET_SUBPATH:?NC_TARGET_SUBPATH fehlt in Config}" PHP="${PHP:-/usr/bin/php}" OCC="${OCC:-/var/www/nextcloud/occ}" SRC_DIR="${DROP_BASE}/${VOD_ID}" DST_BASE="${NC_DATA}/${NC_USER}/files" DST_DIR="${DST_BASE}/${NC_TARGET_SUBPATH}/${VOD_ID}" SCAN_PATH="${NC_USER}/files/${NC_TARGET_SUBPATH}/${VOD_ID}" # ---- Vorbedingungen prĂŒfen (wir legen KEINE Benutzerstruktur an!) [[ -d "$NC_DATA" ]] || { echo "NC_DATA nicht gefunden: $NC_DATA" >&2; exit 2; } [[ -d "$DST_BASE" ]] || { echo "Benutzerdateien fehlen: $DST_BASE (stimmt NC_USER/NC_DATA?)" >&2; exit 3; } [[ -d "$SRC_DIR" ]] || { echo "Drop-Ordner fehlt: $SRC_DIR" >&2; exit 4; } # ---- Ziel-Unterordner anlegen (falls nicht vorhanden) mkdir -p "$DST_DIR" chown -R www-data:www-data "$DST_BASE/$NC_TARGET_SUBPATH" # ---- Dateien/Ordner verschieben shopt -s nullglob dotglob had_files=false for f in "${SRC_DIR}/"*; do had_files=true mv -f "$f" "$DST_DIR/" done # Leeren Drop-Ordner entfernen (optional) rmdir "$SRC_DIR" 2>/dev/null || true shopt -u nullglob dotglob if [[ "$had_files" = false ]]; then echo "Keine Dateien in Drop-Ordner: ${SRC_DIR}" >&2 exit 5 fi # ---- Rechte setzen wie von Nextcloud erwartet chown -R www-data:www-data "$DST_DIR" # ---- Index nur fĂŒr diesen Pfad aktualisieren sudo -n -u www-data "$PHP" "$OCC" files:scan --path="$SCAN_PATH" --quiet # ---- Übrig gebliebene .lock-Dateien entfernen (z. B. durch AbbrĂŒche bei Upload) find "$DROP_BASE" -type f -name "*.lock" -delete 2>/dev/null || true # ---- Erfolgsmeldung echo "OK: $(printf '%s\n' "$DST_DIR")" ``` Wie zuvor auch, mĂŒssen wir die Rechte korrekt setzen, damit alles reibungslos funktioniert. Gebe dazu in der Konsole ```bash chmod 755 /usr/local/bin/nc_finalize_vod chown root:root /usr/local/bin/nc_finalize_vod ``` ein. Im weiteren Verlauf erstellen wir dann einen Node, der dieses Skript aufruft. Im Anschluss sollte das VOD in dem entsprechenden Ordner landen. 9. **Merge – Combine** (Node-Name: `Select VODs to Download`) - **Node-Typ:** Merge - **Mode:** Combine - **Combine By:** Matching Fields - **Fields To Match Have Different Names**: "ON" - **Eingang 1:** Split Out – vods - **Eingang 2:** Split Out – Twitch VOD - **Input 1 Field**: vods - **Input 2 Field**: data.id - **Output Type**: Keep Non-Matches - **Output Data From**: Input 2 Mit diesem Merge Node sorgen wir dafĂŒr, dass wir nur die VODs herunter laden, die neu sind und noch nicht von Clipper bearbeitet wurden. 10. **Split In Batches** (Node-Name: `Einzeldurchlauf`) - **Node-Typ:** Split In Batches - **Batch Size:** 1 Dieser Node sorgt dafĂŒr, dass wenn mal mehr wie ein VOD heruntergaladen werden muss dies nicht parallel geschieht. S sparen wir Ressourcen und sind schneller mit der Arbeit fertig. 11. **SSH Node 1 – State-Datei schreiben** (Node-Name: `State Datei schreiben`) - **Node-Typ:** SSH - **Credentials:** *SSH Clipper* - **Operation:** Execute Command - **Command is an Expression:** **ON** - **Command:** ``` bash set -euo pipefail; STATE="/srv/clipper/state/vod_seen.list"; mkdir -p "$(dirname "$STATE")"; printf '%s\n' "{{$json.data.id}}" >> "$STATE" ``` 12. **SSH Node 2 – Download ** (Node-Name: `Down`) - **Node-Typ:** SSH - **Credentials:** *SSH Clipper* - **Operation:** Execute Command - **Command is an Expression:** **ON** - **Command:** ```bash /bin/clipper-vod-get "{{ $('Loop Over Items').item.json.data.id }}" "{{ $json.url || ('https://www.twitch.tv/videos/' + {{ $('Loop Over Items').item.json.data.id }}" ``` 13. **SSH Node 3 – Upload ** (Node-Name: `Up`) - **Node-Typ:** SSH - **Credentials:** *SSH Clipper* - **Operation:** Execute Command - **Command is an Expression:** **ON** - **Command:** ```bash /bin/cleanup "{{ $('Loop Over Items').item.json.data.id }}" ``` 14. **SSH Node 4 – Finalize** (Node-Name: Finalize VOD) - Node-Typ: SSH - Credentials: SSH Nextcloud (nc_runner) - Operation: Execute Command - Command is an Expression: ON - Command: ```bash {{`ssh -i /root/.ssh/nc_runner_ed25519 nc_runner@ "sudo -n /usr/local/bin/nc_finalize_vod.sh ${$('Merge').item.json.data.id}"`}} ``` Diese 12 Nodes werden das gesamte GrundgerĂŒst der gesamten Automation sein. Wie aber mĂŒssen sie verbudnen werden? Das folgende Schaubild zeigt dir die konkrete Verkabelung ```bash ---- **SSH – Check State** --- **Set – File Information** --- **Set – vods in Array** --- **Split Out – vods** ------ | | **Cron – Alle 10 Min** ---- | **Select VODs to Download** --- **Einzeldurchlauf** --- **State Datei schreiben** --- **Down 'n' Up** --- **Finalize VOD** (Hier folgen spĂ€ter weitere Nodes, aber da der Einzeldurchlauf ein Loop ist wird der letzte Node mit Einzeldurchlauf verbunden) | | ---- *Get Twitch VOD IDs* --- *Split Out* – *Twitch VOD* -------------------------------------------------- ``` ## đŸ§Ș Abschnitt 4 – Analyse > [!NOTE] > Wir verwenden in diesem Tutorial **keine KI‑Analyse**. HierfĂŒr wird es ein **eigenes Tutorial** geben. Wir legen aber bereits alles so an, dass spĂ€ter keine Umbauten mehr nötig sind. Mit den vorherigen Schritten haben wir alles vorbereitet. Aber wir mĂŒssen nun dafĂŒr sorgen, dass wir auch die Clips und alles weitere kopieren können. HierfĂŒr beginnen wir zunĂ€chst mit der Analyse des zuvor heruntergeladnen VODs. > [!CAUTION] > Die Nodes, die wir hier verwenden, werden nicht nach dem Finalize Node eingefĂŒgt, sondern direkt nach `Down`. ### Schritt 4.1 – Analyse‑Skript anlegen **Ort:** Terminal im **Clipper‑LXC** → als Benutzer **clipper** ```bash nano /usr/local/bin/vod_analyze ``` ```bash #!/bin/bash set -euo pipefail # Cleanup bei CTRL+C, SIGTERM, etc. cleanup() { echo "[WARN] Analyse abgebrochen – prĂŒfe auf ffmpeg-Leichen..." pkill -P $$ ffmpeg || true pkill -f "ffmpeg.*$VOD_ID" || true # FIFO sĂ€ubern (nur wenn gesetzt & vorhanden) if [ -n "${FIFO_PATH-}" ] && [ -p "$FIFO_PATH" ]; then rm -f "$FIFO_PATH"; fi exit 1 } trap cleanup SIGINT SIGTERM # Umgebungsvariablen laden if [ -f "/etc/clipper/clipper.env" ]; then set -a . "/etc/clipper/clipper.env" set +a else echo "[ERROR] Environment file not found: /etc/clipper/clipper.env" >&2 exit 1 fi # Erwartete Umgebungsvariablen prĂŒfen : "${CLIPPER_PEAK_THRESHOLD:?Environment variable CLIPPER_PEAK_THRESHOLD is not set.}" : "${CLIPPER_TMP:?Environment variable CLIPPER_TMP is not set.}" : "${CLIPPER_LOG:?Environment variable CLIPPER_LOG is not set.}" # ID aus dem Aufruf ĂŒbernehmen VOD_ID="$1" VOD_PATH="/srv/clipper/out/$VOD_ID/original/$VOD_ID.mp4" TMP_DIR="$CLIPPER_TMP/$VOD_ID" TMP_AUDIO="$TMP_DIR/audio.wav" LOG_DIR="$CLIPPER_LOG/$VOD_ID" TMP_LOG_AUDIO="$LOG_DIR/audio.log" TMP_JSON="$TMP_DIR/candidates.json" mkdir -p "$TMP_DIR" "$LOG_DIR" exec > >(tee -a "${LOG_DIR}/analyze.log") 2>&1 echo "== Analyze $VOD_ID ==" echo "[INFO] VOD gefunden: $VOD_PATH" echo "[INFO] Extrahiere WAV aus $VOD_PATH → $TMP_AUDIO" # Audio extrahieren mit Logging nice -n 10 ffmpeg -v warning -threads 1 -i "$VOD_PATH" -ac 1 -ar 16000 -vn "$TMP_AUDIO" 2> "$TMP_LOG_AUDIO" echo "[OK] Audio extrahiert: $TMP_AUDIO ($(du -h "$TMP_AUDIO" | cut -f1))" echo "[INFO] Verwende Schwelle: $CLIPPER_PEAK_THRESHOLD" echo "[INFO] Starte Python-Analyse..." # Python-Analyse /srv/clipper/.venv/bin/python3 - < cutoff)[0] if len(high_energy) == 0: print("[INFO] Keine Peaks ĂŒber Schwelle gefunden.") with open(outfile, 'w') as f: json.dump([], f) print(f"[DONE] {outfile} geschrieben mit 0 Clip(s)") exit(0) # ZeitrĂ€ume berechnen candidates = [] t_start = None for i in range(len(energy)): t = i * frame_length / sr if energy[i] > cutoff: if t_start is None: t_start = t elif t_start is not None: t_end = t if t_end - t_start > 5: candidates.append({"start": round(t_start, 2), "end": round(t_end, 2)}) t_start = None if t_start is not None: t_end = len(y) / sr if t_end - t_start > 5: candidates.append({"start": round(t_start, 2), "end": round(t_end, 2)}) with open(outfile, 'w') as f: json.dump(candidates, f) print(f"[DONE] {outfile} geschrieben mit {len(candidates)} Clip(s)") EOF echo "[INFO] Starte visuelle Analyse (alle 3 Sekunden ein Frame)..." TMP_FRAME_DIR="$TMP_DIR/frames" mkdir -p "$TMP_FRAME_DIR" # Videodauer ermitteln (in Sekunden) DURATION=$(ffprobe -v error -show_entries format=duration \ -of default=noprint_wrappers=1:nokey=1 "$VOD_PATH") DURATION=${DURATION%.*} INTERVAL=3 EXPECTED_FRAMES=$(printf "%.0f" "$(echo "$DURATION / $INTERVAL" | bc -l)") echo "[INFO] VOD-Dauer: ${DURATION}s → erwarte ca. ${EXPECTED_FRAMES} Bilder bei fps=1/${INTERVAL}" # ffmpeg im Hintergrund START_TIME=$(date +%s) nice -n 10 ffmpeg -v warning -i "$VOD_PATH" \ -vf "fps=1/${INTERVAL},scale=160:90" \ "$TMP_FRAME_DIR/frame_%06d.bmp" & FFMPEG_PID=$! EXTRACTED_FRAMES=0 LAST_LOGGED_FRAMES=0 while kill -0 $FFMPEG_PID 2>/dev/null; do EXTRACTED_FRAMES=$(find "$TMP_FRAME_DIR" -name 'frame_*.bmp' | wc -l) if (( EXTRACTED_FRAMES > LAST_LOGGED_FRAMES )); then NOW=$(date +%s) ELAPSED=$((NOW - START_TIME)) ELAPSED_FMT=$(printf "%02d:%02d:%02d" $((ELAPSED/3600)) $((ELAPSED%3600/60)) $((ELAPSED%60))) if (( EXTRACTED_FRAMES > 0 )); then ESTIMATED_TOTAL=$((ELAPSED * EXPECTED_FRAMES / EXTRACTED_FRAMES)) ETA_SECONDS=$((ESTIMATED_TOTAL - ELAPSED)) ETA_FMT=$(printf "%02d:%02d:%02d" $((ETA_SECONDS/3600)) $((ETA_SECONDS%3600/60)) $((ETA_SECONDS%60))) else ETA_FMT="??:??:??" fi PERCENT=$(awk "BEGIN { printf \"%.2f\", $EXTRACTED_FRAMES * 100 / $EXPECTED_FRAMES }") echo "[INFO] Lade Frame $EXTRACTED_FRAMES / $EXPECTED_FRAMES (${PERCENT}%) | ETA: $ETA_FMT | Elapsed: $ELAPSED_FMT" LAST_LOGGED_FRAMES=$EXTRACTED_FRAMES fi sleep 1 done # Final-Log EXTRACTED_FRAMES=$(find "$TMP_FRAME_DIR" -name 'frame_*.bmp' | wc -l) END_TIME=$(date +%s) TOTAL_TIME=$((END_TIME - START_TIME)) TOTAL_FMT=$(printf "%02d:%02d:%02d" $((TOTAL_TIME/3600)) $((TOTAL_TIME%3600/60)) $((TOTAL_TIME%60))) echo "[INFO] Frame-Export abgeschlossen mit $EXTRACTED_FRAMES Bildern. Dauer: $TOTAL_FMT" echo "[INFO] Starte visuelle Analyse basierend auf Bewegung zwischen Frames..." TMP_JSON_VISUAL="$TMP_DIR/candidates.visual.json" /srv/clipper/.venv/bin/python3 < cutoff: if active_start is None: active_start = t else: if active_start is not None and (t - active_start) >= MIN_CLIP_DURATION: start_time = max(0, active_start - PRE_POST_BUFFER) end_time = min(t + PRE_POST_BUFFER, len(frame_files) * SECONDS_PER_FRAME) candidates.append({ "start": round(start_time, 2), "end": round(end_time, 2) }) active_start = None # Offener Clip am Ende if active_start is not None: t = len(frame_files) * SECONDS_PER_FRAME if t - active_start >= MIN_CLIP_DURATION: start_time = max(0, active_start - PRE_POST_BUFFER) end_time = min(t + PRE_POST_BUFFER, len(frame_files) * SECONDS_PER_FRAME) candidates.append({ "start": round(start_time, 2), "end": round(end_time, 2) }) with open(output_json, "w") as f: json.dump(candidates, f) print(f"[DONE] Bewegungserkennung abgeschlossen → {len(candidates)} Clip(s) geschrieben in {output_json}") EOF echo "[INFO] Vergleiche Audio- und visuelle Kandidaten..." TMP_JSON_FINAL="$TMP_DIR/candidates.final.json" CLIPPER_MATCH_TOLERANCE="${CLIPPER_MATCH_TOLERANCE:-4.0}" /srv/clipper/.venv/bin/python3 < 🔧 Optional: Konfigurationsordner /srv/clipper/etc anlegen Dieser Schritt ist optional und bereitet Clipper auf erweiterte Funktionen vor – zum Beispiel die spĂ€tere Verwendung von Codenamen fĂŒr Clips und PDFs. 📁 Ordner anlegen: mkdir -p /srv/clipper/etc Auch in diesem Fall mĂŒssen wir die korrekten Rechte und BesitzverhĂ€ltnisse schaffen.' chmod 755 /srv/clipper/etc chown clipper:clipper /srv/clipper/etc Wenn dieser Ordner nicht existiert, arbeitet Clipper im Basis-Modus. Erweiterte Funktionen wie zufĂ€llige Codenamen oder benutzerdefinierte Vorlagen werden deaktiviert. Damit Clipper zufĂ€llige Codenamen verwenden kann, laden wir eine vorbereitete Liste von einer zentralen Quelle herunter. 📄 Zielpfad: /srv/clipper/etc/codename_pool.txt /srv/clipper/etc/adjektive_de.txt 🌐 Quelle: https://git.bratonien.de/Thomas/Homelab--Bratonein-Kontrollzentrum--Tutorial/src/branch/main/codename_pool.txt https://git.bratonien.de/Thomas/Homelab--Bratonein-Kontrollzentrum--Tutorial/raw/branch/main/adjektive_de.txt 📩 Herunterladen: curl -fsSL https://git.bratonien.de/Thomas/Homelab--Bratonein-Kontrollzentrum--Tutorial/src/branch/main/codename_pool.txt -o /srv/clipper/etc/codename_pool.txt curl -fsSL https://git.bratonien.de/Thomas/Homelab--Bratonein-Kontrollzentrum--Tutorial/raw/branch/main/adjektive_de.txt -o /srv/clipper/etc/adjektive_de.txt Alternativ kann die Datei auch manuell heruntergeladen und dort abgelegt werden. Die Datei enthĂ€lt eine Liste von z. B. bekannten Anime-Namen – eine pro Zeile. Um nun die Clips zu schneiden, benötigen wir ein weiteres Skript und einen weiteren Node. Lege mit ```nano /bin/clipper-cut-vod ``` die benötigte Datei an und fĂŒlle sie mit dem folgenden COde: ```bash #!/bin/bash set -euo pipefail VOD_ID="$1" echo "[START] Starte Clip-Schnitt fĂŒr VOD: $VOD_ID" # Pfade ETC_DIR="/srv/clipper/etc" TMP_DIR="/srv/clipper/temp/$VOD_ID" OUT_DIR="/srv/clipper/out/$VOD_ID" VOD_PATH="$OUT_DIR/original/$VOD_ID.mp4" CANDIDATES_JSON="$TMP_DIR/candidates.json" USED_PATH="$ETC_DIR/used.json" LOG_DIR="/srv/clipper/logs/$VOD_ID" LOG_FILE="$LOG_DIR/cut-clips.log" CLIPS_DIR="$OUT_DIR/clips" mkdir -p "$LOG_DIR" "$CLIPS_DIR" touch "$LOG_FILE" echo 0 > /dev/null # Suppress debug=0 parse errors from unwanted stdin log() { echo "[INFO] $1" | tee -a "$LOG_FILE" >&2; } warn() { echo "[WARN] $1" | tee -a "$LOG_FILE" >&2; } error() { echo "[ERROR] $1" | tee -a "$LOG_FILE" >&2; exit 1; } # Namenslogik CODE_POOL="$ETC_DIR/codename_pool.txt" ADJ_POOL="$ETC_DIR/adjektive_de.txt" NAMING_MODE="fallback" if [[ -s "$CODE_POOL" && -s "$ADJ_POOL" ]]; then NAMING_MODE="adjektiv_codename" elif [[ -s "$CODE_POOL" ]]; then NAMING_MODE="codename" fi log "Namensmodus: $NAMING_MODE" declare -A USED_MAP if [[ -f "$USED_PATH" ]]; then while IFS= read -r name; do USED_MAP["$name"]=1 done < <(jq -r '.[]' "$USED_PATH" || echo "") fi is_used() { [[ -n "${USED_MAP[$1]+1}" ]]; } choose_final_name() { case "$NAMING_MODE" in "codename") mapfile -t CANDIDATES < "$CODE_POOL" ;; "adjektiv_codename") mapfile -t CODES < "$CODE_POOL" mapfile -t ADJS < "$ADJ_POOL" CANDIDATES=() for adj in "${ADJS[@]}"; do for code in "${CODES[@]}"; do CANDIDATES+=("${adj}-${code}") done done ;; *) echo "$VOD_ID"; return 0 ;; esac FINAL_NAME="" for candidate in $(printf "%s\n" "${CANDIDATES[@]}" | shuf); do if ! is_used "$candidate"; then FINAL_NAME="$candidate" log "GewĂ€hlter Codename: $FINAL_NAME" break fi done [[ -n "$FINAL_NAME" ]] && echo "$FINAL_NAME" || { warn "Keine freien Namen – fallback: $VOD_ID"; echo "$VOD_ID"; } } FINAL_NAME=$(choose_final_name) if [[ "$FINAL_NAME" != "$VOD_ID" ]]; then jq --arg name "$FINAL_NAME" '. + [$name]' "$USED_PATH" 2>/dev/null > "$USED_PATH.tmp" || echo "[\"$FINAL_NAME\"]" > "$USED_PATH.tmp" mv "$USED_PATH.tmp" "$USED_PATH" fi INDEX_CSV="$TMP_DIR/index.csv" echo "Typ;Quelle;Beginn;Ende;Dateiname;Pfad" > "$INDEX_CSV" CLIP_NUM=1 for SECTION in combined only_audio only_video; do case "$SECTION" in combined) QUELLE="kombiniert" ;; only_audio) QUELLE="audio" ;; only_video) QUELLE="video" ;; esac COUNT=$(jq ".\"$SECTION\" | length" "$CANDIDATES_JSON" 2>/dev/null || echo 0) if [[ "$COUNT" -eq 0 ]]; then warn "Keine Clips in $SECTION" continue fi STARTS=($(jq -r ".\"$SECTION\"[].start" "$CANDIDATES_JSON")) ENDS=($(jq -r ".\"$SECTION\"[].end" "$CANDIDATES_JSON")) for i in "${!STARTS[@]}"; do START="${STARTS[$i]}" END="${ENDS[$i]}" DURATION=$(awk "BEGIN { printf \"%.2f\", $END - $START }") OUT_BASENAME=$(printf "%03d_%s" "$CLIP_NUM" "$FINAL_NAME") CLIP_DIR="$CLIPS_DIR/$OUT_BASENAME" mkdir -p "$CLIP_DIR" OUT_PATH="$CLIP_DIR/$OUT_BASENAME.mp4" START_FMT=$(date -u -d "@$START" +"%H:%M:%S" 2>/dev/null || echo "$START") END_FMT=$(date -u -d "@$END" +"%H:%M:%S" 2>/dev/null || echo "$END") log "→ Clip $OUT_BASENAME.mp4 (Start: $START_FMT, Dauer: ${DURATION}s)" if ! ffmpeg -hide_banner -loglevel error -ss "$START" -i "$VOD_PATH" -t "$DURATION" \ -c:v libx264 -preset veryfast -crf 23 -c:a aac "$OUT_PATH" 2>>"$LOG_FILE"; then warn "Fehler beim Clip $OUT_BASENAME" continue fi echo "Clip;$QUELLE;$START_FMT;$END_FMT;$OUT_BASENAME.mp4;clips/$OUT_BASENAME/" >> "$INDEX_CSV" CLIP_NUM=$((CLIP_NUM + 1)) done done log "[DONE] Schnitt abgeschlossen – $((CLIP_NUM - 1)) Clips erstellt." echo '[]' ``` SSH Node – Clips schneiden (Node-Name: Cut Clips) Node-Typ: SSH Credentials: SSH Clipper Working Dir: /srv/clipper Command (Expression): ``/srv/clipper/bin/clipper-cut-vod "{{ $('Loop Over Items').item.json.data.id }}"`` Setze auch hier die richtigen BErechtigungen, damit alles einwandfrei und problemlos durchlaufen kann. ```bash chmod 755 /srv/clipper/bin/clipper-cut-vod chown clipper:clipper /srv/clipper/bin/clipper-cut-vod ``` Als nĂ€chstes wollen wir die zuvor installierte KI Funktion von Whispser nutzen. DafĂŒr haben wir bereits alles vorbereitet. Einzig das Skript und der Node in n8n stehen noch aus. Erstelle mit ``nano /srv/clipper/bin/create-subtitle`` die entsprechende Datei und befĂŒlle sie mit ```bash #!/usr/bin/env bash set -euo pipefail # === ENV einlesen === ENV_FILE="/etc/clipper/clipper.env" [ -r "$ENV_FILE" ] || { echo "[FATAL] ENV nicht lesbar: $ENV_FILE" >&2; exit 1; } source "$ENV_FILE" VOD_ID="${1:?VOD-ID muss ĂŒbergeben werden}" MODEL="${WHISPER_MODEL:-small}" LOG_FILE="/srv/clipper/logs/$VOD_ID/subtitle.log" mkdir -p "$(dirname "$LOG_FILE")" exec >> "$LOG_FILE" 2>&1 echo "[INFO] Starte Untertitelung fĂŒr VOD: $VOD_ID mit Modell: $MODEL" # Pfade VOD_OUT="/srv/clipper/out/$VOD_ID" CLIPS_ROOT="$VOD_OUT/clips" WAV_DIR="/srv/clipper/temp/$VOD_ID/whisper_wav" mkdir -p "$WAV_DIR" # VorprĂŒfung: Whisper installiert? if ! /srv/clipper/.venv/bin/python3 -c "import whisper" 2>/dev/null; then echo "[FATAL] Python-Modul 'whisper' fehlt im venv." >&2 exit 1 fi for clip_folder in "$CLIPS_ROOT"/*; do [ -d "$clip_folder" ] || continue clip_file="$(find "$clip_folder" -maxdepth 1 -name '*.mp4' | head -n1)" [ -f "$clip_file" ] || { echo "[WARN] Kein Clip in $clip_folder – ĂŒberspringe"; continue; } basename="$(basename "$clip_file" .mp4)" wav_file="$WAV_DIR/$basename.wav" srt_file="$clip_folder/$basename.srt" json_file="$clip_folder/$basename.json" txt_file="$clip_folder/$basename.txt" echo "[INFO] → Clip: $basename" if [ ! -f "$wav_file" ]; then echo "[INFO] Extrahiere WAV..." ffmpeg -hide_banner -loglevel error -y \ -i "$clip_file" -vn -ac 1 -ar 16000 "$wav_file" fi echo "[INFO] Starte Whisper-Transkription fĂŒr $basename..." # Python-Block korrekt im venv ausfĂŒhren /srv/clipper/.venv/bin/python3 -u <> "$LOG_FILE" 2>&1 import os import whisper import json from pathlib import Path import logging print = lambda *a, **k: __builtins__.print(*a, flush=True, **k) logger = logging.getLogger("subtitle_logger") logger.setLevel(logging.INFO) handler = logging.StreamHandler() handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s")) logger.addHandler(handler) MODEL = os.environ.get("MODEL", "$MODEL") wav_path = Path("$wav_file") srt_path = Path("$srt_file") json_path = Path("$json_file") txt_path = Path("$txt_file") logger.info(f"Modell laden: {MODEL}") model = whisper.load_model(MODEL) result = model.transcribe(str(wav_path), fp16=False) logger.info("Schreibe SRT...") with srt_path.open("w", encoding="utf-8") as f: for seg in result["segments"]: f.write(f"{seg['id']+1}\\n{seg['start']:.3f} --> {seg['end']:.3f}\\n{seg['text'].strip()}\\n\\n") logger.info("Schreibe JSON...") with json_path.open("w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2) logger.info("Schreibe TXT...") with txt_path.open("w", encoding="utf-8") as f: f.write(result["text"].strip()) logger.info(f"[DONE] $basename abgeschlossen.") EOF done echo "[DONE] Untertitelung abgeschlossen fĂŒr VOD $VOD_ID." ``` Mit ```bash chmod 755 /srv/clipper/bin/create-subtitle chown clipper:clipper /srv/clipper/bin/create-subtitle ``` setzen wir dir Rechte korrekt. Der in n8n benötigte node sieht wie folgt aus: SSH Node – Untertitel erstellen (Node-Name: Create Subtitle) Node-Typ: SSH Credentials: SSH Clipper Command (Expression): ``/srv/clipper/bin/create-subtitle "{{ $('Loop Over Items').item.json.data.id }}"``