Files
Homelab--Bratonein-Kontroll…/Kapitel 20/Tutorial.md

1716 lines
59 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 🛠️ 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, 12 GB RAM, 100 GB Speicher reichen aus
- **Grundwissen**: SSH-Verbindung, Nano-Editor, Basiskenntnisse in n8n
---
## Vorbereitung
Wir beginnen mit einem frischen Debian12LXC 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 ClipperLXC einrichten (Benutzer, Verzeichnisse, Pakete, Skripte)
In diesem Abschnitt richten wir den **ClipperContainer** so ein, dass **SSHSchlüssel**, **Downloads** und **n8nAufrufe** ohne Berechtigungsfehler funktionieren. Wir arbeiten jetzt **im Terminal deines ClipperLXC als root**.
> **Entscheidung & Begründung Benutzer mit Home & Bash**
> Der Benutzer **clipper** bekommt ein **Homeverzeichnis** (`/home/clipper`) und eine **LoginShell**. So können SSHSchlüssel sauber in `~clipper/.ssh` landen und n8n später per SSH Befehle ausführen. Varianten ohne Home (SystemUser) 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**: TwitchVOD/ClipDownloads (HLS)
- **jq**: JSONHandling
- **python3/venv**: spätere AnalyseTools
- **inotify-tools**: DateisystemEvents (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 PythonUmgebung 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 **SSHSchlü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@<CLIPPER-IP> # << unbedingt ausführen
ssh -i ~/.ssh/id_n8n_clipper clipper@<CLIPPER-IP> "echo OK"
```
Ersetze `<CLIPPER-IP>` 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@<IP_des_Nextcloud_LXC>` 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@<IP_des_Nextcloud_LXC>
```
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@<NEXTCLOUD-IP> "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=<DEIN_LOGIN>
```
* Auth: OAuth2 Credential
* Header: `Client-Id: <DEINE_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=<DEINE_TWITCH_USER_ID>&type=archive&first=20`
- **Authentifizierung:** OAuth2 (Credential: *Twitch API*)
- **Header:** Client-Id: <DEINE_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: `<CLIPPER-IP>` (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@<IP_des_Nextcloud_LXC>
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/<VOD_ID>/<VOD_ID>.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 <clipper-ordner>/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 <clipper-ordner>/bin/clipper-vod-get
chown clipper:clipper <clipper-ordner>/bin/clipper-vod-get
```
> [!NOTE]
> Pro VOD entsteht ein Logfile in `<clipper-ordner>/logs/<ID>.log`. Du kannst es live mit `tail -f <clipper-ordner>/logs/<ID>.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" <<EOF
put "$ARCHIVE_FINAL_PATH" "${SFTP_DROP_BASE}/${VOD_ID}.tar.gz"
EOF
# -------------------------------
# Archiv lokal löschen nach erfolgreichem Upload
# -------------------------------
echo "🗑️ Lösche Archiv im VOD-Ordner: $ARCHIVE_FINAL_PATH"
rm -f "$ARCHIVE_FINAL_PATH"
# -------------------------------
# OUT-Verzeichnis löschen
# -------------------------------
echo "🧹 Entferne OUT: $OUT_DIR"
rm -rf "$OUT_DIR"
# -------------------------------
# TEMP-Verzeichnis löschen (falls vorhanden)
# -------------------------------
if [[ -d "$TEMP_DIR" ]]; then
echo "🧹 Entferne TEMP: $TEMP_DIR"
rm -rf "$TEMP_DIR"
else
echo " Kein TEMP-Verzeichnis vorhanden."
fi
# -------------------------------
# LOGS nur löschen, wenn älter als 30 Tage
# -------------------------------
if [[ -d "$LOG_VOD_DIR" ]]; then
LAST_CHANGE=$(find "$LOG_VOD_DIR" -type f -printf "%T@\\n" | sort -n | tail -1 || echo 0)
NOW=$(date +%s)
AGE_DAYS=$(( (NOW - ${LAST_CHANGE%.*}) / 86400 ))
if (( AGE_DAYS > 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 <clipper-ordner>/bin/cleanup
chown clipper:clipper <clipper-ordner>/bin/cleanup
```
8.3 **Finalize-Skript auf dem Nextcloud-Host**
Nachdem der Clipper die VOD-Datei in die Drop-Zone incoming/<VOD_ID> 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. **HTTP Request Marker.json laden** (Node-Name: `Marker.json laden`)
- **Node-Typ:** HTTP Request
- **Methode:** GET
- **URL:** `https://api.twitch.tv/helix/streams/markers?video_id={{ $('Loop Over Items').item.json.data.id }}`
- **Authentifizierung:** OAuth2 (Credential: *Twitch API*)
- **Header:** Client-Id: <DEINE_CLIENT_ID>
13. **SSH Node 2 Download ** (Node-Name: `Down`)
- **Node-Typ:** SSH
- **Credentials:** *SSH Clipper*
- **Operation:** Execute Command
- **Command is an Expression:** **ON**
- **Command:**
```bash
MARKER_JSON_DATA='{{ JSON.stringify($node["Marker.json laden"].json["data"]) }}' \
/<clipper-ordner>/clipper/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) }}"
```
14. **SSH Node 3 Upload ** (Node-Name: `Up`)
- **Node-Typ:** SSH
- **Credentials:** *SSH Clipper*
- **Operation:** Execute Command
- **Command is an Expression:** **ON**
- **Command:**
```bash
<clipper-ordner>/bin/cleanup "{{ $('Loop Over Items').item.json.data.id }}"
```
15. **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@<NEXTCLOUD-IP> "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 KIAnalyse**. 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 AnalyseSkript anlegen
**Ort:** Terminal im **ClipperLXC** → 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 - <<EOF
import os
import json
import numpy as np
import soundfile as sf
import librosa
path = "$TMP_AUDIO"
outfile = "$TMP_JSON"
threshold = float(os.getenv("CLIPPER_PEAK_THRESHOLD", "0.85"))
try:
y, sr = librosa.load(path, sr=None, mono=True)
except Exception as e:
print("[ERROR] Laden fehlgeschlagen:", e)
exit(1)
if len(y) == 0:
print("[WARN] Keine Audio-Daten vorhanden.")
with open(outfile, 'w') as f:
json.dump([], f)
print(f"[DONE] {outfile} geschrieben mit 0 Clip(s)")
exit(0)
frame_length = sr // 2
energy = np.array([
np.sum(np.abs(y[i:i+frame_length]))
for i in range(0, len(y), frame_length)
])
cutoff = np.quantile(energy, threshold)
high_energy = np.where(energy > 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 <<EOF
import os
import json
import cv2
import numpy as np
frame_dir = "$TMP_FRAME_DIR"
output_json = "$TMP_JSON_VISUAL"
frame_files = sorted([
os.path.join(frame_dir, f) for f in os.listdir(frame_dir)
if f.endswith(".bmp")
])
# 3 Sekunden pro Frame fix
SECONDS_PER_FRAME = 3
MIN_CLIP_DURATION = 15 # Sekunden
PRE_POST_BUFFER = 2 # Sekunden
movement_scores = []
frames = []
# Scores sammeln
for idx, frame_path in enumerate(frame_files):
frame = cv2.imread(frame_path, cv2.IMREAD_GRAYSCALE)
if frame is None:
continue
if idx == 0:
prev_frame = frame
continue
diff = cv2.absdiff(prev_frame, frame)
score = int(np.sum(diff))
movement_scores.append(score)
frames.append((idx, score))
print(f"[DEBUG] Frame {idx}: Bewegungsscore = {score}")
prev_frame = frame
if not movement_scores:
print("[WARN] Keine Bewegungsscores ermittelt.")
with open(output_json, 'w') as f:
json.dump([], f)
exit(0)
# Schwelle dynamisch bestimmen
cutoff = np.quantile(movement_scores, 0.99)
print(f"[INFO] Dynamische Bewegungsschwelle (99%): {int(cutoff)}")
print(f"[INFO] Min: {min(movement_scores)}, Max: {max(movement_scores)}, Median: {int(np.median(movement_scores))}")
# Kandidaten suchen
candidates = []
active_start = None
for idx, score in frames:
t = idx * SECONDS_PER_FRAME
if score > 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 <<EOF
import json
import os
audio_file = "$TMP_JSON"
visual_file = "$TMP_JSON_VISUAL"
output_file = "$TMP_JSON_FINAL"
tolerance = float("${CLIPPER_MATCH_TOLERANCE}")
# Overlap mit Toleranz
def overlap(a, b):
return not (a["end"] + tolerance < b["start"] or b["end"] + tolerance < a["start"])
try:
with open(audio_file) as f:
audio = json.load(f)
except:
print("[WARN] Audio-Kandidaten fehlen oder leer.")
audio = []
try:
with open(visual_file) as f:
visual = json.load(f)
except:
print("[WARN] Visuelle Kandidaten fehlen oder leer.")
visual = []
combined = []
only_audio = []
only_video = visual.copy()
matched_visual_indices = set()
for a in audio:
matched = False
for idx, v in enumerate(visual):
if overlap(a, v):
combined.append({
"start": round(min(a["start"], v["start"]), 2),
"end": round(max(a["end"], v["end"]), 2)
})
matched_visual_indices.add(idx)
matched = True
break
if not matched:
only_audio.append(a)
only_video = [
v for idx, v in enumerate(visual)
if idx not in matched_visual_indices
]
result = {
"only_audio": only_audio,
"only_video": only_video,
"combined": combined
}
with open(output_file, "w") as f:
json.dump(result, f)
print(f"[DONE] Kandidaten klassifiziert → combined={len(combined)}, only_audio={len(only_audio)}, only_video={len(only_video)}")
EOF
echo "[INFO] Ersetze ursprüngliches JSON mit finaler Version..."
mv "$TMP_JSON_FINAL" "$TMP_JSON"
```
```bash
chmod 755 /srv/clipper/bin/vod-analyze
chown clipper:clipper /srv/clipper/bin/vod-analyze
```
**SSH Node Analyze VOD** (Node-Name: Analyze VOD)
- Node-Typ: SSH
- Credentials: SSH Clipper
- Working Dir: /srv/clipper
- Command (Expression):
```bash
set -euo pipefail; /srv/clipper/bin/vod-analyze "{{ $('Loop Over Items').item.json.data.id }}"
```
### Ergebnis
- Für jedes neue VOD wird automatisch ein eigener LogOrdner erstellt.
- Die Analyse erzeugt pro VOD eine **`candidates.json`** mit Zeitfenstern.
- Abschnitt 5 nutzt diese JSON für den Schnitt.
<details>
<summary>🔧 Optional: Konfigurationsordner <code>/srv/clipper/etc</code> anlegen</summary>
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:
<code>mkdir -p /srv/clipper/etc</code>
Auch in diesem Fall müssen wir die korrekten Rechte und Besitzverhältnisse schaffen.'
<code>
chmod 755 /srv/clipper/etc
chown clipper:clipper /srv/clipper/etc
</code>
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:
<code>/srv/clipper/etc/codename_pool.txt
/srv/clipper/etc/adjektive_de.txt</code>
🌐 Quelle:
<code>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</code>
📦 Herunterladen:
<code>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
</code>
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.
</details>
Um nun die Clips zu schneiden, benötigen wir ein weiteres Skript und einen weiteren Node.
Lege mit `nano <clipper-ordner>/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 <<EOF >> "$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 }}"`
An dieser Stelle wären wir nun fertig mit dem Einrichtn von Clipper. Wir laden automatisch alle neuen VODs von Twitch herunter, die freigegeben wurden. Erkennen automatisch potentielle Clips, schneiden diese und speichern diese in unserem Langzeitspeicher.
Was aber wenn die Systemressourcen nicht ausreichend sind bzw. so gering sind, dass eine Verarbeitung die Last ins unermessliche steigern würde?
Um dieses Risiko zu mildern können wir ein kurzes Skript vorschalten, dass die Systemlast für 2 Minuten im Augebehält und erst dann die Verarbeitung startet. So können wir sicher sein, dass die Lasspizen abgefangen und eine maximale Auslastung nicht allzu lange anhält.
Wir fügen dafür eirekt zu Beginn des Loops einen Execute Command Node ein.
Diese füllen wir mit dem folgenden Code:
```bash
bash -c '
abbruch=0
timeout=900
ok=0
step=2 # Sekunden
target=20 # Ziel: 120 Sekunden stabil
threshold=15 # = 1.5 × 10 (um Dezimalzahlen zu umgehen)
while [ "$ok" -lt "$target" ]; do
raw_load=$(awk "{print \$1}" /proc/loadavg)
load=$(echo "$raw_load * 10" | awk "{printf \"%d\", \$1}")
if [ "$abbruch" -ge "$timeout" ]; then
echo "[FAIL] Abbruch: Last war nicht stabil genug in $timeout Sekunden"
exit 1
fi
if [ "$load" -le "$threshold" ]; then
ok=$((ok + step))
abbruch=$((abbruch + step))
echo "[INFO] Last stabil für $ok Sekunden"
else
ok=0
abbruch=$((abbruch + step))
"[INFO] Last nicht stabil. Beginne von vorne"
fi
sleep $step
done
echo "[PASS] Last stabil für $ok Sekunden"
exit 0
'
```
Natürlich verzögern wir jede Bearbeitung um mindestens zwei Minuten, aber wir verhinden so die Auslastung wenn wir sie nicht gebrauchen können.
---
# Bonus: Manuelle VOD auswahl
In diesem Teil ergänzen wir den geamten Workflow um einen weiteren Workflow, der die manuelle Auswahl von VODs unterstützt
```bash
#!/usr/bin/env bash
set -euo pipefail
source /etc/clipper/clipper.env
NC_ALIAS="nextcloud"
REMOTE_DIR="Analyse"
INBOX_DIR="/srv/clipper/inbox"
PROCESSED_DIR="$INBOX_DIR/.processed"
LOG_FILE="/srv/clipper/logs/fetch-and-process.log"
mkdir -p "$(dirname "$LOG_FILE")"
exec >> "$LOG_FILE" 2>&1
echo
echo "========== [START] $(date '+%F %T') =========="
mkdir -p "$INBOX_DIR" "$PROCESSED_DIR"
mapfile -t files < <(sftp "$NC_ALIAS" <<EOF | grep '\.mp4$' | awk '{print $NF}'
cd "$REMOTE_DIR"
ls
bye
EOF
)
for file in "${files[@]}"; do
vod_id="${file%.mp4}"
local_file="$INBOX_DIR/$file"
echo "[INFO] Lade $file von Nextcloud..."
sftp "$NC_ALIAS" <<EOF
cd "$REMOTE_DIR"
get "$file" "$local_file.partial"
bye
EOF
if [ -f "$local_file.partial" ]; then
mv "$local_file.partial" "$local_file"
echo "[INFO] Starte Clipper für $vod_id"
/srv/clipper/bin/clipper-vod-get "$vod_id" "$local_file"
mv "$local_file" "$PROCESSED_DIR/"
echo "[DONE] $vod_id abgeschlossen"
else
echo "[FAIL] Download fehlgeschlagen für $file"
fi
done
echo "========== [END] $(date '+%F %T') =========="
```
```bash
#!/usr/bin/env bash
set -euo pipefail
RELATIVE_PATH="${1:?Pfad fehlt}"
SRC="/mnt/hdd/nextcloud_data/<username>/files/$RELATIVE_PATH"
DST="/srv/clipper/watch/$(basename "$RELATIVE_PATH").partial"
echo "[INFO] Verschiebe: $SRC → $DST"
mv "$SRC" "$DST"
```