This commit is contained in:
2026-03-25 18:56:07 +01:00
parent f948c4dc3a
commit a2c347cd09
2 changed files with 406 additions and 0 deletions

View File

@@ -0,0 +1,206 @@
# Music Sync Script (Unraid)
## Zweck
Synchronisiert neue Musikdateien aus `incoming` in zwei Zielordner.
Dateien werden nur verarbeitet, wenn sie vollständig geschrieben sind.
---
## Ablauf
incoming → ready → DEST1 + DEST2 → delete
---
## Warum incoming → ready?
Das Script verwendet zwei Verzeichnisse, um einen sauberen und sicheren Verarbeitungszustand zu gewährleisten.
### Problem ohne Trennung
Wenn direkt aus `incoming` synchronisiert wird:
- Dateien können noch geschrieben werden (z. B. durch Picard)
- `rsync` kopiert ggf. unvollständige Dateien
- bei mehreren Durchläufen entstehen Inkonsistenzen zwischen den Zielen
---
### Lösung: Staging mit `ready`
Der Ablauf ist bewusst zweistufig:
incoming (unsicher) → ready (stabil) → Sync
#### incoming
- hier schreibt Picard
- Dateien können noch im Zugriff sein
- kein Sync von hier
#### ready
- Dateien werden erst verschoben, wenn sie alt genug sind
- `mv` innerhalb desselben Filesystems ist atomar
- Datei ist danach garantiert vollständig
👉 Ergebnis:
Das Script arbeitet nur mit fertigen, stabilen Dateien.
---
## Wichtiger Hinweis zu "atomar"
Ein `mv` ist nur dann atomar, wenn sich Quelle und Ziel im selben Filesystem befinden.
In diesem Setup:
```
INCOMING="/mnt/user/Cache/Picard/incoming"
READY="/mnt/user/Cache/Picard/ready"
```
liegen beide Pfade im selben Share → gleicher Storage → atomarer Move.
Wichtig:
- Es ist egal, wo Picard läuft (Docker, anderer Host, etc.)
- Entscheidend ist nur, wohin geschrieben wird
Nicht atomar wäre z. B.:
```
/mnt/user/... → /mnt/diskX/...
```
Dann würde `mv` intern kopieren + löschen.
---
## Lockfile
```
/mnt/user/Cache/Picard/sync.lock
```
### Zweck
- verhindert parallele Ausführung
- schützt vor doppelten Operationen
### Problemfall
Wenn das Script abbricht, bleibt das Lockfile bestehen und blockiert weitere Runs.
### Lösung
```
rm /mnt/user/Cache/Picard/sync.lock
```
Nur ausführen, wenn sicher kein Script mehr läuft.
---
## CHOWN (Dateirechte / CIFS Problem)
Nach dem Kopieren werden Dateien und Ordner explizit gesetzt auf:
```
CHOWN_USER="thorsten"
CHOWN_GROUP="users"
```
### Warum ist das nötig?
Unraid + CIFS/Samba verhält sich so:
- Dateien werden oft als `root` oder `nobody` angelegt
- Netzwerkzugriff über CIFS hat dann **keine Löschrechte**
- selbst wenn Lesen funktioniert
👉 Typisches Symptom:
Datei kann geöffnet, aber nicht gelöscht werden
---
### Lösung im Script
Nach erfolgreichem Sync:
- wird jede Datei gechowned
- zugehörige Ordner werden ebenfalls angepasst
- Ordner aber nur **einmal pro Lauf** (Performance)
👉 Ergebnis:
- Dateien gehören deinem User
- CIFS Zugriff funktioniert korrekt
- kein manuelles `chown -R` mehr nötig
---
## Eigenschaften
- verarbeitet nur Dateien älter als 1 Minute
- atomarer Move (incoming → ready)
- Sync zu zwei Zielen per rsync
- Löschen nur bei Erfolg beider Syncs
- fehlgeschlagene Dateien bleiben erhalten
- Lockfile verhindert parallele Ausführung
- Dry-Run Modus vorhanden
- Logging + Konsolenausgabe
- Pfade tolerant gegenüber trailing `/`
- Zielpfade müssen existieren (sonst Abbruch)
- Ownership wird gesetzt (`chown`)
- Ordner werden nur einmal pro Lauf angepasst
---
## Konfiguration
```
INCOMING="/mnt/user/Cache/Picard/incoming"
READY="/mnt/user/Cache/Picard/ready"
DEST1="/mnt/user/Cache/Syncthing/..."
DEST2="/mnt/user/Media/Musik/..."
LOG="/mnt/user/Cache/Picard/sync.log"
LOCKFILE="/mnt/user/Cache/Picard/sync.lock"
DRY_RUN=false
CHOWN_USER="thorsten"
CHOWN_GROUP="users"
```
---
## Dry Run
```
DRY_RUN=true
```
- keine Änderungen am Dateisystem
- rsync läuft mit --dry-run
- Aktionen werden geloggt
---
## Cron Beispiel
```
0 */6 * * *
```
---
## Hinweise
- Script beendet sich sofort, wenn keine Dateien vorhanden sind
- `ready/` wird nur bei Bedarf erstellt und kann wieder verschwinden
- Rechteproblem bei CIFS wird durch chown gelöst
---
## Debug
```
cat /mnt/user/Cache/Picard/sync.log
ls /mnt/user/Cache/Picard
```

View File

@@ -0,0 +1,200 @@
#!/bin/bash
# ----------------------------------------
# CONFIG
# ----------------------------------------
INCOMING="/mnt/user/Cache/Picard/incoming"
READY="/mnt/user/Cache/Picard/ready"
DEST1="/mnt/user/Cache/Syncthing/Julian/Musik/Thorsten -> Julian"
DEST2="/mnt/user/Media/Musik/[0] Navidrome"
LOG="/mnt/user/Cache/Picard/sync.log"
LOCKFILE="/mnt/user/Cache/Picard/sync.lock"
LOG_LINES=5000
DRY_RUN=false
CHOWN_USER="thorsten"
CHOWN_GROUP="users"
# ----------------------------------------
# NORMALIZE PATHS
# ----------------------------------------
normalize_path() {
local p="$1"
[[ "$p" != "/" ]] && p="${p%/}"
echo "$p"
}
INCOMING="$(normalize_path "$INCOMING")"
READY="$(normalize_path "$READY")"
DEST1="$(normalize_path "$DEST1")"
DEST2="$(normalize_path "$DEST2")"
# ----------------------------------------
# FUNCTIONS
# ----------------------------------------
log() {
MSG="$(date '+%Y-%m-%d %H:%M:%S') | $1"
echo "$MSG"
echo "$MSG" >> "$LOG"
}
rotate_log() {
if [ -f "$LOG" ]; then
tail -n "$LOG_LINES" "$LOG" > "${LOG}.tmp" && mv "${LOG}.tmp" "$LOG"
fi
}
run_cmd() {
if [ "$DRY_RUN" = true ]; then
log "[DRY-RUN] $*"
else
"$@"
fi
}
rsync_cmd() {
if [ "$DRY_RUN" = true ]; then
rsync -a --dry-run "$@"
return 0
else
rsync -a "$@"
return $?
fi
}
# ----------------------------------------
# VERIFY DESTINATIONS
# ----------------------------------------
for DEST in "$DEST1" "$DEST2"; do
if [ ! -d "$DEST" ]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') | ERROR: Destination not found -> $DEST"
exit 1
fi
done
# ----------------------------------------
# LOCKFILE
# ----------------------------------------
if [ -f "$LOCKFILE" ]; then
echo "Lockfile exists -> $LOCKFILE"
exit 1
fi
trap "rm -f '$LOCKFILE'" EXIT
touch "$LOCKFILE"
# ----------------------------------------
# QUICK EXIT
# ----------------------------------------
if ! find "$INCOMING" -type f -mmin +1 -print -quit | grep -q .; then
echo "$(date '+%Y-%m-%d %H:%M:%S') | No files to process -> exit"
exit 0
fi
# ----------------------------------------
# START
# ----------------------------------------
rotate_log
log "========================================"
log "START RUN (DRY_RUN=$DRY_RUN)"
log "========================================"
# ----------------------------------------
# TRACK CHOWNED DIRECTORIES
# ----------------------------------------
declare -A CHOWN_DONE_DIRS
# ----------------------------------------
# 1. INCOMING → READY
# ----------------------------------------
log "---- MOVE: INCOMING → READY ----"
find "$INCOMING" -type f -mmin +1 | while read -r FILE; do
[ -f "$FILE" ] || continue
REL="${FILE#"$INCOMING"/}"
TARGET="$READY/$REL"
mkdir -p "$READY/$(dirname "$REL")"
log "MOVE -> $REL"
run_cmd mv "$FILE" "$TARGET"
done
# ----------------------------------------
# 2. READY → DESTS
# ----------------------------------------
log "---- SYNC: READY → DESTINATIONS ----"
log "DEST1: $DEST1"
log "DEST2: $DEST2"
find "$READY" -type f 2>/dev/null | while read -r FILE; do
[ -f "$FILE" ] || continue
REL="${FILE#"$READY"/}"
DEST1_FILE="$DEST1/$REL"
DEST2_FILE="$DEST2/$REL"
mkdir -p "$(dirname "$DEST1_FILE")"
mkdir -p "$(dirname "$DEST2_FILE")"
log "SYNC -> $REL"
rsync_cmd "$FILE" "$DEST1_FILE"
STATUS1=$?
rsync_cmd "$FILE" "$DEST2_FILE"
STATUS2=$?
if [[ $STATUS1 -eq 0 && $STATUS2 -eq 0 ]]; then
log "SET OWNER -> $REL"
# Datei
run_cmd chown "$CHOWN_USER:$CHOWN_GROUP" "$DEST1_FILE"
run_cmd chown "$CHOWN_USER:$CHOWN_GROUP" "$DEST2_FILE"
# Ordner nur einmal behandeln
DIR1="$(dirname "$DEST1_FILE")"
DIR2="$(dirname "$DEST2_FILE")"
if [[ -z "${CHOWN_DONE_DIRS[$DIR1]}" ]]; then
run_cmd chown "$CHOWN_USER:$CHOWN_GROUP" "$DIR1"
CHOWN_DONE_DIRS[$DIR1]=1
fi
if [[ -z "${CHOWN_DONE_DIRS[$DIR2]}" ]]; then
run_cmd chown "$CHOWN_USER:$CHOWN_GROUP" "$DIR2"
CHOWN_DONE_DIRS[$DIR2]=1
fi
log "OK -> delete $REL"
run_cmd rm "$FILE"
else
log "ERROR -> $REL (dest1=$STATUS1 dest2=$STATUS2)"
fi
done
# ----------------------------------------
# CLEANUP
# ----------------------------------------
if [ "$DRY_RUN" != true ]; then
find "$INCOMING" -mindepth 1 -type d -empty -delete
find "$READY" -mindepth 1 -type d -empty -delete 2>/dev/null
else
log "[DRY-RUN] Skipping cleanup"
fi
log "========================================"
log "END RUN"
log "========================================"