Moin zusammen, ich habe für meinen eigenen Unraid-Server ein kleines Bash-Skript entwickelt, das Bilder und Videos automatisch sortiert. Da es eventuell auch für andere nützlich sein könnte, stelle ich die aktuelle Version 1.0.6 hier als Beta-/Testversion zur Verfügung. Das Skript bietet unter anderem: Copy- und Move-Modus Sortierung nach Kategorie, Medientyp, Jahr und Monat alternativ nur Jahr oder eine flache Ordnerstruktur Datumsermittlung über Metadaten, Dateinamen und Dateisystemdaten SHA-256-basierte Duplikaterkennung persistente oder temporäre Datenbank schneller Source-State-Abgleich im Copy-Modus Watch-Modus über Inotify oder Polling Dry-Run für Testläufe abgesicherte Übertragung über temporäre Dateien und Hash-Prüfung Recovery-Funktion nach Abbruch oder Stromausfall optionale Rechtekorrektur und Bereinigung leerer Ordner Das Skript wurde für meinen eigenen Anwendungsfall entwickelt und auf meinem System ausführlich getestet. Andere Ordnerstrukturen, Dateisysteme, Rechtekonfigurationen oder ungewöhnliche Mediendateien können sich jedoch anders verhalten. Bitte unbedingt beachten: Vor der ersten Verwendung ein unabhängiges Backup erstellen. Zuerst mit DRY_RUN="on" und Kopien in separaten Testordnern ausprobieren. Alle Pfade und Einstellungen sorgfältig kontrollieren. Den Move-Modus niemals direkt mit ungesicherten Originaldateien testen. Die Verwendung erfolgt vollständig auf eigene Verantwortung. Der schnelle Source-State-Abgleich verwendet im Copy-Modus den vollständigen Quellpfad, die Dateigröße und den mtime-Zeitstempel. Neue oder erkennbar veränderte Dateien werden vollständig per SHA-256 geprüft. Wird eine Datei jedoch gezielt so ersetzt, dass Pfad, Größe und mtime identisch bleiben, kann der Schnellvergleich diese Manipulation nicht erkennen. Wichtiger Hinweis zum Support: Ich stelle das Skript so zur Verfügung, wie ich es selbst verwende, kann dafür aber keinen Support anbieten. Das betrifft insbesondere Hilfe bei Installation und Einrichtung, die Prüfung fremder Konfigurationen, individuelle Anpassungen sowie die Fehleranalyse auf anderen Systemen. Auch regelmäßige Updates oder die Umsetzung von Änderungswünschen kann ich nicht versprechen. Erfahrungsberichte und Hinweise dürfen natürlich trotzdem gerne im Thema geteilt werden – bitte jedoch ohne die Erwartung, dass ich jeden Fall untersuchen oder eine angepasste Version erstellen kann. Im Anhang befindet sich die aktuelle Skriptversion. Bitte vor der Verwendung zunächst den Einstellungsbereich vollständig durchgehen. #!/bin/bash
#backgroundOnly=true
#arrayStarted=true
#noParity=true
# Version: 1.0.6
# Medien-Sortierer
# Ausgelegt für Unraid, Raspberry Pi OS, Fedora und ähnliche Linux-Systeme.
# Es werden keine Pakete installiert und keine Systemeinstellungen verändert.
START_TIME=$(date +%s)
START_TIME_TEXT=$(date '+%d.%m.%Y %H:%M:%S')
SCRIPT_PATH="${BASH_SOURCE[0]}"
# ---------------------------------------------
# EINSTELLUNGEN
# ---------------------------------------------
# Nur dieser Bereich ist für die normale Einrichtung gedacht.
# Alle Werte werden vor dem ersten Zugriff auf SRC, DEST oder Datenbanken geprüft.
#
# Empfohlener erster Test nach einer Änderung:
# DRY_RUN auf "on" setzen
# WATCH_MODE auf "off" setzen
# Danach Log, Quelle, Zielstruktur und Zähler kontrollieren.
# ===== 1. QUELLE UND ZIEL =====
# Ordner mit den neu zu verarbeitenden Dateien.
# Der Quellordner muss bereits existieren. Fehlt er, bricht das Script ab.
SRC="/mnt/user/Der/Eingang"
# Zielordner der sortierten Dateien.
# Der Zielordner muss bereits existieren. Fehlt er, bricht das Script ab.
# Eingehängte Unter-Dateisysteme unter SRC oder DEST werden aus Sicherheitsgründen
# nicht rekursiv verarbeitet oder bereinigt.
DEST="/mnt/user/dein/Ziel"
# ===== 2. GRUNDMODUS =====
# move = Datei sicher ins Ziel kopieren, vollständig prüfen und synchronisieren;
# erst danach die unveränderte Quelle löschen.
# copy = Datei sicher ins Ziel kopieren und die Quelle unverändert lassen.
PROCESS_MODE="move"
# on = Testlauf. Keine Nutzdaten, Zielordner, Datenbanken oder Statusdateien ändern.
# Nur der Instanz-Lock und temporäre Laufzeitdateien unter /tmp sind erlaubt.
# off = echter Lauf.
DRY_RUN="off"
# auto = bei move Duplikate speichern, bei copy Duplikate überspringen
# store = Duplikate immer speichern
# skip = Duplikate immer überspringen
DUPLICATE_ACTION="auto"
# ===== 3. ZIELSTRUKTUR =====
# Die erste Ebene ist immer die Formatgruppe, die zweite Ebene der Medientyp:
# COMPATIBLE/BILDER = jpg, jpeg, png
# COMPATIBLE/VIDEOS = mp4, avi, mkv, mpg, mpeg
# APPLE/BILDER = heic, heif, dng, raw
# APPLE/VIDEOS = mov, m4v
# OTHER/BILDER = webp, gif, bmp, tif, tiff
# OTHER/VIDEOS = 3gp, 3gpp, webm
# DUPLICATES/BILDER = gespeicherte Bild-Duplikate
# DUPLICATES/VIDEOS = gespeicherte Video-Duplikate
#
# year_month = Kategorie/Medientyp/Jahr/Monat,
# z. B. APPLE/VIDEOS/2026/07
# year = Kategorie/Medientyp/Jahr,
# z. B. APPLE/VIDEOS/2026
# flat = Kategorie/Medientyp ohne Datumsordner,
# z. B. APPLE/VIDEOS
SORT_STRUCTURE="year_month"
# ===== 4. DATUM UND EXIFTOOL =====
# auto = zuerst EXIFTOOL_PATH prüfen, danach exiftool aus PATH verwenden
# system = ausschließlich exiftool aus PATH verwenden
# path = ausschließlich EXIFTOOL_PATH verwenden
# ExifTool ist zwingend. Bei einem Fehler wird vor jeder Verarbeitung abgebrochen.
EXIFTOOL_MODE="auto"
EXIFTOOL_PATH="/mnt/user/das/EXIFTOOL/mussExtraRuntergeladenWerden"
# ===== 5. DUPLIKAT- UND DATENBANKPRÜFUNG =====
# on = Dateigröße als schneller Vorfilter; SHA256 bleibt die finale Prüfung
# off = SHA256 für jede Eingabedatei sofort berechnen
FAST_MODE="on"
# on = den Zielbestand in genau einem schnellen Durchgang prüfen. Dabei werden
# relativer Speicherort/Dateiname und Dateigröße mit der DB verglichen.
# Nur auffällige Dateien werden mit ExifTool und SHA256 tief geprüft.
# Verwaiste .part-Dateien werden in demselben Durchgang erkannt.
# In copy und move werden Pfad und Größe – sofern vom installierten find
# unterstützt – gemeinsam eingesammelt. So entfällt der separate stat-Aufruf
# pro Zieldatei. Ohne diese find-Funktion gilt automatisch der bisherige Weg.
# off = keine allgemeine Zielprüfung; Recovery-Marker und .part-Dateien werden
# trotzdem gezielt geprüft.
QUICK_DEST_CHECK="on"
# size = schneller Vergleich von Pfad/Name, Größe und Zielstruktur
# hash = zusätzlich jede bekannte Zieldatei vollständig mit SHA256 prüfen
# hash erkennt auch Inhaltsänderungen bei gleicher Größe, liest aber jede Datei komplett.
DEST_CHECK_LEVEL="size"
# persistent = Hash-DB, Journal und Source-State dauerhaft in DEST speichern
# Die Verarbeitung von Medieninhalten und rekursiven Dateilisten erfolgt streamend.
# Im RAM liegen nur kompakte Zuordnungen für Hash, Pfad und Größe; keine Dateiinhalte
# und keine vollständigen rekursiven Dateilisten.
# temporary = Datenbanken nur für den aktuellen Lauf unter /tmp aufbauen;
# vorhandene persistente DB-Dateien bleiben unberührt
DATABASE_MODE="persistent"
# on = im copy-Modus unveränderte, bereits bekannte Quellen überspringen
# off = jede Quelle erneut prüfen
# Der schnelle Vergleich verwendet ausschließlich den exakten vollständigen
# Quellpfad einschließlich Dateiname, die Dateigröße und mtime. Stimmen alle drei
# Werte und die gespeicherte SHA256-Zielzuordnung, ist kein Inhalts-Hash nötig.
# Weicht ein Wert ab oder fehlt die sichere Zielzuordnung, folgt automatisch die
# normale Verarbeitung mit vollständigem SHA256-Hash. ctime und Datei-Identität
# bleiben für das abwärtskompatible Dateiformat gespeichert, lösen allein aber
# weder einen Hash noch ein Neuschreiben der Source-State-Datei aus.
# Im move-Modus wird diese Funktion automatisch deaktiviert.
# Eindeutig verwaiste .source_state.tmp.XXXXXX-Dateien werden bei aktivem Lock
# sicher erkannt und vor dem nächsten Lauf automatisch entfernt.
SOURCE_STATE_CACHE="on"
# ===== 6. WATCH-MODUS =====
# on = SRC dauerhaft überwachen und nach einer Ruhezeit sortieren
# off = genau einen Sortierlauf ausführen und danach beenden
WATCH_MODE="on"
# inotify = empfohlene Voreinstellung; keine regelmäßigen Verzeichnisscans
# poll = regelmäßige Scans; kann Festplatten wach halten oder aufwecken
# auto = inotify verwenden, sonst automatisch auf poll zurückfallen
# ACHTUNG: auto kann dadurch unbemerkt Polling aktivieren.
# Es wird niemals etwas installiert oder am System verändert.
WATCH_BACKEND="inotify"
# Startverzögerung nur für den äußeren Watcher. Bei WATCH_MODE=off sowie bei
# internen Sortierläufen des Watchers wird sie vollständig ignoriert.
# 0 = deaktiviert.
START_DELAY_SECONDS=15
# Nach dem letzten erkannten Schreib-/Verschiebeereignis so lange warten.
# 0 = Sortierlauf unmittelbar nach dem Ereignis starten.
WATCH_IDLE_SECONDS=120
# on = beim Watcher-Start bereits vorhandene Dateien einmal verarbeiten
# off = ausschließlich auf danach eintreffende Dateien reagieren
# Offene Recovery-Marker oder ein vorhandenes Journal starten unabhängig davon
# sofort einen Worker, damit auch ein nach dem Move bereits leerer Input repariert wird.
WATCH_PROCESS_EXISTING="on"
# Prüfintervall bei WATCH_BACKEND=poll. Mindestwert: 1 Sekunde.
WATCH_POLL_SECONDS=5
# ===== 7. AUFRÄUMEN =====
# Nur leere Unterordner entfernen; SRC und DEST selbst werden niemals gelöscht.
# Im copy-Modus wird SRC grundsätzlich nicht bereinigt.
DELETE_EMPTY_SRC_DIRS="on"
DELETE_EMPTY_DEST_DIRS="on"
# ===== 8. RECHTE =====
# on = Rechte ausschließlich auf Dateien und Unterordner innerhalb von DEST anwenden.
# DEST selbst wird weder mit chmod noch mit chown verändert.
# off = keine Rechte gezielt durch das Script ändern.
SET_PERMISSIONS="on"
# on = am Ende jedes echten Sortierlaufs den gesamten vorhandenen Inhalt unterhalb
# von DEST rekursiv auf die unten eingestellten Rechte bringen. Das ist unter Unraid
# sinnvoll, wenn über SMB sonst einzelne Dateien wegen abweichender Owner-
# oder Moduswerte nicht geöffnet beziehungsweise verändert werden können.
#
# Sicherheits- und RAM-Verhalten:
# - ausschließlich unterhalb von DEST; DEST selbst bleibt unverändert
# - bleibt mit -xdev auf demselben Dateisystem
# - folgt keinen symbolischen Links
# - durchläuft den Zielbaum einmal, arbeitet mit find -exec in kleinen
# Stapeln und lädt keine vollständige Dateiliste in den Arbeitsspeicher
#
# off = nur neu angelegte Dateien und Ordner korrigieren.
# Diese Einstellung hat nur Wirkung, wenn SET_PERMISSIONS="on" ist.
RECURSIVE_PERMISSION_FIX="on"
# Unter Unraid üblich: nobody:users. Für andere Linux-Systeme kann der Wert
# leer gelassen werden; dann wird kein chown ausgeführt.
PERMISSION_OWNER="nobody:users"
PERMISSION_DIR_MODE="777"
PERMISSION_FILE_MODE="666"
# Die wirksame umask wird intern automatisch aus den beiden Modi berechnet.
# Beispiele: 777/666 -> 000, 775/664 -> 002, 750/640 -> 027.
# Beide Modi müssen mit derselben umask darstellbar sein.
# ===== 9. SCHUTZ UND SYNCHRONISIERUNG =====
# on = parallele Läufe mit demselben Ziel verhindern
# off = kein Lock; nicht empfohlen
ENABLE_LOCK="on"
# on = Temp-Datei, Ziel, Transaktionsmarker und Verzeichnisse synchronisieren,
# bevor bei move die Quelle gelöscht wird
# off = zusätzliche Sync-Schritte deaktivieren; reduziert Stromausfallschutz
RUN_SYNC="on"
# Freier Platz, der zusätzlich zur aktuellen Dateigröße im Ziel verbleiben muss.
# 0 = Prüfung deaktivieren. Wert in MiB.
MIN_FREE_SPACE_MB=256
# ---------------------------------------------
# INTERNE DATEIEN
# Diese Namen nicht auf andere Dateisysteme außerhalb von DEST umbiegen.
# ---------------------------------------------
REAL_HASH_DB="$DEST/.hashes.db"
REAL_JOURNAL_DB="$DEST/.hashes.journal"
REAL_SOURCE_STATE_DB="$DEST/.source_state.db"
RECOVERY_DIR="$DEST/.medien_sortierer_recovery"
RECOVERY_QUARANTINE_DIR="$RECOVERY_DIR/quarantine"
ZIP_REPORT_FILE="$DEST/Uebersprungene_ZIP-Dateien.txt"
STATUS_RUN_FILE="$DEST/RUN.txt"
STATUS_ERROR_FILE="$DEST/ERROR.txt"
STATUS_FINISH_FILE="$DEST/FINISH.txt"
# ---------------------------------------------
# ZÄHLER UND LAUFSTATUS
# ---------------------------------------------
COUNT_ORIGINAL=0
COUNT_DUPLICATE=0
COUNT_DUPLICATE_STORED=0
COUNT_DUPLICATE_SKIPPED=0
COUNT_UNKNOWN=0
COUNT_SKIPPED=0
COUNT_ZIP_SKIPPED=0
COUNT_DB_REMOVED=0
COUNT_REBUILD_ADDED=0
COUNT_REBUILD_DUPES=0
COUNT_REBUILD_FIXED=0
COUNT_TRANSFER_FAILED=0
COUNT_JOURNAL_RECOVERED=0
COUNT_RECOVERY_COMPLETED=0
COUNT_RECOVERY_REPAIRED=0
COUNT_RECOVERY_FAILED=0
COUNT_RECOVERY_QUARANTINED=0
COUNT_DEST_SCANNED=0
COUNT_DEST_UNKNOWN=0
COUNT_DEST_SIZE_MISMATCH=0
COUNT_DEST_STRUCTURE_MISMATCH=0
COUNT_DEST_PROCESSED=0
COUNT_DEST_HASH_MISMATCH=0
COUNT_UNSUPPORTED_NAMES=0
COUNT_SOURCE_STATE_LOADED=0
COUNT_SOURCE_STATE_SKIPPED=0
COUNT_SOURCE_STATE_WRITTEN=0
COUNT_SOURCE_STATE_REMOVED=0
COUNT_SOURCE_STATE_INVALID=0
DB_REBUILD=0
DB_REBUILD_REASON="Nein"
SOURCE_STATE_CACHE_EFFECTIVE="off"
EFFECTIVE_DUPLICATE_ACTION=""
SCRIPT_HAD_ERROR=0
LAST_ERROR_MESSAGE=""
SCRIPT_COMPLETED=0
STATUS_TRACKING_ACTIVE=0
LOCK_OWNED=0
LOCK_DIR=""
WATCH_CHILD_PID=""
ACTIVE_TRANSFER_PID=""
ACTIVE_WAIT_PID=""
WATCH_MONITOR_PID=""
WATCH_EVENT_FILE=""
WATCH_BACKEND_EFFECTIVE="off"
CURRENT_UID=$(id -u 2>/dev/null || echo "")
CURRENT_GID=$(id -g 2>/dev/null || echo "")
SRC_CANONICAL=""
DEST_CANONICAL=""
PERMISSION_UMASK=""
TMP_ROOT=""
DB_FILE=""
JOURNAL_FILE=""
SOURCE_STATE_FILE=""
SOURCE_STATE_NEW=""
FILE_LIST=""
INPUT_FILE_LIST=""
ZIP_PATH_LIST=""
EXIFTOOL=""
EXIFTOOL_VERSION=""
HASH_RESULT=""
FILE_SNAPSHOT_SIZE=""
FILE_SNAPSHOT_MTIME=""
FILE_SNAPSHOT_CTIME=""
FILE_SNAPSHOT_DEVINO=""
SOURCE_STATE_CURRENT_KEY=""
TARGET_DIR_RESULT=""
UNIQUE_TARGET_RESULT=""
DB_LOOKUP_HASH=""
DB_LOOKUP_FILE=""
DB_DIRTY=0
DB_RECORD_COUNT=0
JOURNAL_NEEDS_CLEAR=0
ATOMIC_PUBLISH_VERIFIED=0
SOURCE_STATE_DIRTY=0
SOURCE_STATE_FILE_EXISTED=0
# Interner Worker eines Watchers:
# - keine zweite Startverzögerung
# - kein zweiter Lock
# - immer genau ein Sortierlauf
INTERNAL_WATCH_WORKER="${MEDIEN_SORTIERER_WORKER:-0}"
EXTERNAL_LOCK_HELD="${MEDIEN_SORTIERER_LOCK_HELD:-0}"
if [[ "$INTERNAL_WATCH_WORKER" == "1" ]]; then
WATCH_MODE_EFFECTIVE="off"
START_DELAY_EFFECTIVE=0
else
WATCH_MODE_EFFECTIVE="$WATCH_MODE"
if [[ "$WATCH_MODE" == "on" ]]; then
START_DELAY_EFFECTIVE="$START_DELAY_SECONDS"
else
# Bei einem normalen Einmallauf ergibt eine Watcher-Startverzögerung keinen Sinn.
START_DELAY_EFFECTIVE=0
fi
fi
# ---------------------------------------------
# LOGGING
# ---------------------------------------------
log_info() { printf "[INFO] %s\n" "$1" >&2; }
log_ok() { printf "[OK] %s\n" "$1" >&2; }
log_warn() { printf "[WARN] %s\n" "$1" >&2; }
log_error() {
SCRIPT_HAD_ERROR=1
LAST_ERROR_MESSAGE="$1"
printf "[FEHLER] %s\n" "$1" >&2
}
mark_runtime_error() {
SCRIPT_HAD_ERROR=1
LAST_ERROR_MESSAGE="$1"
log_warn "$1"
}
# ---------------------------------------------
# GRUNDHILFEN
# ---------------------------------------------
is_on_off() {
case "$1" in
on|off) return 0 ;;
*) return 1 ;;
esac
}
check_on_off_setting() {
local name="$1"
local value="$2"
if ! is_on_off "$value"; then
log_error "$name muss on oder off sein"
exit 1
fi
}
is_uint() {
[[ "$1" =~ ^[0-9]+$ ]]
}
valid_hash() {
[[ "$1" =~ ^[a-fA-F0-9]{64}$ ]]
}
valid_size() {
[[ "$1" =~ ^[0-9]+$ ]]
}
valid_time_number() {
[[ "$1" =~ ^[0-9]+$ ]]
}
valid_stat_time_number() {
[[ "$1" =~ ^-?[0-9]+$ ]]
}
valid_devino() {
[[ "$1" =~ ^[0-9]+:[0-9]+$ ]]
}
valid_calendar_datetime_components() {
local year_text="$1"
local month_text="$2"
local day_text="$3"
local hour_text="$4"
local minute_text="$5"
local second_text="$6"
local year=0
local month=0
local day=0
local hour=0
local minute=0
local second=0
local max_day=0
[[ "$year_text" =~ ^[0-9]{4}$ ]] || return 1
[[ "$month_text" =~ ^[0-9]{2}$ ]] || return 1
[[ "$day_text" =~ ^[0-9]{2}$ ]] || return 1
[[ "$hour_text" =~ ^[0-9]{2}$ ]] || return 1
[[ "$minute_text" =~ ^[0-9]{2}$ ]] || return 1
[[ "$second_text" =~ ^[0-9]{2}$ ]] || return 1
year=$((10#$year_text))
month=$((10#$month_text))
day=$((10#$day_text))
hour=$((10#$hour_text))
minute=$((10#$minute_text))
second=$((10#$second_text))
(( month >= 1 && month <= 12 )) || return 1
(( hour <= 23 && minute <= 59 && second <= 59 )) || return 1
case "$month" in
1|3|5|7|8|10|12)
max_day=31
;;
4|6|9|11)
max_day=30
;;
2)
max_day=28
if (( year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) )); then
max_day=29
fi
;;
esac
(( day >= 1 && day <= max_day ))
}
valid_datetime_value() {
local value="$1"
[[ "$value" == "UNKNOWN" ]] && return 0
if [[ "$value" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})_([0-9]{3})$ ]]; then
valid_calendar_datetime_components \
"${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" \
"${BASH_REMATCH[4]}" "${BASH_REMATCH[5]}" "${BASH_REMATCH[6]}"
return $?
fi
return 1
}
path_entry_exists() {
[[ -e "$1" || -L "$1" ]]
}
has_unsupported_record_chars() {
case "$1" in
*'|'*|*$'\n'*|*$'\r'*) return 0 ;;
*) return 1 ;;
esac
}
media_path_supported() {
local file="$1"
local readable=""
if has_unsupported_record_chars "$file"; then
printf -v readable '%q' "$file"
COUNT_UNSUPPORTED_NAMES=$((COUNT_UNSUPPORTED_NAMES + 1))
log_warn "Datei bleibt unverändert, weil ihr Pfad | oder einen Zeilenumbruch enthält: $readable"
return 1
fi
return 0
}
canonicalize_path() {
local input_path="$1"
if command -v realpath >/dev/null 2>&1; then
realpath -m -- "$input_path" 2>/dev/null
elif command -v readlink >/dev/null 2>&1; then
readlink -m -- "$input_path" 2>/dev/null
else
return 1
fi
}
# Prüft gespeicherte absolute Pfade ohne einen eigenen realpath-Prozess pro
# Datensatz. Die Prüfung ist der kanonischen Auflösung gleichwertig: Der Pfad
# muss direkt unterhalb des bereits kanonisierten Wurzelpfads liegen, darf keine
# leeren, .- oder ..-Komponenten enthalten und keine Symlink-Komponente benutzen.
record_path_is_canonical_within() {
local file="$1"
local root="$2"
local relative=""
local current="$root"
local component=""
local index=0
local last_index=0
local old_ifs=""
local -a components=()
[[ -n "$file" && -n "$root" ]] || return 1
has_unsupported_record_chars "$file" && return 1
[[ "$file" == "$root/"* ]] || return 1
relative="${file#"$root"/}"
[[ -n "$relative" && "$relative" != */ && "$relative" != *//* ]] || return 1
old_ifs="$IFS"
IFS='/'
read -r -a components <<< "$relative"
IFS="$old_ifs"
(( ${#components[@]} > 0 )) || return 1
last_index=$((${#components[@]} - 1))
for index in "${!components[@]}"
do
component="${components[$index]}"
[[ -n "$component" && "$component" != "." && "$component" != ".." ]] || return 1
current="$current/$component"
[[ ! -L "$current" ]] || return 1
if (( index < last_index )) && path_entry_exists "$current"; then
[[ -d "$current" ]] || return 1
fi
done
[[ "$current" == "$file" ]]
}
path_is_within() {
local child="$1"
local parent="$2"
[[ "$child" == "$parent" || "$child" == "$parent/"* ]]
}
update_internal_paths() {
REAL_HASH_DB="$DEST/.hashes.db"
REAL_JOURNAL_DB="$DEST/.hashes.journal"
REAL_SOURCE_STATE_DB="$DEST/.source_state.db"
RECOVERY_DIR="$DEST/.medien_sortierer_recovery"
RECOVERY_QUARANTINE_DIR="$RECOVERY_DIR/quarantine"
ZIP_REPORT_FILE="$DEST/Uebersprungene_ZIP-Dateien.txt"
STATUS_RUN_FILE="$DEST/RUN.txt"
STATUS_ERROR_FILE="$DEST/ERROR.txt"
STATUS_FINISH_FILE="$DEST/FINISH.txt"
}
validate_permission_mode() {
[[ "$1" =~ ^[0-7]{3,4}$ ]]
}
calculate_permission_umask() {
local dir_mode="${PERMISSION_DIR_MODE: -3}"
local file_mode="${PERMISSION_FILE_MODE: -3}"
local dir_value=0
local file_value=0
local umask_value=0
local expected_file_value=0
dir_value=$((8#$dir_mode))
file_value=$((8#$file_mode))
umask_value=$((0777 & ~dir_value))
expected_file_value=$((0666 & ~umask_value))
(( file_value == expected_file_value )) || return 1
printf -v PERMISSION_UMASK '%03o' "$umask_value"
return 0
}
apply_file_permissions() {
local file="$1"
[[ "$SET_PERMISSIONS" == "on" && -e "$file" && ! -L "$file" ]] || return 0
[[ "$file" == "$DEST/"* ]] || return 0
if [[ -n "$PERMISSION_OWNER" ]]; then
chown -- "$PERMISSION_OWNER" "$file" 2>/dev/null || \
log_warn "Owner konnte nicht gesetzt werden: $file"
fi
chmod -- "$PERMISSION_FILE_MODE" "$file" 2>/dev/null || \
log_warn "Dateirechte konnten nicht gesetzt werden: $file"
}
apply_dir_permissions() {
local dir="$1"
[[ "$SET_PERMISSIONS" == "on" && -d "$dir" && ! -L "$dir" ]] || return 0
[[ "$dir" == "$DEST/"* ]] || return 0
if [[ -n "$PERMISSION_OWNER" ]]; then
chown -- "$PERMISSION_OWNER" "$dir" 2>/dev/null || \
log_warn "Owner konnte nicht gesetzt werden: $dir"
fi
chmod -- "$PERMISSION_DIR_MODE" "$dir" 2>/dev/null || \
log_warn "Ordnerrechte konnten nicht gesetzt werden: $dir"
}
apply_recursive_permissions() {
local permission_failed=0
[[ "$SET_PERMISSIONS" == "on" && "$RECURSIVE_PERMISSION_FIX" == "on" ]] || return 0
[[ "$DRY_RUN" == "off" ]] || return 0
[[ -d "$DEST" && ! -L "$DEST" ]] || return 1
[[ "$(canonicalize_path "$DEST")" == "$DEST" ]] || return 1
log_info "Korrigiere Rechte innerhalb des Zielordners für den SMB-Zugriff"
# Ein streamender find-Durchlauf behandelt Ordner und Dateien mit getrennten
# Modi. DEST selbst wird mit -mindepth 1 ausdrücklich ausgeschlossen. Owner
# und Modus werden weiterhin pro kleinem Stapel gemeinsam gesetzt.
find "$DEST" -xdev -mindepth 1 \
\( -type d -exec sh -c '
owner=$1
mode=$2
shift 2
rc=0
if [ -n "$owner" ]; then
chown -- "$owner" "$@" || rc=1
fi
chmod -- "$mode" "$@" || rc=1
exit "$rc"
' sh "$PERMISSION_OWNER" "$PERMISSION_DIR_MODE" {} + \) -o \
\( -type f -exec sh -c '
owner=$1
mode=$2
shift 2
rc=0
if [ -n "$owner" ]; then
chown -- "$owner" "$@" || rc=1
fi
chmod -- "$mode" "$@" || rc=1
exit "$rc"
' sh "$PERMISSION_OWNER" "$PERMISSION_FILE_MODE" {} + \) \
2>/dev/null || permission_failed=1
if [[ "$permission_failed" -ne 0 ]]; then
log_warn "Mindestens ein Owner oder Dateimodus innerhalb des Zielordners konnte nicht angepasst werden"
return 1
fi
log_ok "Rechte innerhalb des Zielordners rekursiv korrigiert"
return 0
}
ensure_safe_directory() {
local dir="$1"
local relative=""
local current="$DEST"
local component=""
local old_ifs=""
local -a dir_parts=()
dir="${dir%/}"
path_is_within "$dir" "$DEST" || return 1
has_unsupported_record_chars "$dir" && return 1
if [[ "$dir" == "$DEST" ]]; then
[[ -d "$DEST" && ! -L "$DEST" ]]
return
fi
relative="${dir#"$DEST"/}"
old_ifs="$IFS"
IFS='/'
read -r -a dir_parts <<< "$relative"
IFS="$old_ifs"
for component in "${dir_parts[@]}"
do
[[ -n "$component" && "$component" != "." && "$component" != ".." ]] || return 1
current="$current/$component"
if path_entry_exists "$current"; then
[[ -d "$current" && ! -L "$current" ]] || return 1
else
mkdir -- "$current" || return 1
apply_dir_permissions "$current"
fi
done
[[ "$(canonicalize_path "$current")" == "$current" ]]
}
safe_parent_directory_for_target() {
local target_file="$1"
local parent=""
path_is_within "$target_file" "$DEST" || return 1
parent="${target_file%/*}"
ensure_safe_directory "$parent"
}
validate_control_paths() {
local control_path=""
for control_path in \
"$REAL_HASH_DB" "$REAL_JOURNAL_DB" "$REAL_SOURCE_STATE_DB" \
"$ZIP_REPORT_FILE" "$STATUS_RUN_FILE" "$STATUS_ERROR_FILE" "$STATUS_FINISH_FILE" \
"$RECOVERY_DIR" "$RECOVERY_QUARANTINE_DIR"
do
if [[ -L "$control_path" ]]; then
log_error "Sicherheitsabbruch: interner Pfad ist ein symbolischer Link: $control_path"
return 1
fi
path_is_within "$(canonicalize_path "$control_path")" "$DEST" || {
log_error "Sicherheitsabbruch: interner Pfad liegt außerhalb von DEST: $control_path"
return 1
}
done
return 0
}
safe_remove_runtime_dir() {
local dir="$1"
if [[ -z "$dir" ]]; then
return 0
fi
if [[ "$dir" =~ ^/tmp/medien_sortierer\.[A-Za-z0-9]+$ ]] || \
[[ "$dir" =~ ^/tmp/medien_sortierer_[a-fA-F0-9]{64}\.lockdir$ ]]; then
rm -rf -- "$dir" 2>/dev/null || true
return 0
fi
log_warn "Unsicherer Cleanup-Pfad wurde nicht entfernt: $dir"
return 1
}
check_free_space_for_file() {
local target_dir="$1"
local file_size="$2"
local df_output=""
local available_kib=""
local required_kib=""
local -a df_lines=()
(( MIN_FREE_SPACE_MB > 0 )) || return 0
valid_size "$file_size" || return 1
df_output=$(df -Pk -- "$target_dir" 2>/dev/null) || return 1
mapfile -t df_lines <<< "$df_output"
(( ${#df_lines[@]} >= 2 )) || return 1
IFS=' ' read -r _ _ _ available_kib _ <<< "${df_lines[1]}"
valid_size "$available_kib" || return 1
required_kib=$(( (file_size + 1023) / 1024 + MIN_FREE_SPACE_MB * 1024 ))
(( available_kib >= required_kib ))
}
verify_atomic_publish_support() {
local test_dir="${1:-$DEST}"
local test_source=""
local test_target=""
[[ "$DRY_RUN" == "off" ]] || return 0
[[ "$ATOMIC_PUBLISH_VERIFIED" -eq 0 ]] || return 0
[[ -d "$test_dir" && ! -L "$test_dir" ]] || return 1
path_is_within "$(canonicalize_path "$test_dir")" "$DEST" || return 1
test_source=$(mktemp "$test_dir/.medien-linktest.XXXXXX") || return 1
test_target="${test_source}.link"
printf "test\n" > "$test_source" || {
rm -f -- "$test_source"
return 1
}
if ! ln -- "$test_source" "$test_target" 2>/dev/null; then
rm -f -- "$test_source" "$test_target"
return 1
fi
rm -f -- "$test_source" "$test_target"
safe_sync_dir "$test_dir" || return 1
ATOMIC_PUBLISH_VERIFIED=1
return 0
}
stat_size() {
stat -c%s "$1" 2>/dev/null || echo ""
}
stat_mtime() {
stat -c%Y "$1" 2>/dev/null || echo ""
}
stat_ctime() {
stat -c%Z "$1" 2>/dev/null || echo ""
}
stat_devino() {
stat -c '%d:%i' "$1" 2>/dev/null || echo ""
}
# Liest alle für Source-State und Transfer-Sicherheit benötigten stat-Werte in
# genau einem Prozess. Die Ausgabe enthält ausschließlich numerische Felder.
read_file_snapshot() {
local file="$1"
FILE_SNAPSHOT_SIZE=""
FILE_SNAPSHOT_MTIME=""
FILE_SNAPSHOT_CTIME=""
FILE_SNAPSHOT_DEVINO=""
if ! IFS='|' read -r \
FILE_SNAPSHOT_SIZE \
FILE_SNAPSHOT_MTIME \
FILE_SNAPSHOT_CTIME \
FILE_SNAPSHOT_DEVINO \
< <(stat -c '%s|%Y|%Z|%d:%i' -- "$file" 2>/dev/null); then
return 1
fi
valid_size "$FILE_SNAPSHOT_SIZE" && \
valid_stat_time_number "$FILE_SNAPSHOT_MTIME" && \
valid_stat_time_number "$FILE_SNAPSHOT_CTIME" && \
valid_devino "$FILE_SNAPSHOT_DEVINO"
}
snapshot_matches_expected() {
local expected_size="$1"
local expected_devino="$2"
local expected_mtime="$3"
local expected_ctime="$4"
[[ "$FILE_SNAPSHOT_SIZE" == "$expected_size" ]] || return 1
# Leere Identitätswerte kommen ausschließlich aus alten v1-Markern.
[[ -z "$expected_devino" || "$FILE_SNAPSHOT_DEVINO" == "$expected_devino" ]] || return 1
[[ -z "$expected_mtime" || "$FILE_SNAPSHOT_MTIME" == "$expected_mtime" ]] || return 1
[[ -z "$expected_ctime" || "$FILE_SNAPSHOT_CTIME" == "$expected_ctime" ]] || return 1
}
# Liest SHA256 ohne nachgeschalteten awk-Prozess. GNU sha256sum markiert Namen
# mit Backslash durch einen führenden Backslash; dieser gehört nicht zum Hash.
calculate_file_hash() {
local file="$1"
HASH_RESULT=""
if ! IFS=' ' read -r HASH_RESULT _ < <(sha256sum -- "$file" 2>/dev/null); then
HASH_RESULT=""
return 1
fi
HASH_RESULT="${HASH_RESULT#\\}"
valid_hash "$HASH_RESULT"
}
calculate_text_hash() {
local value="$1"
HASH_RESULT=""
if ! IFS=' ' read -r HASH_RESULT _ < <(printf '%s' "$value" | sha256sum); then
HASH_RESULT=""
return 1
fi
valid_hash "$HASH_RESULT"
}
safe_sync_path() {
local path="$1"
if [[ "$RUN_SYNC" != "on" || "$DRY_RUN" != "off" ]]; then
return 0
fi
# GNU/coreutils unterstützt gezielten Sync eines Pfades. Falls eine kleinere
# Linux-Variante das nicht kann, wird sicherheitshalber global synchronisiert.
sync -- "$path" >/dev/null 2>&1 || sync >/dev/null 2>&1
}
safe_sync_dir() {
local dir="$1"
[[ -d "$dir" && ! -L "$dir" ]] || return 1
if [[ "$RUN_SYNC" != "on" || "$DRY_RUN" != "off" ]]; then
return 0
fi
sync -f -- "$dir" >/dev/null 2>&1 || sync >/dev/null 2>&1
}
set_single_file_permissions() {
apply_file_permissions "$1"
}
write_status_file() {
local target_file="$1"
local content="$2"
local target_dir=""
local temp_file=""
target_dir="${target_file%/*}"
[[ -d "$target_dir" && ! -L "$target_dir" ]] || return 1
[[ ! -L "$target_file" ]] || return 1
temp_file=$(mktemp "$target_dir/.medien-status.XXXXXX") || return 1
if printf "%s\n" "$content" > "$temp_file" 2>/dev/null && \
safe_sync_path "$temp_file" && \
mv -f -- "$temp_file" "$target_file" 2>/dev/null; then
set_single_file_permissions "$target_file"
safe_sync_path "$target_file" || return 1
safe_sync_dir "$target_dir" || return 1
return 0
fi
rm -f -- "$temp_file"
return 1
}
# ---------------------------------------------
# LOCK, SIGNALE UND STATUS
# ---------------------------------------------
acquire_lock() {
local lock_id=""
local lock_target=""
local old_pid=""
if [[ "$ENABLE_LOCK" != "on" || "$EXTERNAL_LOCK_HELD" == "1" ]]; then
return 0
fi
lock_target=$(canonicalize_path "$DEST") || {
log_error "Lock-Ziel konnte nicht sicher aufgelöst werden"
exit 1
}
calculate_text_hash "$lock_target" || {
log_error "Lock-ID konnte nicht sicher berechnet werden"
exit 1
}
lock_id="$HASH_RESULT"
LOCK_DIR="/tmp/medien_sortierer_${lock_id}.lockdir"
if mkdir "$LOCK_DIR" 2>/dev/null; then
chmod 700 "$LOCK_DIR" 2>/dev/null || true
printf "%s\n" "$$" > "$LOCK_DIR/pid" 2>/dev/null || true
LOCK_OWNED=1
log_info "Lock aktiv"
return 0
fi
if [[ -f "$LOCK_DIR/pid" ]]; then
old_pid=$(cat "$LOCK_DIR/pid" 2>/dev/null || true)
fi
if is_uint "$old_pid" && kill -0 "$old_pid" 2>/dev/null; then
log_error "Script läuft bereits für dieses Ziel"
exit 1
fi
log_warn "Verwaister Lock wird entfernt: $LOCK_DIR"
safe_remove_runtime_dir "$LOCK_DIR"
if path_entry_exists "$LOCK_DIR"; then
log_error "Verwaister Lock konnte nicht entfernt werden"
exit 1
fi
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
log_error "Lock konnte nicht angelegt werden"
exit 1
fi
chmod 700 "$LOCK_DIR" 2>/dev/null || true
printf "%s\n" "$$" > "$LOCK_DIR/pid" 2>/dev/null || true
LOCK_OWNED=1
log_info "Lock aktiv"
}
cleanup_stale_source_state_temps() {
local candidate=""
local basename_value=""
local nullglob_was_set=0
local removed_count=0
local failed_count=0
local -a candidates=()
local -a removable=()
[[ -d "$DEST" && ! -L "$DEST" ]] || return 1
[[ "$(canonicalize_path "$DEST")" == "$DEST" ]] || return 1
if shopt -q nullglob; then
nullglob_was_set=1
fi
shopt -s nullglob
candidates=("$DEST"/.source_state.tmp.*)
if (( nullglob_was_set == 0 )); then
shopt -u nullglob
fi
for candidate in "${candidates[@]}"
do
basename_value="${candidate##*/}"
# Nur exakt die von mktemp mit sechs Zufallszeichen erzeugte Form ist
# entbehrlich. Ähnlich benannte Dateien, Links und Ordner bleiben stehen.
if [[ ! "$basename_value" =~ ^[.]source_state[.]tmp[.][A-Za-z0-9]{6}$ ]]; then
log_warn "Ähnlich benannte Source-State-Datei wird nicht automatisch entfernt: $candidate"
continue
fi
if [[ -L "$candidate" || ! -f "$candidate" ]]; then
log_warn "Source-State-Temp-Pfad ist keine reguläre Datei und bleibt unverändert: $candidate"
continue
fi
removable+=("$candidate")
done
(( ${#removable[@]} > 0 )) || return 0
if [[ "$DRY_RUN" == "on" ]]; then
log_info "Testlauf: verwaiste Source-State-Temp-Dateien gefunden: ${#removable[@]}"
return 0
fi
# Ohne exklusiven Ziel-Lock könnte eine Datei zu einem parallelen Lauf
# gehören. In diesem Fall wird bewusst nur gewarnt und nichts gelöscht.
if [[ "$ENABLE_LOCK" != "on" || \
( "$LOCK_OWNED" -ne 1 && "$EXTERNAL_LOCK_HELD" != "1" ) ]]; then
log_warn "Verwaiste Source-State-Temp-Dateien nicht entfernt, weil kein exklusiver Ziel-Lock aktiv ist: ${#removable[@]}"
return 0
fi
for candidate in "${removable[@]}"
do
if rm -f -- "$candidate" 2>/dev/null && ! path_entry_exists "$candidate"; then
removed_count=$((removed_count + 1))
else
failed_count=$((failed_count + 1))
log_warn "Verwaiste Source-State-Temp-Datei konnte nicht entfernt werden: $candidate"
fi
done
if (( removed_count > 0 )); then
log_ok "Verwaiste Source-State-Temp-Dateien entfernt: $removed_count"
fi
(( failed_count == 0 ))
}
status_start() {
local content=""
if [[ "$DRY_RUN" != "off" ]]; then
return 0
fi
rm -f -- "$STATUS_RUN_FILE" "$STATUS_ERROR_FILE" "$STATUS_FINISH_FILE"
content=$(cat <<EOF
Status: RUN
Gestartet: $START_TIME_TEXT
Modus: $PROCESS_MODE
Quelle: $SRC
Ziel: $DEST
Prozess-ID: $$
EOF
)
if write_status_file "$STATUS_RUN_FILE" "$content"; then
STATUS_TRACKING_ACTIVE=1
else
log_warn "RUN.txt konnte nicht erstellt werden: $STATUS_RUN_FILE"
fi
}
status_watch_standby() {
local last_worker_exit="${1:-}"
local now_text=""
local last_result="Noch kein Sortierlauf in dieser Watch-Sitzung"
local content=""
[[ "$DRY_RUN" == "off" ]] || return 0
[[ -d "$DEST" && ! -L "$DEST" ]] || return 1
now_text=$(date '+%d.%m.%Y %H:%M:%S')
if [[ -n "$last_worker_exit" ]]; then
if [[ "$last_worker_exit" -eq 0 ]]; then
last_result="Erfolgreich abgeschlossen"
rm -f -- "$STATUS_ERROR_FILE"
else
last_result="Mit Exit-Code $last_worker_exit beendet; ERROR.txt bleibt erhalten"
fi
fi
rm -f -- "$STATUS_FINISH_FILE"
content=$(cat <<EOF
Status: STANDBY
Watcher gestartet: $START_TIME_TEXT
Stand aktualisiert: $now_text
Zustand: Wartet auf neue oder geänderte Dateien im Input
Backend: $WATCH_BACKEND_EFFECTIVE
Ruhezeit nach letztem Ereignis: ${WATCH_IDLE_SECONDS}s
Letzter Sortierlauf: $last_result
Modus: $PROCESS_MODE
Quelle: $SRC
Ziel: $DEST
Watcher-Prozess-ID: $$
EOF
)
if write_status_file "$STATUS_RUN_FILE" "$content"; then
STATUS_TRACKING_ACTIVE=1
return 0
fi
log_warn "Standby-Status konnte nicht in RUN.txt geschrieben werden"
return 1
}
cleanup_and_finalize() {
local exit_code="$1"
local end_time=""
local end_time_text=""
local runtime=""
local status_content=""
local final_error=0
local error_message=""
rm -f -- \
"${SOURCE_STATE_NEW:-}" \
"${FILE_LIST:-}" \
"${INPUT_FILE_LIST:-}" \
"${ZIP_PATH_LIST:-}" 2>/dev/null || true
if [[ -n "${TMP_ROOT:-}" && -d "$TMP_ROOT" ]]; then
safe_remove_runtime_dir "$TMP_ROOT"
fi
if [[ "$LOCK_OWNED" -eq 1 && -n "$LOCK_DIR" ]]; then
safe_remove_runtime_dir "$LOCK_DIR"
fi
if [[ "$STATUS_TRACKING_ACTIVE" -ne 1 ]]; then
return 0
fi
end_time=$(date +%s)
end_time_text=$(date '+%d.%m.%Y %H:%M:%S')
runtime=$((end_time - START_TIME))
if [[ "$exit_code" -ne 0 || \
"$SCRIPT_HAD_ERROR" -ne 0 || \
"$COUNT_TRANSFER_FAILED" -gt 0 || \
"$COUNT_RECOVERY_FAILED" -gt 0 || \
"$SCRIPT_COMPLETED" -ne 1 ]]; then
final_error=1
fi
rm -f -- "$STATUS_RUN_FILE"
if [[ "$final_error" -eq 1 ]]; then
rm -f -- "$STATUS_FINISH_FILE"
error_message="$LAST_ERROR_MESSAGE"
if [[ -z "$error_message" && "$COUNT_TRANSFER_FAILED" -gt 0 ]]; then
error_message="Mindestens ein Datei-Transfer ist fehlgeschlagen."
elif [[ -z "$error_message" && "$COUNT_RECOVERY_FAILED" -gt 0 ]]; then
error_message="Mindestens eine Wiederherstellung ist fehlgeschlagen."
elif [[ -z "$error_message" ]]; then
error_message="Der Scriptlauf wurde nicht vollständig abgeschlossen."
fi
status_content=$(cat <<EOF
Status: ERROR
Gestartet: $START_TIME_TEXT
Beendet/abgebrochen: $end_time_text
Laufzeit: ${runtime}s
Modus: $PROCESS_MODE
Exit-Code: $exit_code
Fehlgeschlagene Transfers: $COUNT_TRANSFER_FAILED
Recovery-Fehler: $COUNT_RECOVERY_FAILED
Letzte Fehlerangabe: $error_message
Quelle: $SRC
Ziel: $DEST
EOF
)
write_status_file "$STATUS_ERROR_FILE" "$status_content" || true
else
rm -f -- "$STATUS_ERROR_FILE"
status_content=$(cat <<EOF
Status: FINISH
Gestartet: $START_TIME_TEXT
Beendet: $end_time_text
Laufzeit: ${runtime}s
Modus: $PROCESS_MODE
Neue Originale: $COUNT_ORIGINAL
Duplikate erkannt: $COUNT_DUPLICATE
Übersprungene ZIP-Dateien: $COUNT_ZIP_SKIPPED
Übersprungene sonstige Dateien: $COUNT_SKIPPED
Fehlgeschlagene Transfers: $COUNT_TRANSFER_FAILED
Recovery abgeschlossen: $COUNT_RECOVERY_COMPLETED
Recovery repariert: $COUNT_RECOVERY_REPAIRED
Quelle: $SRC
Ziel: $DEST
EOF
)
write_status_file "$STATUS_FINISH_FILE" "$status_content" || true
fi
}
handle_signal() {
local signal_name="$1"
local exit_code="$2"
SCRIPT_HAD_ERROR=1
LAST_ERROR_MESSAGE="Script durch Signal $signal_name abgebrochen"
if [[ -n "$ACTIVE_TRANSFER_PID" ]] && kill -0 "$ACTIVE_TRANSFER_PID" 2>/dev/null; then
kill -TERM "$ACTIVE_TRANSFER_PID" 2>/dev/null || true
wait "$ACTIVE_TRANSFER_PID" 2>/dev/null || true
ACTIVE_TRANSFER_PID=""
fi
if [[ -n "$WATCH_MONITOR_PID" ]] && kill -0 "$WATCH_MONITOR_PID" 2>/dev/null; then
kill -TERM "$WATCH_MONITOR_PID" 2>/dev/null || true
wait "$WATCH_MONITOR_PID" 2>/dev/null || true
WATCH_MONITOR_PID=""
fi
if [[ -n "$ACTIVE_WAIT_PID" ]] && kill -0 "$ACTIVE_WAIT_PID" 2>/dev/null; then
kill -TERM "$ACTIVE_WAIT_PID" 2>/dev/null || true
wait "$ACTIVE_WAIT_PID" 2>/dev/null || true
ACTIVE_WAIT_PID=""
fi
if [[ -n "$WATCH_CHILD_PID" ]] && kill -0 "$WATCH_CHILD_PID" 2>/dev/null; then
kill -TERM "$WATCH_CHILD_PID" 2>/dev/null || true
wait "$WATCH_CHILD_PID" 2>/dev/null || true
fi
exit "$exit_code"
}
trap 'cleanup_and_finalize $?' EXIT
trap 'handle_signal INT 130' INT
trap 'handle_signal TERM 143' TERM
trap 'handle_signal HUP 129' HUP
# ---------------------------------------------
# STARTVERZÖGERUNG
# ---------------------------------------------
if ! command -v sha256sum >/dev/null 2>&1; then
log_error "sha256sum wurde nicht gefunden"
exit 1
fi
check_on_off_setting "ENABLE_LOCK" "$ENABLE_LOCK"
# Quelle und Ziel müssen bereits vorhanden sein. Diese frühe Prüfung erfolgt vor
# Lock und Startverzögerung, damit bei einem falschen Pfad nichts angelegt wird.
if [[ ! -d "$SRC" || -L "$SRC" ]]; then
log_error "SRC existiert nicht als echter Ordner: $SRC"
exit 1
fi
if [[ ! -d "$DEST" || -L "$DEST" ]]; then
log_error "DEST existiert nicht als echter Ordner: $DEST"
exit 1
fi
acquire_lock
if ! is_uint "$START_DELAY_EFFECTIVE"; then
log_error "START_DELAY_SECONDS muss eine nichtnegative Ganzzahl sein"
exit 1
fi
if (( START_DELAY_EFFECTIVE > 0 )); then
log_info "Startverzögerung: ${START_DELAY_EFFECTIVE}s"
sleep "$START_DELAY_EFFECTIVE" &
ACTIVE_WAIT_PID=$!
if ! wait "$ACTIVE_WAIT_PID"; then
ACTIVE_WAIT_PID=""
log_error "Startverzögerung wurde unerwartet beendet"
exit 1
fi
ACTIVE_WAIT_PID=""
fi
echo "========================================"
echo "[START] Medien-Sortierung gestartet"
echo "========================================"
# ---------------------------------------------
# EINSTELLUNGEN UND PROGRAMME PRÜFEN
# ---------------------------------------------
for setting_name in \
DRY_RUN FAST_MODE QUICK_DEST_CHECK SOURCE_STATE_CACHE \
DELETE_EMPTY_SRC_DIRS DELETE_EMPTY_DEST_DIRS SET_PERMISSIONS RECURSIVE_PERMISSION_FIX \
ENABLE_LOCK RUN_SYNC WATCH_MODE WATCH_PROCESS_EXISTING
do
check_on_off_setting "$setting_name" "${!setting_name}"
done
if [[ "$PROCESS_MODE" != "move" && "$PROCESS_MODE" != "copy" ]]; then
log_error "PROCESS_MODE muss move oder copy sein"
exit 1
fi
if [[ "$DUPLICATE_ACTION" != "auto" && \
"$DUPLICATE_ACTION" != "store" && \
"$DUPLICATE_ACTION" != "skip" ]]; then
log_error "DUPLICATE_ACTION muss auto, store oder skip sein"
exit 1
fi
if [[ "$EXIFTOOL_MODE" != "auto" && \
"$EXIFTOOL_MODE" != "system" && \
"$EXIFTOOL_MODE" != "path" ]]; then
log_error "EXIFTOOL_MODE muss auto, system oder path sein"
exit 1
fi
if [[ "$SORT_STRUCTURE" != "year_month" && \
"$SORT_STRUCTURE" != "year" && \
"$SORT_STRUCTURE" != "flat" ]]; then
log_error "SORT_STRUCTURE muss year_month, year oder flat sein"
exit 1
fi
if [[ "$DATABASE_MODE" != "persistent" && "$DATABASE_MODE" != "temporary" ]]; then
log_error "DATABASE_MODE muss persistent oder temporary sein"
exit 1
fi
if [[ "$DEST_CHECK_LEVEL" != "size" && "$DEST_CHECK_LEVEL" != "hash" ]]; then
log_error "DEST_CHECK_LEVEL muss size oder hash sein"
exit 1
fi
if [[ "$WATCH_BACKEND" != "auto" && \
"$WATCH_BACKEND" != "inotify" && \
"$WATCH_BACKEND" != "poll" ]]; then
log_error "WATCH_BACKEND muss auto, inotify oder poll sein"
exit 1
fi
if ! is_uint "$WATCH_IDLE_SECONDS"; then
log_error "WATCH_IDLE_SECONDS muss eine nichtnegative Ganzzahl sein"
exit 1
fi
if ! is_uint "$WATCH_POLL_SECONDS" || (( WATCH_POLL_SECONDS < 1 )); then
log_error "WATCH_POLL_SECONDS muss eine Ganzzahl ab 1 sein"
exit 1
fi
if ! is_uint "$MIN_FREE_SPACE_MB"; then
log_error "MIN_FREE_SPACE_MB muss eine nichtnegative Ganzzahl sein"
exit 1
fi
if ! validate_permission_mode "$PERMISSION_DIR_MODE" || \
! validate_permission_mode "$PERMISSION_FILE_MODE"; then
log_error "PERMISSION_DIR_MODE und PERMISSION_FILE_MODE müssen numerische Oktalwerte sein"
exit 1
fi
if [[ "$SET_PERMISSIONS" == "on" ]] && ! calculate_permission_umask; then
log_error "PERMISSION_DIR_MODE und PERMISSION_FILE_MODE müssen mit derselben umask darstellbar sein, z. B. 777/666 oder 775/664"
exit 1
fi
if (( BASH_VERSINFO[0] < 4 )); then
log_error "Dieses Script benötigt Bash 4 oder neuer"
exit 1
fi
for required_command in \
cat chmod chown cp date df find id ln mktemp mv \
rm rmdir sha256sum sh sleep sort stat sync
do
if ! command -v "$required_command" >/dev/null 2>&1; then
log_error "Benötigtes Programm fehlt: $required_command"
exit 1
fi
done
if ! command -v realpath >/dev/null 2>&1 && ! command -v readlink >/dev/null 2>&1; then
log_error "Für sichere Pfadprüfung wird realpath oder readlink benötigt"
exit 1
fi
find_exiftool() {
local system_exiftool=""
case "$EXIFTOOL_MODE" in
path)
[[ -x "$EXIFTOOL_PATH" ]] || return 1
EXIFTOOL="$EXIFTOOL_PATH"
;;
system)
system_exiftool=$(command -v exiftool 2>/dev/null || true)
[[ -n "$system_exiftool" && -x "$system_exiftool" ]] || return 1
EXIFTOOL="$system_exiftool"
;;
auto)
if [[ -n "$EXIFTOOL_PATH" && -x "$EXIFTOOL_PATH" ]]; then
EXIFTOOL="$EXIFTOOL_PATH"
else
system_exiftool=$(command -v exiftool 2>/dev/null || true)
[[ -n "$system_exiftool" && -x "$system_exiftool" ]] || return 1
EXIFTOOL="$system_exiftool"
fi
;;
esac
EXIFTOOL_VERSION=$("$EXIFTOOL" -ver 2>/dev/null) || return 1
[[ "$EXIFTOOL_VERSION" =~ ^[0-9]+([.][0-9]+)*$ ]]
}
if ! find_exiftool; then
log_error "ExifTool wurde nicht gefunden oder die Versionsprüfung ist fehlgeschlagen"
exit 1
fi
log_ok "ExifTool bereit: $EXIFTOOL ($EXIFTOOL_VERSION)"
SRC="${SRC%/}"
DEST="${DEST%/}"
if [[ -z "$SRC" || -z "$DEST" ]]; then
log_error "SRC und DEST dürfen nicht leer sein"
exit 1
fi
if has_unsupported_record_chars "$SRC" || has_unsupported_record_chars "$DEST"; then
log_error "SRC und DEST dürfen kein | und keine Zeilenumbrüche enthalten"
exit 1
fi
if [[ -L "$SRC" || -L "$DEST" ]]; then
log_error "SRC und DEST dürfen aus Sicherheitsgründen keine symbolischen Links sein"
exit 1
fi
SRC_CANONICAL=$(canonicalize_path "$SRC") || {
log_error "SRC konnte nicht sicher aufgelöst werden: $SRC"
exit 1
}
DEST_CANONICAL=$(canonicalize_path "$DEST") || {
log_error "DEST konnte nicht sicher aufgelöst werden: $DEST"
exit 1
}
SRC="$SRC_CANONICAL"
DEST="$DEST_CANONICAL"
update_internal_paths
case "$SRC" in
/|/bin|/boot|/dev|/etc|/home|/lib|/lib64|/mnt|/mnt/user|/opt|/proc|/root|/run|/sbin|/srv|/sys|/tmp|/usr|/var)
log_error "Gefährlicher SRC-Pfad: $SRC"
exit 1
;;
esac
case "$DEST" in
/|/bin|/boot|/dev|/etc|/home|/lib|/lib64|/mnt|/mnt/user|/opt|/proc|/root|/run|/sbin|/srv|/sys|/tmp|/usr|/var)
log_error "Gefährlicher DEST-Pfad: $DEST"
exit 1
;;
esac
if [[ "$SRC" == "$DEST" ]]; then
log_error "SRC und DEST dürfen nicht identisch sein"
exit 1
fi
if path_is_within "$DEST" "$SRC"; then
log_error "DEST darf nicht innerhalb von SRC liegen"
exit 1
fi
if path_is_within "$SRC" "$DEST"; then
log_error "SRC darf nicht innerhalb von DEST liegen"
exit 1
fi
if [[ ! -d "$SRC" || -L "$SRC" ]]; then
log_error "SRC existiert nicht als echter Ordner: $SRC"
exit 1
fi
if [[ ! -d "$DEST" || -L "$DEST" ]]; then
log_error "DEST existiert nicht als echter Ordner: $DEST"
exit 1
fi
if [[ "$DRY_RUN" == "off" ]]; then
validate_control_paths || exit 1
else
log_warn "DRY_RUN ist aktiv. Es werden keine echten Änderungen ausgeführt."
fi
if [[ "$SET_PERMISSIONS" == "on" ]]; then
umask "$PERMISSION_UMASK"
log_info "Dynamische umask aus den Rechtevariablen: $PERMISSION_UMASK"
fi
if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
log_warn "Script läuft als root; strenge Pfad-, Symlink- und Überschreibschutzprüfungen sind aktiv."
fi
if [[ "$DUPLICATE_ACTION" == "auto" ]]; then
if [[ "$PROCESS_MODE" == "move" ]]; then
EFFECTIVE_DUPLICATE_ACTION="store"
else
EFFECTIVE_DUPLICATE_ACTION="skip"
fi
else
EFFECTIVE_DUPLICATE_ACTION="$DUPLICATE_ACTION"
fi
if [[ "$PROCESS_MODE" == "copy" && "$SOURCE_STATE_CACHE" == "on" ]]; then
SOURCE_STATE_CACHE_EFFECTIVE="on"
else
SOURCE_STATE_CACHE_EFFECTIVE="off"
fi
if ! cleanup_stale_source_state_temps; then
log_warn "Verwaiste Source-State-Temp-Dateien konnten nicht vollständig bereinigt werden"
fi
if [[ "$RUN_SYNC" == "off" && "$DRY_RUN" == "off" ]]; then
log_warn "RUN_SYNC=off reduziert den Schutz bei plötzlichem Stromausfall"
fi
# ---------------------------------------------
# WATCH-CONTROLLER
# ---------------------------------------------
run_watch_worker() {
local worker_exit=0
log_info "Starte überwachten Sortierlauf"
MEDIEN_SORTIERER_WORKER=1 \
MEDIEN_SORTIERER_LOCK_HELD=1 \
bash "$SCRIPT_PATH" &
WATCH_CHILD_PID=$!
if [[ "$LOCK_OWNED" -eq 1 && -n "$LOCK_DIR" ]]; then
printf "%s\n" "$WATCH_CHILD_PID" > "$LOCK_DIR/pid" 2>/dev/null || true
fi
wait "$WATCH_CHILD_PID"
worker_exit=$?
WATCH_CHILD_PID=""
if [[ "$LOCK_OWNED" -eq 1 && -n "$LOCK_DIR" ]]; then
printf "%s\n" "$$" > "$LOCK_DIR/pid" 2>/dev/null || true
fi
if [[ "$worker_exit" -eq 0 ]]; then
log_ok "Überwachter Sortierlauf abgeschlossen"
else
log_warn "Überwachter Sortierlauf endete mit Exit-Code $worker_exit"
fi
status_watch_standby "$worker_exit" || true
return "$worker_exit"
}
input_has_any_file() {
local candidate=""
IFS= read -r -d '' candidate < <(
find "$SRC" -xdev -type f -print0 -quit 2>/dev/null
)
}
recovery_work_pending() {
local marker=""
if [[ "$DATABASE_MODE" == "persistent" ]] && path_entry_exists "$REAL_JOURNAL_DB"; then
return 0
fi
[[ -d "$RECOVERY_DIR" && ! -L "$RECOVERY_DIR" ]] || return 1
IFS= read -r -d '' marker < <(
find "$RECOVERY_DIR" -xdev -maxdepth 1 -type f -name '*.txn' -print0 -quit 2>/dev/null
)
}
watch_initial_worker_needed() {
if recovery_work_pending; then
log_info "Offene Recovery- oder Journal-Arbeit gefunden; starte sofort einen sicheren Sortierlauf."
return 0
fi
[[ "$WATCH_PROCESS_EXISTING" == "on" ]] && input_has_any_file
}
input_has_processable_media() {
local candidate=""
local ext=""
while IFS= read -r -d '' candidate
do
has_unsupported_record_chars "$candidate" && continue
ext="${candidate##*.}"
ext="${ext,,}"
case "$ext" in
jpg|jpeg|png|heic|heif|webp|gif|bmp|tif|tiff|dng|raw|mp4|mov|m4v|avi|mkv|mpg|mpeg|3gp|3gpp|webm)
return 0
;;
esac
done < <(find "$SRC" -xdev -type f -print0 2>/dev/null)
return 1
}
resolve_watch_backend() {
case "$WATCH_BACKEND" in
auto)
if command -v inotifywait >/dev/null 2>&1; then
WATCH_BACKEND_EFFECTIVE="inotify"
else
WATCH_BACKEND_EFFECTIVE="poll"
log_warn "WATCH_BACKEND=auto fällt auf Polling zurück; dies kann Festplatten wach halten."
fi
;;
inotify)
if ! command -v inotifywait >/dev/null 2>&1; then
log_error "WATCH_BACKEND=inotify benötigt inotifywait. Es wird nichts automatisch installiert."
return 1
fi
WATCH_BACKEND_EFFECTIVE="inotify"
;;
poll)
WATCH_BACKEND_EFFECTIVE="poll"
;;
esac
return 0
}
poll_snapshot_hash() {
local snapshot_hash=""
# NUL-getrennte und sortierte Pfade halten auch Leerzeichen und Zeilenumbrüche aus.
# Größe, mtime und ctime erkennen neue, gelöschte und weiter beschriebene Dateien.
if ! IFS=' ' read -r snapshot_hash _ < <({
while IFS= read -r -d '' poll_file
do
printf 'FILE\0%s\0' "$poll_file"
if [[ -f "$poll_file" ]]; then
stat -c 'SIZE\0%s\0MTIME\0%Y\0CTIME\0%Z\0' "$poll_file" 2>/dev/null || \
printf 'MISSING\0'
else
printf 'MISSING\0'
fi
done < <(find "$SRC" -xdev -type f -print0 2>/dev/null | sort -z)
} | sha256sum); then
return 1
fi
valid_hash "$snapshot_hash" || return 1
printf '%s' "$snapshot_hash"
}
watch_sleep() {
local seconds="$1"
local sleep_exit=0
sleep "$seconds" &
ACTIVE_WAIT_PID=$!
wait "$ACTIVE_WAIT_PID" || sleep_exit=$?
ACTIVE_WAIT_PID=""
return "$sleep_exit"
}
watch_event_file_size() {
stat -c%s "$WATCH_EVENT_FILE" 2>/dev/null || echo ""
}
start_inotify_monitor() {
if [[ "$ENABLE_LOCK" == "on" && -n "$LOCK_DIR" && -d "$LOCK_DIR" ]]; then
WATCH_EVENT_FILE="$LOCK_DIR/inotify.events"
: > "$WATCH_EVENT_FILE" || return 1
else
WATCH_EVENT_FILE=$(mktemp "/tmp/medien_sortierer.watch.XXXXXX") || return 1
fi
inotifywait -q -m -r \
-e close_write,moved_to \
--format '1' \
"$SRC" >> "$WATCH_EVENT_FILE" 2>/dev/null &
WATCH_MONITOR_PID=$!
watch_sleep 1 || return 1
kill -0 "$WATCH_MONITOR_PID" 2>/dev/null
}
stop_inotify_monitor() {
if [[ -n "$WATCH_MONITOR_PID" ]] && kill -0 "$WATCH_MONITOR_PID" 2>/dev/null; then
kill -TERM "$WATCH_MONITOR_PID" 2>/dev/null || true
wait "$WATCH_MONITOR_PID" 2>/dev/null || true
fi
WATCH_MONITOR_PID=""
if [[ -n "$WATCH_EVENT_FILE" && "$WATCH_EVENT_FILE" == /tmp/medien_sortierer.watch.* ]]; then
rm -f -- "$WATCH_EVENT_FILE" 2>/dev/null || true
fi
}
wait_for_inotify_event_after() {
local previous_size="$1"
local current_size=""
while true
do
kill -0 "$WATCH_MONITOR_PID" 2>/dev/null || return 1
current_size=$(watch_event_file_size)
valid_size "$current_size" || return 1
if (( current_size > previous_size )); then
WATCH_EVENT_SIZE="$current_size"
return 0
fi
watch_sleep 1 || return 1
done
}
wait_for_inotify_quiet() {
local current_size=""
local last_size=""
local last_change=""
local now=""
current_size=$(watch_event_file_size)
valid_size "$current_size" || return 1
last_size="$current_size"
last_change=$(date +%s)
if (( WATCH_IDLE_SECONDS == 0 )); then
WATCH_EVENT_SIZE="$last_size"
return 0
fi
while true
do
kill -0 "$WATCH_MONITOR_PID" 2>/dev/null || return 1
watch_sleep 1 || return 1
current_size=$(watch_event_file_size)
valid_size "$current_size" || return 1
now=$(date +%s)
if [[ "$current_size" != "$last_size" ]]; then
last_size="$current_size"
last_change="$now"
continue
fi
if (( now - last_change >= WATCH_IDLE_SECONDS )); then
WATCH_EVENT_SIZE="$last_size"
return 0
fi
done
}
run_worker_with_inotify_drain() {
local baseline_size=""
local after_size=""
local worker_exit=0
while true
do
baseline_size=$(watch_event_file_size)
valid_size "$baseline_size" || return 1
run_watch_worker || worker_exit=$?
after_size=$(watch_event_file_size)
valid_size "$after_size" || return 1
if [[ "$after_size" == "$baseline_size" ]]; then
return "$worker_exit"
fi
log_info "Während der Sortierung wurden weitere Dateien erkannt; warte erneut auf Ruhe."
wait_for_inotify_quiet || return 1
worker_exit=0
done
}
watch_controller_inotify() {
local event_size=0
log_info "Überwachungsart: inotify"
log_info "Die Überwachung bleibt auch während eines Sortierlaufs aktiv."
log_info "Im Leerlauf wird ausschließlich eine Ereignisdatei unter /tmp geprüft."
start_inotify_monitor || {
log_error "Dauerhafte inotify-Überwachung konnte nicht gestartet werden"
return 1
}
event_size=$(watch_event_file_size)
valid_size "$event_size" || {
stop_inotify_monitor
return 1
}
if watch_initial_worker_needed; then
run_worker_with_inotify_drain || true
event_size=$(watch_event_file_size)
fi
# STANDBY bedeutet nun wirklich: Monitor läuft und neue Ereignisse können
# nicht mehr unbemerkt zwischen Statusdatei und Watcher-Start eintreffen.
status_watch_standby || true
while true
do
if ! wait_for_inotify_event_after "$event_size"; then
stop_inotify_monitor
log_error "inotify-Überwachung wurde beendet oder SRC ist nicht mehr verfügbar"
return 1
fi
wait_for_inotify_quiet || {
stop_inotify_monitor
log_error "inotify-Ruhezeit konnte nicht sicher überwacht werden"
return 1
}
event_size="$WATCH_EVENT_SIZE"
run_worker_with_inotify_drain || true
event_size=$(watch_event_file_size)
done
}
wait_for_poll_quiet() {
local previous=""
local current=""
local last_change=""
local now=""
previous=$(poll_snapshot_hash) || return 1
last_change=$(date +%s)
if (( WATCH_IDLE_SECONDS == 0 )); then
return 0
fi
while true
do
watch_sleep "$WATCH_POLL_SECONDS" || return 1
current=$(poll_snapshot_hash) || return 1
now=$(date +%s)
if [[ "$current" != "$previous" ]]; then
previous="$current"
last_change="$now"
continue
fi
(( now - last_change >= WATCH_IDLE_SECONDS )) && return 0
done
}
run_worker_with_poll_drain() {
local before_snapshot=""
local after_snapshot=""
local worker_exit=0
local followup_runs=0
while true
do
before_snapshot=$(poll_snapshot_hash) || return 1
worker_exit=0
run_watch_worker || worker_exit=$?
after_snapshot=$(poll_snapshot_hash) || return 1
if [[ "$PROCESS_MODE" == "copy" ]]; then
[[ "$after_snapshot" == "$before_snapshot" ]] && return "$worker_exit"
else
input_has_processable_media || return "$worker_exit"
fi
followup_runs=$((followup_runs + 1))
if (( followup_runs >= 10 )); then
log_warn "Polling: maximal 10 direkte Folgeläufe erreicht; verbleibende Dateien werden beim nächsten Ereignis erneut geprüft."
return "$worker_exit"
fi
log_info "Polling: Während der Sortierung änderte sich der Input; warte erneut auf Ruhe."
wait_for_poll_quiet || return 1
done
}
watch_controller_poll() {
local previous_snapshot=""
local current_snapshot=""
local last_change=0
local now=0
local pending_run=0
if ! command -v sort >/dev/null 2>&1; then
log_error "WATCH_BACKEND=poll benötigt das Programm sort"
return 1
fi
log_info "Überwachungsart: Polling"
log_info "Prüfintervall: ${WATCH_POLL_SECONDS}s"
log_warn "Polling liest den Input regelmäßig und kann dadurch Festplatten wach halten oder aufwecken."
previous_snapshot=$(poll_snapshot_hash) || {
log_error "Erster Polling-Schnappschuss konnte nicht erstellt werden"
return 1
}
if watch_initial_worker_needed; then
run_worker_with_poll_drain || true
previous_snapshot=$(poll_snapshot_hash) || return 1
fi
status_watch_standby || true
while true
do
watch_sleep "$WATCH_POLL_SECONDS" || return 1
current_snapshot=$(poll_snapshot_hash) || {
log_error "Polling-Schnappschuss konnte nicht erstellt werden"
return 1
}
now=$(date +%s)
if [[ "$current_snapshot" != "$previous_snapshot" ]]; then
previous_snapshot="$current_snapshot"
last_change="$now"
pending_run=1
continue
fi
if [[ "$pending_run" -eq 1 ]] && \
(( WATCH_IDLE_SECONDS == 0 || now - last_change >= WATCH_IDLE_SECONDS )); then
run_worker_with_poll_drain || true
previous_snapshot=$(poll_snapshot_hash) || return 1
pending_run=0
fi
done
}
watch_controller() {
if ! resolve_watch_backend; then
return 1
fi
log_info "Watch-Modus aktiv"
log_info "Überwachter Ordner: $SRC"
log_info "Ruhezeit: ${WATCH_IDLE_SECONDS}s"
log_info "Gewähltes Backend: $WATCH_BACKEND"
log_info "Verwendetes Backend: $WATCH_BACKEND_EFFECTIVE"
case "$WATCH_BACKEND_EFFECTIVE" in
inotify) watch_controller_inotify ;;
poll) watch_controller_poll ;;
*)
log_error "Interner Fehler: unbekanntes Watch-Backend"
return 1
;;
esac
}
if [[ "$WATCH_MODE_EFFECTIVE" == "on" ]]; then
watch_controller
WATCH_EXIT=$?
exit "$WATCH_EXIT"
fi
# ---------------------------------------------
# STATUS UND ARBEITSDATEIEN VORBEREITEN
# ---------------------------------------------
status_start
TMP_ROOT=$(mktemp -d "/tmp/medien_sortierer.XXXXXX") || {
log_error "Temporäres Arbeitsverzeichnis konnte nicht erstellt werden"
exit 1
}
chmod 700 "$TMP_ROOT" 2>/dev/null || true
FILE_LIST="$TMP_ROOT/filelist"
INPUT_FILE_LIST="$TMP_ROOT/input_filelist"
ZIP_PATH_LIST="$TMP_ROOT/zip_paths"
: > "$ZIP_PATH_LIST"
chmod 600 "$ZIP_PATH_LIST" 2>/dev/null || true
if [[ "$DRY_RUN" == "on" ]]; then
DB_FILE="$TMP_ROOT/hashes.db"
JOURNAL_FILE="$TMP_ROOT/hashes.journal"
if [[ -f "$REAL_HASH_DB" && ! -L "$REAL_HASH_DB" ]]; then
cp -- "$REAL_HASH_DB" "$DB_FILE"
else
: > "$DB_FILE"
fi
if [[ -f "$REAL_JOURNAL_DB" && ! -L "$REAL_JOURNAL_DB" ]]; then
cp -- "$REAL_JOURNAL_DB" "$JOURNAL_FILE"
else
: > "$JOURNAL_FILE"
fi
if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" == "on" ]]; then
SOURCE_STATE_FILE="$TMP_ROOT/source_state.db"
if [[ -f "$REAL_SOURCE_STATE_DB" && ! -L "$REAL_SOURCE_STATE_DB" ]]; then
cp -- "$REAL_SOURCE_STATE_DB" "$SOURCE_STATE_FILE"
else
: > "$SOURCE_STATE_FILE"
fi
fi
elif [[ "$DATABASE_MODE" == "temporary" ]]; then
DB_FILE="$TMP_ROOT/hashes.db"
JOURNAL_FILE="$TMP_ROOT/hashes.journal"
: > "$DB_FILE"
: > "$JOURNAL_FILE"
if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" == "on" ]]; then
SOURCE_STATE_FILE="$TMP_ROOT/source_state.db"
: > "$SOURCE_STATE_FILE"
fi
else
DB_FILE="$REAL_HASH_DB"
JOURNAL_FILE="$REAL_JOURNAL_DB"
# Source-State wird ausschließlich im copy-Modus mit aktivem Cache benötigt.
# Eine Temp-Datei entsteht erst beim finalen Schreiben und nur dann, wenn
# sich der erfasste Quellzustand tatsächlich geändert hat.
if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" == "on" ]]; then
SOURCE_STATE_FILE="$REAL_SOURCE_STATE_DB"
fi
fi
# Recovery-Verzeichnisse und der Hardlink-Funktionstest werden absichtlich erst
# beim ersten echten Transfer beziehungsweise Quarantänefall angelegt/ausgeführt.
# Ein unveränderter Leerlauf hinterlässt dadurch keinen leeren Recovery-Ordner.
# ---------------------------------------------
# DATEITYPEN UND DATUM
# ---------------------------------------------
is_media_extension() {
case "$1" in
jpg|jpeg|png|heic|heif|webp|gif|bmp|tif|tiff|dng|raw|mp4|mov|m4v|avi|mkv|mpg|mpeg|3gp|3gpp|webm)
return 0
;;
*)
return 1
;;
esac
}
set_category_from_ext() {
case "$1" in
jpg|jpeg|png|mp4|avi|mkv|mpg|mpeg)
CATEGORY_RESULT="COMPATIBLE"
;;
heic|heif|dng|raw|mov|m4v)
CATEGORY_RESULT="APPLE"
;;
*)
CATEGORY_RESULT="OTHER"
;;
esac
}
set_media_type_from_ext() {
case "$1" in
jpg|jpeg|png|heic|heif|webp|gif|bmp|tif|tiff|dng|raw)
MEDIA_TYPE_RESULT="BILDER"
;;
mp4|mov|m4v|avi|mkv|mpg|mpeg|3gp|3gpp|webm)
MEDIA_TYPE_RESULT="VIDEOS"
;;
*)
MEDIA_TYPE_RESULT=""
return 1
;;
esac
return 0
}
normalize_subseconds_to_milliseconds() {
local value="$1"
MILLISECONDS_RESULT="000"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
if [[ "$value" =~ ^0[\.,]([0-9]+)$ ]]; then
value="${BASH_REMATCH[1]}"
elif [[ "$value" =~ ^[\.,]([0-9]+)$ ]]; then
value="${BASH_REMATCH[1]}"
fi
if [[ "$value" =~ ^[0-9]+$ ]]; then
value="${value}000"
MILLISECONDS_RESULT="${value:0:3}"
fi
}
parse_exif_datetime_value() {
local value="$1"
local parsed_year=""
local parsed_month=""
local parsed_day=""
local parsed_hour=""
local parsed_minute=""
local parsed_second=""
local parsed_subseconds=""
PARSED_DATETIME=""
PARSED_SUBSECONDS=""
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
if [[ -z "$value" || "$value" == "-" ]]; then
return 1
fi
if [[ "$value" =~ ^([0-9]{4})[-:]([0-9]{2})[-:]([0-9]{2})[[:space:]T_]([0-9]{2}):([0-9]{2}):([0-9]{2})([\.,]([0-9]+))?[[:space:]]*([zZ]|[+-][0-9]{2}:?[0-9]{2})?$ ]]; then
parsed_year="${BASH_REMATCH[1]}"
parsed_month="${BASH_REMATCH[2]}"
parsed_day="${BASH_REMATCH[3]}"
parsed_hour="${BASH_REMATCH[4]}"
parsed_minute="${BASH_REMATCH[5]}"
parsed_second="${BASH_REMATCH[6]}"
parsed_subseconds="${BASH_REMATCH[8]}"
valid_calendar_datetime_components \
"$parsed_year" "$parsed_month" "$parsed_day" \
"$parsed_hour" "$parsed_minute" "$parsed_second" || return 1
PARSED_DATETIME="${parsed_year}-${parsed_month}-${parsed_day}_${parsed_hour}-${parsed_minute}-${parsed_second}"
PARSED_SUBSECONDS="$parsed_subseconds"
return 0
fi
return 1
}
set_datetime_from_exif() {
local input_file="$1"
local ext=""
local metadata_line=""
local raw_subsec_datetime_original=""
local raw_datetime_original=""
local raw_subsec_original=""
local raw_keys_creation_date=""
local raw_userdata_datetime_original=""
local raw_quicktime_create_date=""
local raw_media_create_date=""
local raw_subsec_create_date=""
local raw_create_date=""
local raw_subsec_digitized=""
local selected_datetime=""
local selected_subseconds=""
DATETIME=""
YEAR=""
MONTH=""
ext="${input_file##*.}"
ext="${ext,,}"
# QuickTime:CreateDate und MediaCreateDate sind UTC-Zeitwerte. Mit
# QuickTimeUTC=1 gibt ExifTool sie in der lokalen Systemzeitzone aus und
# berücksichtigt die zum Aufnahmedatum passende Sommer-/Winterzeit.
#
# iPhone-Videos enthalten häufig Keys:CreationDate oder
# UserData:DateTimeOriginal. Diese lokalen Aufnahmezeiten werden bevorzugt,
# weil sie bei bearbeiteten Videos eher das ursprüngliche Datum bewahren.
# QuickTimeUTC beeinflusst normale Bild-Metadaten nicht.
metadata_line=$("$EXIFTOOL" \
-api QuickTimeUTC=1 \
-T \
-SubSecDateTimeOriginal \
-DateTimeOriginal \
-SubSecTimeOriginal \
-Keys:CreationDate \
-UserData:DateTimeOriginal \
-QuickTime:CreateDate \
-MediaCreateDate \
-SubSecCreateDate \
-CreateDate \
-SubSecTimeDigitized \
"$input_file" 2>/dev/null)
IFS=$'\t' read -r \
raw_subsec_datetime_original \
raw_datetime_original \
raw_subsec_original \
raw_keys_creation_date \
raw_userdata_datetime_original \
raw_quicktime_create_date \
raw_media_create_date \
raw_subsec_create_date \
raw_create_date \
raw_subsec_digitized \
<<< "$metadata_line"
set_media_type_from_ext "$ext" || true
if [[ "$MEDIA_TYPE_RESULT" == "VIDEOS" ]]; then
# Apple-/QuickTime-spezifische Originalzeit zuerst, danach UTC-Fallbacks.
if parse_exif_datetime_value "$raw_keys_creation_date"; then
selected_datetime="$PARSED_DATETIME"
selected_subseconds="$PARSED_SUBSECONDS"
elif parse_exif_datetime_value "$raw_userdata_datetime_original"; then
selected_datetime="$PARSED_DATETIME"
selected_subseconds="$PARSED_SUBSECONDS"
elif parse_exif_datetime_value "$raw_quicktime_create_date"; then
selected_datetime="$PARSED_DATETIME"
selected_subseconds="$PARSED_SUBSECONDS"
elif parse_exif_datetime_value "$raw_media_create_date"; then
selected_datetime="$PARSED_DATETIME"
selected_subseconds="$PARSED_SUBSECONDS"
elif parse_exif_datetime_value "$raw_subsec_datetime_original"; then
selected_datetime="$PARSED_DATETIME"
selected_subseconds="$PARSED_SUBSECONDS"
elif parse_exif_datetime_value "$raw_datetime_original"; then
selected_datetime="$PARSED_DATETIME"
if [[ -n "$PARSED_SUBSECONDS" ]]; then
selected_subseconds="$PARSED_SUBSECONDS"
else
selected_subseconds="$raw_subsec_original"
fi
elif parse_exif_datetime_value "$raw_subsec_create_date"; then
selected_datetime="$PARSED_DATETIME"
selected_subseconds="$PARSED_SUBSECONDS"
elif parse_exif_datetime_value "$raw_create_date"; then
selected_datetime="$PARSED_DATETIME"
if [[ -n "$PARSED_SUBSECONDS" ]]; then
selected_subseconds="$PARSED_SUBSECONDS"
else
selected_subseconds="$raw_subsec_digitized"
fi
fi
else
# Bild-Reihenfolge bleibt unverändert: Aufnahmezeit vor Erstellungszeit.
if parse_exif_datetime_value "$raw_subsec_datetime_original"; then
selected_datetime="$PARSED_DATETIME"
selected_subseconds="$PARSED_SUBSECONDS"
elif parse_exif_datetime_value "$raw_datetime_original"; then
selected_datetime="$PARSED_DATETIME"
if [[ -n "$PARSED_SUBSECONDS" ]]; then
selected_subseconds="$PARSED_SUBSECONDS"
else
selected_subseconds="$raw_subsec_original"
fi
elif parse_exif_datetime_value "$raw_subsec_create_date"; then
selected_datetime="$PARSED_DATETIME"
selected_subseconds="$PARSED_SUBSECONDS"
elif parse_exif_datetime_value "$raw_create_date"; then
selected_datetime="$PARSED_DATETIME"
if [[ -n "$PARSED_SUBSECONDS" ]]; then
selected_subseconds="$PARSED_SUBSECONDS"
else
selected_subseconds="$raw_subsec_digitized"
fi
fi
fi
if [[ -z "$selected_datetime" ]] || \
[[ "$selected_datetime" =~ ^0000 ]] || \
[[ "$selected_datetime" == "1970-01-01_00-00-00" ]]; then
DATETIME="UNKNOWN"
YEAR="0000"
MONTH="00"
else
normalize_subseconds_to_milliseconds "$selected_subseconds"
DATETIME="${selected_datetime}_${MILLISECONDS_RESULT}"
YEAR="${DATETIME:0:4}"
MONTH="${DATETIME:5:2}"
fi
}
get_year_month_from_datetime() {
local date_value="$1"
YEAR_RESULT=""
MONTH_RESULT=""
if [[ "$date_value" == "UNKNOWN" ]]; then
YEAR_RESULT="0000"
MONTH_RESULT="00"
elif [[ "$date_value" =~ ^([0-9]{4})-([0-9]{2})-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}_[0-9]{3}$ ]]; then
YEAR_RESULT="${BASH_REMATCH[1]}"
MONTH_RESULT="${BASH_REMATCH[2]}"
fi
}
target_dir_for() {
local category="$1"
local media_type="$2"
local year="$3"
local month="$4"
TARGET_DIR_RESULT=""
[[ -n "$category" && -n "$media_type" ]] || return 1
case "$SORT_STRUCTURE" in
year_month)
TARGET_DIR_RESULT="$DEST/$category/$media_type/$year/$month"
;;
year)
TARGET_DIR_RESULT="$DEST/$category/$media_type/$year"
;;
flat)
TARGET_DIR_RESULT="$DEST/$category/$media_type"
;;
esac
return 0
}
run_mkdir() {
local dir="$1"
if [[ "$DRY_RUN" == "off" ]]; then
ensure_safe_directory "$dir" || {
log_error "Zielordner konnte nicht sicher erstellt oder geprüft werden: $dir"
return 1
}
fi
}
is_db_path_plausible() {
local file="$1"
local date_value="$2"
local date_already_validated="${3:-0}"
local name=""
local base=""
local ext=""
local expected_year=""
local expected_month=""
local expected_category=""
local expected_media_type=""
local expected_dir=""
local current_dir=""
if [[ "$date_already_validated" != "1" ]] && ! valid_datetime_value "$date_value"; then
return 1
fi
name="${file##*/}"
base="${name%.*}"
ext="${name##*.}"
ext="${ext,,}"
if ! is_media_extension "$ext"; then
return 1
fi
get_year_month_from_datetime "$date_value"
expected_year="$YEAR_RESULT"
expected_month="$MONTH_RESULT"
if [[ -z "$expected_year" || -z "$expected_month" ]]; then
return 1
fi
set_category_from_ext "$ext"
expected_category="$CATEGORY_RESULT"
set_media_type_from_ext "$ext" || return 1
expected_media_type="$MEDIA_TYPE_RESULT"
target_dir_for "$expected_category" "$expected_media_type" "$expected_year" "$expected_month" || return 1
expected_dir="$TARGET_DIR_RESULT"
current_dir="${file%/*}"
if [[ "$current_dir" != "$expected_dir" ]]; then
return 1
fi
if [[ "$base" == "$date_value" ]] || [[ "$base" =~ ^${date_value}_[0-9]+$ ]]; then
return 0
fi
return 1
}
get_unique_target() {
local category="$1"
local year="$2"
local month="$3"
local datetime="$4"
local ext="$5"
local is_duplicate="$6"
local current_file="$7"
local media_type=""
local target_dir=""
local target=""
local counter=1
UNIQUE_TARGET_RESULT=""
set_media_type_from_ext "$ext" || return 1
media_type="$MEDIA_TYPE_RESULT"
target_dir_for "$category" "$media_type" "$year" "$month" || return 1
target_dir="$TARGET_DIR_RESULT"
run_mkdir "$target_dir" || return 1
if [[ "$is_duplicate" -eq 1 ]]; then
while true
do
target=$(printf "%s/%s_DUP%03d.%s" \
"$target_dir" "$datetime" "$counter" "$ext")
if [[ "$target" == "$current_file" ]] || ! path_entry_exists "$target"; then
UNIQUE_TARGET_RESULT="$target"
return 0
fi
counter=$((counter + 1))
done
else
target="$target_dir/$datetime.$ext"
if [[ "$target" == "$current_file" ]] || ! path_entry_exists "$target"; then
UNIQUE_TARGET_RESULT="$target"
return 0
fi
while true
do
target="$target_dir/${datetime}_${counter}.${ext}"
if [[ "$target" == "$current_file" ]] || ! path_entry_exists "$target"; then
UNIQUE_TARGET_RESULT="$target"
return 0
fi
counter=$((counter + 1))
done
fi
}
# ---------------------------------------------
# DB IM ARBEITSSPEICHER
# ---------------------------------------------
# Pro Original werden nur zwei kompakte Zuordnungen gehalten:
# Hash -> Datei
# Datei -> Hash|Datum|Größe|interner Prüfstatus
# Zusätzlich existiert ein kleiner Größenzähler für den FAST_MODE. Dateiinhalte
# und rekursive Dateilisten werden nicht in den Arbeitsspeicher geladen.
declare -A DB_HASH_FILE
declare -A DB_FILE_RECORD
declare -A DB_SIZE_COUNT
db_clear_memory() {
DB_HASH_FILE=()
DB_FILE_RECORD=()
DB_SIZE_COUNT=()
DB_RECORD_COUNT=0
}
db_increment_size() {
local size="$1"
local count=0
[[ -n "$size" ]] || return 0
count="${DB_SIZE_COUNT[$size]:-0}"
DB_SIZE_COUNT["$size"]=$((count + 1))
}
db_decrement_size() {
local size="$1"
local count=0
[[ -n "$size" && -n "${DB_SIZE_COUNT[$size]+x}" ]] || return 0
count="${DB_SIZE_COUNT[$size]}"
if (( count <= 1 )); then
unset "DB_SIZE_COUNT[$size]"
else
DB_SIZE_COUNT["$size"]=$((count - 1))
fi
}
db_add_memory() {
local hash="$1"
local date="$2"
local size="$3"
local file="$4"
local old_record=""
local old_file=""
local old_hash=""
if [[ -n "${DB_HASH_FILE[$hash]+x}" ]]; then
old_file="${DB_HASH_FILE[$hash]}"
[[ "$old_file" == "$file" ]] || return 1
return 0
fi
if [[ -n "${DB_FILE_RECORD[$file]+x}" ]]; then
old_record="${DB_FILE_RECORD[$file]}"
IFS='|' read -r old_hash _ <<< "$old_record"
[[ "$old_hash" == "$hash" ]] || return 1
return 0
fi
DB_HASH_FILE["$hash"]="$file"
DB_FILE_RECORD["$file"]="$hash|$date|$size|0"
db_increment_size "$size"
DB_RECORD_COUNT=$((DB_RECORD_COUNT + 1))
return 0
}
db_remove_hash_memory() {
local hash="$1"
local file=""
local record=""
local record_hash=""
local date=""
local size=""
local seen=""
[[ -n "${DB_HASH_FILE[$hash]+x}" ]] || return 0
file="${DB_HASH_FILE[$hash]}"
if [[ -n "${DB_FILE_RECORD[$file]+x}" ]]; then
record="${DB_FILE_RECORD[$file]}"
IFS='|' read -r record_hash date size seen <<< "$record"
fi
unset "DB_HASH_FILE[$hash]"
[[ -n "$file" ]] && unset "DB_FILE_RECORD[$file]"
db_decrement_size "$size"
(( DB_RECORD_COUNT > 0 )) && DB_RECORD_COUNT=$((DB_RECORD_COUNT - 1))
DB_DIRTY=1
}
db_remove_file_memory() {
local file="$1"
local record=""
local hash=""
local date=""
local size=""
local seen=""
[[ -n "${DB_FILE_RECORD[$file]+x}" ]] || return 0
record="${DB_FILE_RECORD[$file]}"
IFS='|' read -r hash date size seen <<< "$record"
db_remove_hash_memory "$hash"
}
db_find_hash() {
local hash="$1"
local file=""
local record=""
DB_LOOKUP_HASH=""
DB_LOOKUP_FILE=""
[[ -n "${DB_HASH_FILE[$hash]+x}" ]] || return 1
file="${DB_HASH_FILE[$hash]}"
[[ -n "${DB_FILE_RECORD[$file]+x}" ]] || return 1
record="${DB_FILE_RECORD[$file]}"
IFS='|' read -r DB_LOOKUP_HASH _ <<< "$record"
[[ "$DB_LOOKUP_HASH" == "$hash" ]] || return 1
DB_LOOKUP_FILE="$file"
return 0
}
db_mark_seen() {
local file="$1"
local record=""
local hash=""
local date=""
local size=""
local seen=""
[[ -n "${DB_FILE_RECORD[$file]+x}" ]] || return 0
record="${DB_FILE_RECORD[$file]}"
IFS='|' read -r hash date size seen <<< "$record"
DB_FILE_RECORD["$file"]="$hash|$date|$size|1"
}
db_remove_unseen_after_scan() {
local file=""
local record=""
local hash=""
local date=""
local size=""
local seen=""
# Die Schlüsselliste wird von Bash einmalig als Schleifen-Snapshot erzeugt.
# Dadurch kann der jeweilige Eintrag ohne zusätzliche Missing-Liste entfernt werden.
for file in "${!DB_FILE_RECORD[@]}"
do
record="${DB_FILE_RECORD[$file]}"
IFS='|' read -r hash date size seen <<< "$record"
if [[ "$seen" != "1" ]]; then
db_remove_file_memory "$file"
COUNT_DB_REMOVED=$((COUNT_DB_REMOVED + 1))
fi
done
}
db_record_path_valid() {
local file="$1"
record_path_is_canonical_within "$file" "$DEST"
}
create_db_temp() {
local db_dir=""
db_dir="${DB_FILE%/*}"
[[ -d "$db_dir" && ! -L "$db_dir" ]] || return 1
mktemp "$db_dir/.hashes-work.XXXXXX"
}
db_load_once() {
local hash=""
local date=""
local size=""
local file=""
db_clear_memory
if [[ ! -f "$DB_FILE" || -L "$DB_FILE" || ! -s "$DB_FILE" ]]; then
return 2
fi
while IFS='|' read -r hash date size file
do
[[ -z "$hash$date$size$file" ]] && continue
if ! valid_hash "$hash" || \
! valid_datetime_value "$date" || \
! valid_size "$size" || \
! db_record_path_valid "$file" || \
! db_add_memory "$hash" "$date" "$size" "$file"; then
db_clear_memory
return 3
fi
done < "$DB_FILE"
(( DB_RECORD_COUNT > 0 )) || return 2
return 0
}
db_write_full() {
local hash=""
local file=""
local record=""
local record_hash=""
local date=""
local size=""
local seen=""
local db_temp=""
db_temp=$(create_db_temp) || {
mark_runtime_error "Temporäre DB-Datei konnte nicht sicher erstellt werden"
return 1
}
for hash in "${!DB_HASH_FILE[@]}"
do
file="${DB_HASH_FILE[$hash]}"
record="${DB_FILE_RECORD[$file]}"
IFS='|' read -r record_hash date size seen <<< "$record"
if db_record_path_valid "$file" && [[ "$record_hash" == "$hash" ]]; then
printf "%s|%s|%s|%s\n" "$hash" "$date" "$size" "$file" >> "$db_temp" || {
rm -f -- "$db_temp"
return 1
}
else
rm -f -- "$db_temp"
mark_runtime_error "Ungültiger oder widersprüchlicher Pfad im DB-Arbeitsspeicher: $file"
return 1
fi
done
safe_sync_path "$db_temp" || {
rm -f -- "$db_temp"
mark_runtime_error "Temporäre DB-Datei konnte nicht synchronisiert werden"
return 1
}
[[ ! -L "$DB_FILE" ]] || {
rm -f -- "$db_temp"
mark_runtime_error "DB-Ziel ist unerwartet ein symbolischer Link: $DB_FILE"
return 1
}
mv -f -- "$db_temp" "$DB_FILE" || {
rm -f -- "$db_temp"
mark_runtime_error "Datenbank konnte nicht atomar ersetzt werden: $DB_FILE"
return 1
}
set_single_file_permissions "$DB_FILE"
safe_sync_path "$DB_FILE" || return 1
safe_sync_dir "${DB_FILE%/*}" || return 1
DB_DIRTY=0
return 0
}
db_write_if_dirty() {
if [[ "$DB_DIRTY" -eq 0 ]]; then
log_info "Datenbank unverändert; kein Neuschreiben erforderlich"
return 0
fi
db_write_full
}
journal_append_original() {
local hash="$1"
local date="$2"
local size="$3"
local file="$4"
if [[ "$DRY_RUN" == "off" ]]; then
valid_hash "$hash" || return 1
valid_datetime_value "$date" || return 1
valid_size "$size" || return 1
db_record_path_valid "$file" || return 1
[[ ! -L "$JOURNAL_FILE" ]] || return 1
printf "%s|%s|%s|%s\n" "$hash" "$date" "$size" "$file" >> "$JOURNAL_FILE" || return 1
set_single_file_permissions "$JOURNAL_FILE"
safe_sync_path "$JOURNAL_FILE" || return 1
safe_sync_dir "${JOURNAL_FILE%/*}" || return 1
JOURNAL_NEEDS_CLEAR=1
fi
}
journal_clear() {
local journal_dir=""
[[ "$DRY_RUN" == "off" ]] || return 0
[[ -n "$JOURNAL_FILE" ]] || return 0
journal_dir="${JOURNAL_FILE%/*}"
[[ -d "$journal_dir" && ! -L "$journal_dir" && ! -L "$JOURNAL_FILE" ]] || return 1
if path_entry_exists "$JOURNAL_FILE"; then
[[ -f "$JOURNAL_FILE" ]] || return 1
rm -f -- "$JOURNAL_FILE" || return 1
safe_sync_dir "$journal_dir" || return 1
fi
JOURNAL_NEEDS_CLEAR=0
return 0
}
journal_recover() {
local hash=""
local date=""
local size=""
local file=""
local current_size=""
local current_hash=""
if [[ ! -e "$JOURNAL_FILE" ]]; then
return 0
fi
[[ -f "$JOURNAL_FILE" && ! -L "$JOURNAL_FILE" ]] || return 1
if [[ ! -s "$JOURNAL_FILE" ]]; then
JOURNAL_NEEDS_CLEAR=1
return 0
fi
log_warn "Journal gefunden, stelle noch nicht geschriebene DB-Einträge wieder her"
JOURNAL_NEEDS_CLEAR=1
while IFS='|' read -r hash date size file
do
[[ -z "$hash$date$size$file" ]] && continue
if valid_hash "$hash" && \
valid_datetime_value "$date" && \
valid_size "$size" && \
db_record_path_valid "$file" && \
[[ -f "$file" && ! -L "$file" ]]; then
current_size=$(stat_size "$file")
if [[ "$current_size" == "$size" ]]; then
if ! calculate_file_hash "$file"; then
continue
fi
current_hash="$HASH_RESULT"
if [[ "$current_hash" == "$hash" ]]; then
if [[ -z "${DB_HASH_FILE[$hash]+x}" ]]; then
db_add_memory "$hash" "$date" "$size" "$file" || return 1
DB_DIRTY=1
fi
COUNT_JOURNAL_RECOVERED=$((COUNT_JOURNAL_RECOVERED + 1))
fi
fi
fi
done < "$JOURNAL_FILE"
log_ok "Journal eingelesen"
log_info "Wiederhergestellte Journal-Einträge: $COUNT_JOURNAL_RECOVERED"
return 0
}
# ---------------------------------------------
# SOURCE-STATE-CACHE
# ---------------------------------------------
# Nur im copy-Modus aktiv. Im Arbeitsspeicher dient der exakte vollständige
# Quellpfad direkt als Schlüssel; dadurch ist bei bekannten Quellen kein eigener
# SHA256-Prozess für einen Pfadschlüssel nötig. Für den Skip werden nur der exakte
# vollständige Quellpfad, die Größe und mtime verglichen. Das persistente Format
# bleibt einschließlich ctime und Datei-Identität abwärtskompatibel:
# Pfadschlüssel|Größe|mtime|ctime|devino|Basename|Erweiterung|Hash|Quellpfad
declare -A SS_RECORD
declare -A SS_SEEN_KEY
source_state_key() {
local file="$1"
local existing_record=""
local existing_key=""
SOURCE_STATE_CURRENT_KEY=""
if [[ -n "${SS_RECORD[$file]+x}" ]]; then
existing_record="${SS_RECORD[$file]}"
existing_key="${existing_record%%|*}"
valid_hash "$existing_key" || return 1
SOURCE_STATE_CURRENT_KEY="$existing_key"
return 0
fi
calculate_text_hash "$file" || return 1
SOURCE_STATE_CURRENT_KEY="$HASH_RESULT"
return 0
}
source_state_clear_memory() {
SS_RECORD=()
SS_SEEN_KEY=()
}
source_state_load() {
local key=""
local size=""
local mtime=""
local ctime=""
local devino=""
local basename_value=""
local ext=""
local hash=""
local src_file=""
local record=""
source_state_clear_memory
SOURCE_STATE_DIRTY=0
SOURCE_STATE_FILE_EXISTED=0
if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" != "on" ]]; then
return 0
fi
[[ -n "$SOURCE_STATE_FILE" ]] || return 1
if ! path_entry_exists "$SOURCE_STATE_FILE"; then
SOURCE_STATE_DIRTY=1
return 0
fi
[[ -f "$SOURCE_STATE_FILE" && ! -L "$SOURCE_STATE_FILE" ]] || return 1
SOURCE_STATE_FILE_EXISTED=1
while IFS='|' read -r key size mtime ctime devino basename_value ext hash src_file
do
[[ -z "$key$size$mtime$ctime$devino$basename_value$ext$hash$src_file" ]] && continue
if valid_hash "$key" && \
valid_size "$size" && \
valid_time_number "$mtime" && \
valid_time_number "$ctime" && \
valid_devino "$devino" && \
[[ -n "$basename_value" ]] && \
[[ -n "$ext" ]] && \
valid_hash "$hash" && \
[[ -n "$src_file" ]] && \
! has_unsupported_record_chars "$basename_value" && \
! has_unsupported_record_chars "$src_file" && \
record_path_is_canonical_within "$src_file" "$SRC" && \
[[ -z "${SS_RECORD[$src_file]+x}" ]]; then
record="$key|$size|$mtime|$ctime|$devino|$basename_value|$ext|$hash|$src_file"
SS_RECORD["$src_file"]="$record"
COUNT_SOURCE_STATE_LOADED=$((COUNT_SOURCE_STATE_LOADED + 1))
else
COUNT_SOURCE_STATE_INVALID=$((COUNT_SOURCE_STATE_INVALID + 1))
SOURCE_STATE_DIRTY=1
fi
done < "$SOURCE_STATE_FILE"
}
source_state_should_skip() {
local file="$1"
local size="$2"
local mtime="$3"
local key=""
local record=""
local old_size=""
local old_mtime=""
local old_ctime=""
local old_devino=""
local old_basename=""
local old_ext=""
local old_hash=""
local old_file=""
local old_target=""
SOURCE_STATE_CURRENT_KEY=""
if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" != "on" ]]; then
return 1
fi
if [[ -z "${SS_RECORD[$file]+x}" ]]; then
source_state_key "$file" || return 1
return 1
fi
record="${SS_RECORD[$file]}"
IFS='|' read -r key old_size old_mtime old_ctime old_devino old_basename old_ext old_hash old_file <<< "$record"
valid_hash "$key" || return 1
SOURCE_STATE_CURRENT_KEY="$key"
old_target="${DB_HASH_FILE[$old_hash]:-}"
if [[ "$old_size" == "$size" && \
"$old_mtime" == "$mtime" && \
"$old_file" == "$file" && \
-n "$old_target" && \
-f "$old_target" && \
! -L "$old_target" ]]; then
return 0
fi
return 1
}
source_state_mark_seen() {
local file="$1"
[[ "$SOURCE_STATE_CACHE_EFFECTIVE" == "on" ]] || return 0
[[ -n "$file" ]] || return 1
SS_SEEN_KEY["$file"]=1
}
source_state_write_current() {
local file="$1"
local ext="$2"
local size="$3"
local mtime="$4"
local ctime="$5"
local devino="$6"
local hash="$7"
local key="${8:-$SOURCE_STATE_CURRENT_KEY}"
local basename_value=""
local new_record=""
[[ "$SOURCE_STATE_CACHE_EFFECTIVE" == "on" ]] || return 0
valid_hash "$hash" || return 0
basename_value="${file##*/}"
if has_unsupported_record_chars "$file" || \
has_unsupported_record_chars "$basename_value" || \
! record_path_is_canonical_within "$file" "$SRC" || \
! valid_size "$size" || \
! valid_stat_time_number "$mtime" || \
! valid_stat_time_number "$ctime" || \
! valid_devino "$devino" || \
[[ -z "$ext" ]]; then
COUNT_SOURCE_STATE_INVALID=$((COUNT_SOURCE_STATE_INVALID + 1))
return 0
fi
if ! valid_hash "$key"; then
source_state_key "$file" || {
COUNT_SOURCE_STATE_INVALID=$((COUNT_SOURCE_STATE_INVALID + 1))
return 0
}
key="$SOURCE_STATE_CURRENT_KEY"
fi
new_record="$key|$size|$mtime|$ctime|$devino|$basename_value|$ext|$hash|$file"
if [[ -z "${SS_RECORD[$file]+x}" || "${SS_RECORD[$file]}" != "$new_record" ]]; then
SOURCE_STATE_DIRTY=1
fi
SS_RECORD["$file"]="$new_record"
source_state_mark_seen "$file" || return 0
COUNT_SOURCE_STATE_WRITTEN=$((COUNT_SOURCE_STATE_WRITTEN + 1))
}
source_state_finalize() {
local file=""
local record=""
local source_state_dir=""
if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" != "on" ]]; then
return 0
fi
for file in "${!SS_RECORD[@]}"
do
if [[ -z "${SS_SEEN_KEY[$file]+x}" ]]; then
COUNT_SOURCE_STATE_REMOVED=$((COUNT_SOURCE_STATE_REMOVED + 1))
SOURCE_STATE_DIRTY=1
fi
done
[[ -n "$SOURCE_STATE_FILE" && ! -L "$SOURCE_STATE_FILE" ]] || return 1
if [[ "$SOURCE_STATE_DIRTY" -eq 0 && "$SOURCE_STATE_FILE_EXISTED" -eq 1 ]]; then
log_info "Source-State unverändert; kein Neuschreiben erforderlich"
return 0
fi
source_state_dir="${SOURCE_STATE_FILE%/*}"
[[ -d "$source_state_dir" && ! -L "$source_state_dir" ]] || return 1
SOURCE_STATE_NEW=$(mktemp "$source_state_dir/.source_state.tmp.XXXXXX") || return 1
for file in "${!SS_SEEN_KEY[@]}"
do
[[ -n "${SS_RECORD[$file]+x}" ]] || {
rm -f -- "$SOURCE_STATE_NEW"
SOURCE_STATE_NEW=""
return 1
}
record="${SS_RECORD[$file]}"
printf '%s\n' "$record" >> "$SOURCE_STATE_NEW" || {
rm -f -- "$SOURCE_STATE_NEW"
SOURCE_STATE_NEW=""
return 1
}
done
[[ -f "$SOURCE_STATE_NEW" && ! -L "$SOURCE_STATE_NEW" ]] || return 1
safe_sync_path "$SOURCE_STATE_NEW" || return 1
mv -f -- "$SOURCE_STATE_NEW" "$SOURCE_STATE_FILE" || return 1
SOURCE_STATE_NEW=""
set_single_file_permissions "$SOURCE_STATE_FILE"
safe_sync_path "$SOURCE_STATE_FILE" || return 1
safe_sync_dir "${SOURCE_STATE_FILE%/*}"
}
# ---------------------------------------------
# ABSTURZSICHERE TRANSFERS UND RECOVERY
# ---------------------------------------------
ensure_recovery_root() {
ensure_safe_directory "$RECOVERY_DIR" || return 1
chmod 700 "$RECOVERY_DIR" 2>/dev/null || true
if [[ -n "$CURRENT_UID" && -n "$CURRENT_GID" ]]; then
chown "$CURRENT_UID:$CURRENT_GID" "$RECOVERY_DIR" 2>/dev/null || true
fi
return 0
}
ensure_recovery_quarantine() {
ensure_recovery_root || return 1
ensure_safe_directory "$RECOVERY_QUARANTINE_DIR" || return 1
chmod 700 "$RECOVERY_QUARANTINE_DIR" 2>/dev/null || true
if [[ -n "$CURRENT_UID" && -n "$CURRENT_GID" ]]; then
chown "$CURRENT_UID:$CURRENT_GID" "$RECOVERY_QUARANTINE_DIR" 2>/dev/null || true
fi
return 0
}
cleanup_empty_recovery_dirs() {
[[ "$DRY_RUN" == "off" ]] || return 0
if [[ -d "$RECOVERY_QUARANTINE_DIR" && ! -L "$RECOVERY_QUARANTINE_DIR" ]]; then
rmdir -- "$RECOVERY_QUARANTINE_DIR" 2>/dev/null || true
fi
if [[ -d "$RECOVERY_DIR" && ! -L "$RECOVERY_DIR" ]]; then
rmdir -- "$RECOVERY_DIR" 2>/dev/null || true
fi
return 0
}
transaction_id_for() {
local source_file="$1"
local target_file="$2"
local expected_hash="$3"
HASH_RESULT=""
if ! IFS=' ' read -r HASH_RESULT _ < <(
printf "%s\0%s\0%s" "$source_file" "$target_file" "$expected_hash" |
sha256sum
); then
HASH_RESULT=""
return 1
fi
valid_hash "$HASH_RESULT"
}
source_matches_snapshot() {
local file="$1"
local expected_hash="$2"
local expected_size="$3"
local expected_devino="$4"
local expected_mtime="$5"
local expected_ctime="$6"
[[ -f "$file" && ! -L "$file" ]] || return 1
# Vollständiger Schnappschuss vor und nach dem Inhaltslesen: So bleiben alle
# bisherigen Identitäts-, Zeit- und Größenprüfungen erhalten, gleichzeitig
# werden statt vier einzelner stat-Prozesse nur zwei kohärente Abrufe benötigt.
read_file_snapshot "$file" || return 1
snapshot_matches_expected \
"$expected_size" "$expected_devino" "$expected_mtime" "$expected_ctime" || return 1
calculate_file_hash "$file" || return 1
[[ "$HASH_RESULT" == "$expected_hash" ]] || return 1
read_file_snapshot "$file" || return 1
snapshot_matches_expected \
"$expected_size" "$expected_devino" "$expected_mtime" "$expected_ctime" || return 1
return 0
}
write_transaction_marker() {
local marker="$1"
local mode="$2"
local source_file="$3"
local temp_file="$4"
local target_file="$5"
local expected_hash="$6"
local expected_size="$7"
local expected_devino="$8"
local expected_mtime="$9"
local expected_ctime="${10}"
local marker_temp=""
ensure_recovery_root || return 1
[[ ! -L "$marker" ]] || return 1
marker_temp=$(mktemp "$RECOVERY_DIR/.marker.XXXXXX") || return 1
{
printf '%s\0' "2"
printf '%s\0' "$mode"
printf '%s\0' "$source_file"
printf '%s\0' "$temp_file"
printf '%s\0' "$target_file"
printf '%s\0' "$expected_hash"
printf '%s\0' "$expected_size"
printf '%s\0' "$expected_devino"
printf '%s\0' "$expected_mtime"
printf '%s\0' "$expected_ctime"
} > "$marker_temp" || {
rm -f -- "$marker_temp"
return 1
}
chmod 600 "$marker_temp" 2>/dev/null || true
safe_sync_path "$marker_temp" || {
rm -f -- "$marker_temp"
return 1
}
mv -f -- "$marker_temp" "$marker" || {
rm -f -- "$marker_temp"
return 1
}
chmod 600 "$marker" 2>/dev/null || true
safe_sync_path "$marker" && safe_sync_dir "$RECOVERY_DIR"
}
read_transaction_marker() {
local marker="$1"
local version=""
REC_MODE=""
REC_SOURCE=""
REC_TEMP=""
REC_TARGET=""
REC_HASH=""
REC_SIZE=""
REC_DEVINO=""
REC_MTIME=""
REC_CTIME=""
REC_LEGACY=0
[[ -f "$marker" && ! -L "$marker" ]] || return 1
exec 9< "$marker" || return 1
IFS= read -r -d '' version <&9 || { exec 9<&-; return 1; }
IFS= read -r -d '' REC_MODE <&9 || { exec 9<&-; return 1; }
IFS= read -r -d '' REC_SOURCE <&9 || { exec 9<&-; return 1; }
IFS= read -r -d '' REC_TEMP <&9 || { exec 9<&-; return 1; }
IFS= read -r -d '' REC_TARGET <&9 || { exec 9<&-; return 1; }
IFS= read -r -d '' REC_HASH <&9 || { exec 9<&-; return 1; }
IFS= read -r -d '' REC_SIZE <&9 || { exec 9<&-; return 1; }
if [[ "$version" == "2" ]]; then
IFS= read -r -d '' REC_DEVINO <&9 || { exec 9<&-; return 1; }
IFS= read -r -d '' REC_MTIME <&9 || { exec 9<&-; return 1; }
IFS= read -r -d '' REC_CTIME <&9 || { exec 9<&-; return 1; }
elif [[ "$version" == "1" ]]; then
REC_LEGACY=1
else
exec 9<&-
return 1
fi
exec 9<&-
[[ "$REC_MODE" == "move" || "$REC_MODE" == "copy" ]] || return 1
valid_hash "$REC_HASH" || return 1
valid_size "$REC_SIZE" || return 1
if [[ "$version" == "2" ]]; then
[[ -n "$REC_DEVINO" ]] || return 1
valid_time_number "$REC_MTIME" || return 1
valid_time_number "$REC_CTIME" || return 1
fi
has_unsupported_record_chars "$REC_SOURCE" && return 1
has_unsupported_record_chars "$REC_TARGET" && return 1
has_unsupported_record_chars "$REC_TEMP" && return 1
local raw_source="$REC_SOURCE"
local raw_target="$REC_TARGET"
local raw_temp="$REC_TEMP"
REC_SOURCE=$(canonicalize_path "$raw_source") || return 1
REC_TARGET=$(canonicalize_path "$raw_target") || return 1
REC_TEMP=$(canonicalize_path "$raw_temp") || return 1
[[ "$REC_SOURCE" == "$raw_source" ]] || return 1
[[ "$REC_TARGET" == "$raw_target" ]] || return 1
[[ "$REC_TEMP" == "$raw_temp" ]] || return 1
if ! path_is_within "$REC_SOURCE" "$SRC" && ! path_is_within "$REC_SOURCE" "$DEST"; then
return 1
fi
path_is_within "$REC_TARGET" "$DEST" || return 1
path_is_within "$REC_TEMP" "$DEST" || return 1
[[ "${REC_TEMP%/*}" == "${REC_TARGET%/*}" ]] || return 1
case "${REC_TEMP##*/}" in
.medien-sortierer-*.part) ;;
*) return 1 ;;
esac
return 0
}
file_matches_expected() {
local file="$1"
local expected_hash="$2"
local expected_size="$3"
local current_size=""
local current_hash=""
[[ -f "$file" && ! -L "$file" ]] || return 1
current_size=$(stat_size "$file")
[[ "$current_size" == "$expected_size" ]] || return 1
calculate_file_hash "$file" || return 1
current_hash="$HASH_RESULT"
[[ "$current_hash" == "$expected_hash" ]]
}
run_copy_to_temp() {
local source_file="$1"
local temp_file="$2"
local copy_exit=0
cp -p -- "$source_file" "$temp_file" &
ACTIVE_TRANSFER_PID=$!
wait "$ACTIVE_TRANSFER_PID" || copy_exit=$?
ACTIVE_TRANSFER_PID=""
return "$copy_exit"
}
publish_temp_no_clobber() {
local temp_file="$1"
local target_file="$2"
local target_dir=""
[[ -f "$temp_file" && ! -L "$temp_file" ]] || return 1
! path_entry_exists "$target_file" || return 1
target_dir="${target_file%/*}"
[[ -d "$target_dir" && ! -L "$target_dir" ]] || return 1
# Das Erzeugen eines Hardlinks ist atomar und schlägt fehl, wenn der Zielname
# bereits existiert. Dadurch wird auch bei einem Rennen nichts überschrieben.
ln -- "$temp_file" "$target_file" || return 1
safe_sync_dir "$target_dir" || return 1
rm -f -- "$temp_file" || return 1
safe_sync_dir "$target_dir" || return 1
return 0
}
quarantine_file() {
local file="$1"
local transaction_id="$2"
local source_hash=""
local source_size=""
local source_devino=""
local source_mtime=""
local source_ctime=""
local quarantine_temp=""
local quarantine_target=""
local counter=1
[[ -f "$file" && ! -L "$file" ]] || return 1
path_is_within "$(canonicalize_path "$file")" "$DEST" || return 1
ensure_recovery_quarantine || return 1
verify_atomic_publish_support "$RECOVERY_QUARANTINE_DIR" || return 1
read_file_snapshot "$file" || return 1
source_size="$FILE_SNAPSHOT_SIZE"
source_devino="$FILE_SNAPSHOT_DEVINO"
source_mtime="$FILE_SNAPSHOT_MTIME"
source_ctime="$FILE_SNAPSHOT_CTIME"
calculate_file_hash "$file" || return 1
source_hash="$HASH_RESULT"
valid_size "$source_size" || return 1
valid_hash "$source_hash" || return 1
quarantine_temp=$(mktemp "$RECOVERY_QUARANTINE_DIR/.quarantine.XXXXXX") || return 1
if ! run_copy_to_temp "$file" "$quarantine_temp" || \
! file_matches_expected "$quarantine_temp" "$source_hash" "$source_size" || \
! source_matches_snapshot "$file" "$source_hash" "$source_size" \
"$source_devino" "$source_mtime" "$source_ctime" || \
! safe_sync_path "$quarantine_temp"; then
rm -f -- "$quarantine_temp"
return 1
fi
while true
do
quarantine_target="$RECOVERY_QUARANTINE_DIR/${transaction_id}_${counter}.corrupt"
! path_entry_exists "$quarantine_target" && break
counter=$((counter + 1))
done
publish_temp_no_clobber "$quarantine_temp" "$quarantine_target" || {
rm -f -- "$quarantine_temp"
return 1
}
file_matches_expected "$quarantine_target" "$source_hash" "$source_size" || return 1
source_matches_snapshot "$file" "$source_hash" "$source_size" \
"$source_devino" "$source_mtime" "$source_ctime" || return 1
rm -f -- "$file" || return 1
safe_sync_dir "${file%/*}" || return 1
chmod 600 "$quarantine_target" 2>/dev/null || true
COUNT_RECOVERY_QUARANTINED=$((COUNT_RECOVERY_QUARANTINED + 1))
log_warn "Datei wurde verlustfrei in die Recovery-Quarantäne verschoben: $quarantine_target"
return 0
}
remove_transaction_marker() {
local marker="$1"
[[ -f "$marker" && ! -L "$marker" ]] || return 1
rm -f -- "$marker" || return 1
safe_sync_dir "$RECOVERY_DIR"
}
durable_transfer() {
local source_file="$1"
local target_file="$2"
local expected_hash="$3"
local expected_size="$4"
local mode="$5"
local existing_marker="${6:-}"
local existing_temp="${7:-}"
local target_dir=""
local transaction_id=""
local temp_file=""
local marker=""
local source_devino=""
local source_mtime=""
local source_ctime=""
if [[ "$DRY_RUN" == "on" ]]; then
return 0
fi
valid_hash "$expected_hash" || return 1
valid_size "$expected_size" || return 1
[[ "$mode" == "move" || "$mode" == "copy" ]] || return 1
[[ -f "$source_file" && ! -L "$source_file" ]] || return 1
local source_canonical=""
local target_canonical=""
safe_parent_directory_for_target "$target_file" || return 1
source_canonical=$(canonicalize_path "$source_file") || return 1
target_canonical=$(canonicalize_path "$target_file") || return 1
# Ein abweichender kanonischer Pfad weist auf .. oder einen Symlink in einer
# Pfadkomponente hin. Beides wird bei Root-Ausführung nicht akzeptiert.
[[ "$source_canonical" == "$source_file" ]] || return 1
[[ "$target_canonical" == "$target_file" ]] || return 1
source_file="$source_canonical"
target_file="$target_canonical"
if ! path_is_within "$source_file" "$SRC" && ! path_is_within "$source_file" "$DEST"; then
return 1
fi
path_is_within "$target_file" "$DEST" || return 1
read_file_snapshot "$source_file" || return 1
source_devino="$FILE_SNAPSHOT_DEVINO"
source_mtime="$FILE_SNAPSHOT_MTIME"
source_ctime="$FILE_SNAPSHOT_CTIME"
source_matches_snapshot "$source_file" "$expected_hash" "$expected_size" \
"$source_devino" "$source_mtime" "$source_ctime" || return 1
! path_entry_exists "$target_file" || return 1
target_dir="${target_file%/*}"
[[ -d "$target_dir" && ! -L "$target_dir" ]] || return 1
verify_atomic_publish_support "$target_dir" || {
log_warn "Ziel unterstützt das sichere atomare Veröffentlichen ohne Überschreiben nicht: $target_dir"
return 1
}
check_free_space_for_file "$target_dir" "$expected_size" || {
log_warn "Nicht genügend freier Platz für Datei plus Reserve: $source_file"
return 1
}
transaction_id_for "$source_file" "$target_file" "$expected_hash" || return 1
transaction_id="$HASH_RESULT"
if [[ -n "$existing_temp" ]]; then
temp_file="$existing_temp"
else
temp_file="$target_dir/.medien-sortierer-${transaction_id}.part"
fi
if [[ -n "$existing_marker" ]]; then
marker="$existing_marker"
else
marker="$RECOVERY_DIR/${transaction_id}.txn"
fi
if [[ -L "$temp_file" || -L "$marker" ]]; then
return 1
fi
write_transaction_marker \
"$marker" "$mode" "$source_file" "$temp_file" "$target_file" \
"$expected_hash" "$expected_size" "$source_devino" "$source_mtime" "$source_ctime" || return 1
if path_entry_exists "$temp_file"; then
if [[ -f "$temp_file" && ! -L "$temp_file" ]]; then
rm -f -- "$temp_file" || return 1
else
return 1
fi
fi
run_copy_to_temp "$source_file" "$temp_file" || return 1
if ! file_matches_expected "$temp_file" "$expected_hash" "$expected_size" || \
! source_matches_snapshot "$source_file" "$expected_hash" "$expected_size" \
"$source_devino" "$source_mtime" "$source_ctime"; then
quarantine_file "$temp_file" "${transaction_id}_source_changed_before_publish" || true
remove_transaction_marker "$marker" || true
return 1
fi
safe_sync_path "$temp_file" || return 1
if ! publish_temp_no_clobber "$temp_file" "$target_file"; then
if [[ -f "$temp_file" && ! -L "$temp_file" ]]; then
quarantine_file "$temp_file" "${transaction_id}_target_conflict" || true
fi
remove_transaction_marker "$marker" || true
return 1
fi
if ! file_matches_expected "$target_file" "$expected_hash" "$expected_size"; then
return 1
fi
# Die Quelle wird auch im copy-Modus noch einmal geprüft. Eine während des
# Transfers weitergeschriebene Datei darf nicht als fertige Kopie gelten.
if ! source_matches_snapshot "$source_file" "$expected_hash" "$expected_size" \
"$source_devino" "$source_mtime" "$source_ctime"; then
quarantine_file "$target_file" "${transaction_id}_source_changed_after_publish" || true
remove_transaction_marker "$marker" || true
return 1
fi
apply_file_permissions "$target_file"
safe_sync_path "$target_file" || return 1
safe_sync_dir "$target_dir" || return 1
if [[ "$mode" == "move" ]]; then
source_matches_snapshot "$source_file" "$expected_hash" "$expected_size" \
"$source_devino" "$source_mtime" "$source_ctime" || return 1
rm -f -- "$source_file" || return 1
safe_sync_dir "${source_file%/*}" || return 1
fi
remove_transaction_marker "$marker" || return 1
return 0
}
move_existing_file() {
local source_file="$1"
local target_file="$2"
local known_hash="${3:-}"
local known_size="${4:-}"
local hash="$known_hash"
local size="$known_size"
if [[ "$DRY_RUN" == "on" ]]; then
return 0
fi
[[ -f "$source_file" && ! -L "$source_file" ]] || return 1
[[ -n "$size" ]] || size=$(stat_size "$source_file")
if [[ -z "$hash" ]]; then
calculate_file_hash "$source_file" || return 1
hash="$HASH_RESULT"
fi
durable_transfer "$source_file" "$target_file" "$hash" "$size" "move"
}
transfer_file() {
local source_file="$1"
local target_file="$2"
local hash="$3"
local size="$4"
durable_transfer "$source_file" "$target_file" "$hash" "$size" "$PROCESS_MODE"
}
recover_transactions() {
local marker=""
local marker_name=""
local transaction_id=""
local source_exists=0
local source_ok=0
local temp_ok=0
local target_ok=0
if [[ "$DRY_RUN" != "off" || ! -d "$RECOVERY_DIR" ]]; then
return 0
fi
shopt -s nullglob
for marker in "$RECOVERY_DIR"/*.txn
do
source_exists=0
source_ok=0
temp_ok=0
target_ok=0
marker_name="${marker##*/}"
transaction_id="${marker_name%.txn}"
if ! read_transaction_marker "$marker"; then
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
mark_runtime_error "Ungültiger oder unsicherer Recovery-Marker: $marker"
continue
fi
path_entry_exists "$REC_SOURCE" && source_exists=1
source_matches_snapshot "$REC_SOURCE" "$REC_HASH" "$REC_SIZE" \
"$REC_DEVINO" "$REC_MTIME" "$REC_CTIME" && source_ok=1
file_matches_expected "$REC_TEMP" "$REC_HASH" "$REC_SIZE" && temp_ok=1
file_matches_expected "$REC_TARGET" "$REC_HASH" "$REC_SIZE" && target_ok=1
# Existiert die Quelle noch, hat sich aber verändert, gehören Ziel/Temp zu
# einem alten Zwischenstand. Sie werden gesichert und die aktuelle Quelle
# bleibt unangetastet für einen neuen Lauf.
if [[ "$source_exists" -eq 1 && "$source_ok" -eq 0 ]]; then
if [[ "$target_ok" -eq 1 ]]; then
quarantine_file "$REC_TARGET" "${transaction_id}_stale_target" || {
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
mark_runtime_error "Veraltetes Recovery-Ziel konnte nicht gesichert werden: $REC_TARGET"
continue
}
elif path_entry_exists "$REC_TARGET"; then
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
mark_runtime_error "Unsicheres Recovery-Ziel bei veränderter Quelle: $REC_TARGET"
continue
fi
if [[ "$temp_ok" -eq 1 ]]; then
rm -f -- "$REC_TEMP" || {
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
continue
}
safe_sync_dir "${REC_TEMP%/*}" || true
elif path_entry_exists "$REC_TEMP"; then
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
mark_runtime_error "Unsichere Recovery-Temp-Datei bei veränderter Quelle: $REC_TEMP"
continue
fi
remove_transaction_marker "$marker" || {
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
continue
}
COUNT_RECOVERY_COMPLETED=$((COUNT_RECOVERY_COMPLETED + 1))
COUNT_RECOVERY_REPAIRED=$((COUNT_RECOVERY_REPAIRED + 1))
log_warn "Quelle wurde während eines alten Transfers verändert und wird neu verarbeitet: $REC_SOURCE"
continue
fi
if [[ "$target_ok" -eq 1 ]]; then
if [[ "$temp_ok" -eq 1 ]]; then
rm -f -- "$REC_TEMP" || {
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
continue
}
safe_sync_dir "${REC_TEMP%/*}" || true
elif path_entry_exists "$REC_TEMP"; then
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
mark_runtime_error "Ungültige Temp-Datei neben intaktem Recovery-Ziel: $REC_TEMP"
continue
fi
if [[ "$REC_MODE" == "move" && "$source_ok" -eq 1 ]]; then
if [[ "$REC_LEGACY" -eq 1 ]]; then
log_warn "Alter v1-Recovery-Marker: intakte Quelle bleibt vorsichtshalber erhalten: $REC_SOURCE"
else
rm -f -- "$REC_SOURCE" || {
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
mark_runtime_error "Recovery konnte intakte Quelle nicht entfernen: $REC_SOURCE"
continue
}
safe_sync_dir "${REC_SOURCE%/*}" || {
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
continue
}
fi
fi
remove_transaction_marker "$marker" || {
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
continue
}
COUNT_RECOVERY_COMPLETED=$((COUNT_RECOVERY_COMPLETED + 1))
continue
fi
if path_entry_exists "$REC_TARGET"; then
if [[ "$source_ok" -eq 1 || "$temp_ok" -eq 1 ]]; then
quarantine_file "$REC_TARGET" "$transaction_id" || {
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
mark_runtime_error "Beschädigtes Recovery-Ziel konnte nicht gesichert werden: $REC_TARGET"
continue
}
else
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
mark_runtime_error "Beschädigtes Ziel ohne intakte Quelle oder Temp-Datei: $REC_TARGET"
continue
fi
fi
if [[ "$temp_ok" -eq 1 ]]; then
ensure_safe_directory "${REC_TARGET%/*}" || {
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
continue
}
publish_temp_no_clobber "$REC_TEMP" "$REC_TARGET" || {
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
mark_runtime_error "Recovery konnte Temp-Datei nicht sicher veröffentlichen: $REC_TEMP"
continue
}
file_matches_expected "$REC_TARGET" "$REC_HASH" "$REC_SIZE" || {
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
mark_runtime_error "Recovery-Zieldatei ist nach Veröffentlichung nicht intakt: $REC_TARGET"
continue
}
apply_file_permissions "$REC_TARGET"
if [[ "$REC_MODE" == "move" && "$source_ok" -eq 1 ]]; then
if [[ "$REC_LEGACY" -eq 1 ]]; then
log_warn "Alter v1-Recovery-Marker: Quelle bleibt vorsichtshalber erhalten: $REC_SOURCE"
else
rm -f -- "$REC_SOURCE" || {
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
continue
}
safe_sync_dir "${REC_SOURCE%/*}" || {
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
continue
}
fi
fi
remove_transaction_marker "$marker" || {
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
continue
}
COUNT_RECOVERY_COMPLETED=$((COUNT_RECOVERY_COMPLETED + 1))
COUNT_RECOVERY_REPAIRED=$((COUNT_RECOVERY_REPAIRED + 1))
continue
fi
if [[ "$source_ok" -eq 1 ]]; then
if durable_transfer \
"$REC_SOURCE" "$REC_TARGET" "$REC_HASH" "$REC_SIZE" "$REC_MODE" \
"$marker" "$REC_TEMP"; then
COUNT_RECOVERY_COMPLETED=$((COUNT_RECOVERY_COMPLETED + 1))
COUNT_RECOVERY_REPAIRED=$((COUNT_RECOVERY_REPAIRED + 1))
else
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
mark_runtime_error "Recovery-Neuübertragung ist fehlgeschlagen: $REC_SOURCE"
fi
continue
fi
COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
mark_runtime_error "Recovery nicht möglich; Quelle, Temp-Datei und Ziel sind nicht intakt: $marker"
done
shopt -u nullglob
[[ "$COUNT_RECOVERY_FAILED" -eq 0 ]]
}
handle_orphan_transfer_temp() {
local temp_file="$1"
local temp_name=""
local transaction_id=""
local expected_marker=""
[[ "$DRY_RUN" == "off" ]] || return 0
[[ -f "$temp_file" && ! -L "$temp_file" ]] || return 0
temp_name="${temp_file##*/}"
if [[ ! "$temp_name" =~ ^[.]medien-sortierer-([a-fA-F0-9]{64})[.]part$ ]]; then
log_warn "Datei mit reserviertem Präfix, aber ungültigem Temp-Namen bleibt unangetastet: $temp_file"
return 0
fi
transaction_id="${BASH_REMATCH[1]}"
expected_marker="$RECOVERY_DIR/${transaction_id}.txn"
if [[ ! -f "$expected_marker" ]]; then
quarantine_file "$temp_file" "${transaction_id}_orphan" || {
mark_runtime_error "Verwaiste Transferdatei konnte nicht gesichert werden: $temp_file"
return 1
}
fi
return 0
}
scan_orphan_transfer_temps() {
local temp_file=""
local orphan_list="$TMP_ROOT/orphan_transfer_temps"
[[ "$DRY_RUN" == "off" && -d "$DEST" ]] || return 0
# Feste NUL-Liste: Eine während der Prüfung verschobene .part-Datei kann nicht
# im selben Durchgang unter ihrem neuen Pfad erneut auftauchen.
find "$DEST" -xdev \
-path "$RECOVERY_DIR" -prune -o \
-type f -name '.medien-sortierer-*.part' -print0 \
> "$orphan_list" || return 1
while IFS= read -r -d '' temp_file
do
handle_orphan_transfer_temp "$temp_file" || return 1
done < "$orphan_list"
rm -f -- "$orphan_list"
return 0
}
# Recovery muss vor DB-Prüfung, Zielscan und Input-Verarbeitung laufen.
if ! recover_transactions; then
log_error "Mindestens eine unvollständige Dateioperation konnte nicht sicher repariert werden. Der normale Lauf wird nicht gestartet."
exit 1
fi
cleanup_empty_recovery_dirs
# ---------------------------------------------
# ZIP-BERICHT
# ---------------------------------------------
record_skipped_zip() {
local file="$1"
local readable_path=""
COUNT_ZIP_SKIPPED=$((COUNT_ZIP_SKIPPED + 1))
if [[ "$PROCESS_MODE" == "copy" && -n "${ZIP_PATH_LIST:-}" ]]; then
readable_path="${file//$'\r'/\\r}"
readable_path="${readable_path//$'\n'/\\n}"
printf "%s\n" "$readable_path" >> "$ZIP_PATH_LIST"
fi
}
write_zip_report() {
local report_temp=""
if [[ "$DRY_RUN" != "off" ]]; then
return 0
fi
if [[ -L "$ZIP_REPORT_FILE" ]]; then
mark_runtime_error "ZIP-Bericht ist unerwartet ein symbolischer Link: $ZIP_REPORT_FILE"
return 1
fi
if [[ "$PROCESS_MODE" != "copy" || "$COUNT_ZIP_SKIPPED" -eq 0 ]]; then
if path_entry_exists "$ZIP_REPORT_FILE"; then
[[ -f "$ZIP_REPORT_FILE" ]] || return 1
rm -f -- "$ZIP_REPORT_FILE" || return 1
safe_sync_dir "$DEST" || return 1
fi
return 0
fi
report_temp=$(mktemp "$DEST/.zip-report.XXXXXX") || return 1
{
echo "Übersprungene ZIP-Dateien im Copy-Modus"
echo "Letzte Aktualisierung: $(date '+%d.%m.%Y %H:%M:%S')"
echo "Anzahl: $COUNT_ZIP_SKIPPED"
echo
echo "Die ZIP-Dateien wurden nicht geöffnet, verändert oder gelöscht."
echo
cat "$ZIP_PATH_LIST"
} > "$report_temp" || {
rm -f -- "$report_temp"
return 1
}
safe_sync_path "$report_temp" || {
rm -f -- "$report_temp"
return 1
}
if mv -f -- "$report_temp" "$ZIP_REPORT_FILE"; then
set_single_file_permissions "$ZIP_REPORT_FILE"
safe_sync_path "$ZIP_REPORT_FILE" && safe_sync_dir "$DEST"
else
rm -f -- "$report_temp"
mark_runtime_error "ZIP-Bericht konnte nicht geschrieben werden: $ZIP_REPORT_FILE"
return 1
fi
}
# ---------------------------------------------
# BESTEHENDE DATEI IM DEST NEU BEHANDELN
# ---------------------------------------------
process_existing_dest_file() {
local file="$1"
local reason="$2"
local ext=""
local size=""
local hash=""
local old_file=""
local is_duplicate=0
local category=""
local target=""
local final_size=""
[[ ! -f "$file" && "$DRY_RUN" == "off" ]] && return 0
ext="${file##*.}"
ext="${ext,,}"
is_media_extension "$ext" || return 0
COUNT_DEST_PROCESSED=$((COUNT_DEST_PROCESSED + 1))
db_remove_file_memory "$file"
set_datetime_from_exif "$file"
[[ "$DATETIME" != "UNKNOWN" ]] || COUNT_UNKNOWN=$((COUNT_UNKNOWN + 1))
size=$(stat_size "$file")
calculate_file_hash "$file" || {
mark_runtime_error "SHA256 konnte bei der Zielprüfung nicht berechnet werden: $file"
return 1
}
hash="$HASH_RESULT"
if [[ -n "${DB_HASH_FILE[$hash]+x}" ]]; then
old_file="${DB_HASH_FILE[$hash]}"
if [[ "$DRY_RUN" == "on" || -f "$old_file" ]]; then
is_duplicate=1
COUNT_DUPLICATE=$((COUNT_DUPLICATE + 1))
fi
fi
if [[ "$is_duplicate" -eq 1 ]]; then
category="DUPLICATES"
else
set_category_from_ext "$ext"
category="$CATEGORY_RESULT"
fi
if ! get_unique_target "$category" "$YEAR" "$MONTH" "$DATETIME" "$ext" "$is_duplicate" "$file"; then
COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
mark_runtime_error "Sicheres Ziel konnte nicht bestimmt werden: $file"
return 1
fi
target="$UNIQUE_TARGET_RESULT"
if [[ "$file" != "$target" ]]; then
if move_existing_file "$file" "$target" "$hash" "$size"; then
file="$target"
else
COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
mark_runtime_error "Datei aus Zielprüfung konnte nicht neu einsortiert werden ($reason): $file"
return 1
fi
fi
if [[ "$is_duplicate" -eq 1 ]]; then
COUNT_DUPLICATE_STORED=$((COUNT_DUPLICATE_STORED + 1))
return 0
fi
if [[ "$DRY_RUN" == "on" ]]; then
final_size="$size"
else
final_size=$(stat_size "$file")
fi
db_remove_hash_memory "$hash"
db_add_memory "$hash" "$DATETIME" "$final_size" "$file" || {
mark_runtime_error "Zieldatei konnte nicht eindeutig in die DB aufgenommen werden: $file"
return 1
}
DB_DIRTY=1
db_mark_seen "$file"
COUNT_ORIGINAL=$((COUNT_ORIGINAL + 1))
return 0
}
handle_dest_integrity_mismatch() {
local file="$1"
local reason="$2"
local transaction_id=""
db_remove_file_memory "$file"
calculate_text_hash "$file" || {
mark_runtime_error "Quarantäne-ID konnte nicht sicher berechnet werden: $file"
return 1
}
transaction_id="dest_integrity_$HASH_RESULT"
if [[ "$DRY_RUN" == "on" ]]; then
log_warn "DRY_RUN: Auffällige Zieldatei würde in Quarantäne verschoben ($reason): $file"
return 0
fi
if quarantine_file "$file" "$transaction_id"; then
mark_runtime_error "Auffällige Zieldatei wurde zur Sicherheit quarantänisiert ($reason): $file"
return 0
fi
mark_runtime_error "Auffällige Zieldatei konnte nicht sicher quarantänisiert werden ($reason): $file"
return 1
}
# ---------------------------------------------
# DB EINMALIG LADEN ODER NEU AUFBAUEN
# ---------------------------------------------
if [[ "$DATABASE_MODE" == "temporary" && "$DRY_RUN" == "off" ]]; then
DB_REBUILD=1
DB_REBUILD_REASON="Temporäre Datenbank"
db_clear_memory
log_info "Temporäre Datenbank wird aus dem aktuellen Zielbestand aufgebaut"
else
db_load_once
DB_LOAD_STATUS=$?
case "$DB_LOAD_STATUS" in
0)
DB_REBUILD=0
DB_DIRTY=0
log_info "Datenbank vorhanden und formal gültig"
;;
2)
DB_REBUILD=1
if [[ ! -f "$DB_FILE" ]]; then
DB_REBUILD_REASON="DB fehlt"
log_warn "Datenbank fehlt"
else
DB_REBUILD_REASON="DB leer"
log_warn "Datenbank ist leer"
fi
;;
*)
DB_REBUILD=1
DB_REBUILD_REASON="DB formal fehlerhaft oder widersprüchlich"
log_warn "Datenbank wirkt formal fehlerhaft, widersprüchlich oder hat ein altes Format"
;;
esac
fi
if [[ "$DB_REBUILD" -eq 1 ]]; then
log_info "Starte DB-Neuaufbau mit Strukturprüfung"
log_info "Grund: $DB_REBUILD_REASON"
# Bei einem Rebuild gibt es keinen normalen Schnellscan. Daher werden .part-
# Dateien einmal gezielt geprüft, bevor der Zielbestand neu aufgebaut wird.
scan_orphan_transfer_temps || {
log_error "Verwaiste Transferdateien konnten nicht sicher behandelt werden"
exit 1
}
db_clear_memory
DB_DIRTY=1
if [[ -d "$DEST" ]]; then
if ! find "$DEST" -xdev \
-path "$RECOVERY_DIR" -prune -o \
-type f \
! -name '.hashes.db*' \
! -name '.hashes.journal*' \
! -name '.source_state.db*' \
! -name '.medien-sortierer-*.part' \
-print0 > "$FILE_LIST"; then
log_error "Zielbestand konnte für den DB-Neuaufbau nicht vollständig erfasst werden"
exit 1
fi
else
: > "$FILE_LIST"
fi
while IFS= read -r -d '' file
do
[[ ! -f "$file" && "$DRY_RUN" == "off" ]] && continue
media_path_supported "$file" || continue
EXT="${file##*.}"
EXT="${EXT,,}"
is_media_extension "$EXT" || continue
NAME="${file##*/}"
SIZE=$(stat_size "$file")
set_datetime_from_exif "$file"
if ! calculate_file_hash "$file"; then
COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
mark_runtime_error "SHA256 konnte beim DB-Neuaufbau nicht berechnet werden: $file"
continue
fi
HASH="$HASH_RESULT"
IS_DUPLICATE=0
if [[ "$file" == "$DEST/DUPLICATES/"* ]] || [[ "$NAME" =~ _DUP[0-9]+\. ]]; then
CATEGORY="DUPLICATES"
IS_DUPLICATE=1
elif [[ -n "${DB_HASH_FILE[$HASH]+x}" ]]; then
CATEGORY="DUPLICATES"
IS_DUPLICATE=1
COUNT_REBUILD_DUPES=$((COUNT_REBUILD_DUPES + 1))
else
set_category_from_ext "$EXT"
CATEGORY="$CATEGORY_RESULT"
fi
if ! get_unique_target "$CATEGORY" "$YEAR" "$MONTH" "$DATETIME" "$EXT" "$IS_DUPLICATE" "$file"; then
COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
mark_runtime_error "Sicheres Ziel konnte nicht bestimmt werden: $file"
continue
fi
TARGET="$UNIQUE_TARGET_RESULT"
FILE_FOR_DB="$file"
if [[ "$file" != "$TARGET" ]]; then
COUNT_REBUILD_FIXED=$((COUNT_REBUILD_FIXED + 1))
if move_existing_file "$file" "$TARGET" "$HASH" "$SIZE"; then
FILE_FOR_DB="$TARGET"
else
COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
mark_runtime_error "Strukturkorrektur fehlgeschlagen: $file"
FILE_FOR_DB="$file"
fi
fi
if [[ "$IS_DUPLICATE" -eq 0 ]]; then
if [[ "$DRY_RUN" == "on" ]]; then
SIZE_FOR_DB="$SIZE"
else
SIZE_FOR_DB=$(stat_size "$FILE_FOR_DB")
fi
db_add_memory "$HASH" "$DATETIME" "$SIZE_FOR_DB" "$FILE_FOR_DB" || {
mark_runtime_error "Widersprüchlicher DB-Eintrag beim Neuaufbau: $FILE_FOR_DB"
continue
}
COUNT_REBUILD_ADDED=$((COUNT_REBUILD_ADDED + 1))
fi
done < "$FILE_LIST"
if ! db_write_if_dirty; then
log_error "DB-Neuaufbau konnte nicht sicher abgeschlossen werden"
exit 1
fi
if path_entry_exists "$JOURNAL_FILE"; then
journal_clear || {
log_error "Altes Journal konnte nach dem DB-Neuaufbau nicht entfernt werden"
exit 1
}
fi
log_ok "DB-Neuaufbau abgeschlossen"
log_info "DB-Einträge erstellt: $COUNT_REBUILD_ADDED"
log_info "Struktur korrigiert: $COUNT_REBUILD_FIXED"
log_info "Duplikate beim Neuaufbau erkannt: $COUNT_REBUILD_DUPES"
else
if ! journal_recover; then
log_error "Journal konnte nicht sicher verarbeitet werden"
exit 1
fi
fi
log_ok "Datenbank bereit"
log_info "DB-Einträge im Arbeitsspeicher: $DB_RECORD_COUNT"
# ---------------------------------------------
# SOURCE-STATE LADEN
# ---------------------------------------------
if ! source_state_load; then
log_error "Source-State-Cache konnte nicht sicher geladen werden"
exit 1
fi
if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" == "on" ]]; then
log_ok "Source-State-Cache bereit"
log_info "Source-State-Einträge geladen: $COUNT_SOURCE_STATE_LOADED"
log_info "Ungültige Source-State-Einträge ignoriert: $COUNT_SOURCE_STATE_INVALID"
fi
# ---------------------------------------------
# EINMALIGE SCHNELLE ZIELPRÜFUNG
# ---------------------------------------------
if [[ "$QUICK_DEST_CHECK" == "on" && "$DB_REBUILD" -eq 0 ]]; then
log_info "Starte schnelle Zielprüfung: Pfad/Name und Größe in einem Durchgang"
# Feste NUL-Liste vor der ersten Korrektur: Wird eine Datei innerhalb von DEST
# verschoben, kann ihr neuer Pfad im selben Lauf nicht erneut eingesammelt werden.
# GNU-find liefert die Größe direkt zusammen mit dem Pfad. Das vermeidet in
# copy und move für jede Zieldatei einen separaten externen stat-Prozess. Die
# Prüflogik bleibt identisch: Vergleich von Pfad/Name und Größe mit der DB;
# ein Inhalts-Hash wird nur bei den ohnehin nötigen Tiefprüfungen berechnet.
DEST_QUICK_LIST_HAS_SIZE=0
if find "$DEST" -prune -printf '' >/dev/null 2>&1; then
if ! find "$DEST" -xdev \
-path "$RECOVERY_DIR" -prune -o \
-type f -printf '%p\0%s\0' > "$FILE_LIST"; then
log_error "Zielbestand konnte für die Schnellprüfung nicht vollständig erfasst werden"
exit 1
fi
DEST_QUICK_LIST_HAS_SIZE=1
log_info "Zielscan-Optimierung aktiv: Pfad/Name und Größe gemeinsam eingesammelt"
else
if ! find "$DEST" -xdev \
-path "$RECOVERY_DIR" -prune -o \
-type f -print0 > "$FILE_LIST"; then
log_error "Zielbestand konnte für die Schnellprüfung nicht vollständig erfasst werden"
exit 1
fi
log_info "Zielscan-Optimierung nicht verfügbar: sichere Standardprüfung mit separatem Größenabruf"
fi
while IFS= read -r -d '' file
do
# Bei der Zielscan-Optimierung folgt auf jeden NUL-beendeten Pfad unmittelbar
# die ebenfalls NUL-beendete Dateigröße. Sie muss vor jedem continue
# eingelesen werden, damit die Datensätze ausgerichtet bleiben.
CURRENT_SIZE=""
if [[ "$DEST_QUICK_LIST_HAS_SIZE" -eq 1 ]]; then
if ! IFS= read -r -d '' CURRENT_SIZE; then
log_error "Optimierte Ziel-Schnellliste ist unvollständig"
exit 1
fi
if ! valid_size "$CURRENT_SIZE"; then
log_error "Optimierte Ziel-Schnellliste enthält eine ungültige Dateigröße"
exit 1
fi
fi
FILE_NAME="${file##*/}"
if [[ "$FILE_NAME" == .medien-sortierer-*.part ]]; then
handle_orphan_transfer_temp "$file"
continue
fi
case "$FILE_NAME" in
.hashes.db|.hashes.db.*|.hashes.journal|.hashes.journal.*|\
.source_state.db|.source_state.db.*|RUN.txt|ERROR.txt|FINISH.txt|\
Uebersprungene_ZIP-Dateien.txt)
continue
;;
esac
[[ "$file" != "$DEST/DUPLICATES/"* ]] || continue
media_path_supported "$file" || continue
EXT="${file##*.}"
EXT="${EXT,,}"
is_media_extension "$EXT" || continue
COUNT_DEST_SCANNED=$((COUNT_DEST_SCANNED + 1))
if [[ -z "${DB_FILE_RECORD[$file]+x}" ]]; then
COUNT_DEST_UNKNOWN=$((COUNT_DEST_UNKNOWN + 1))
process_existing_dest_file "$file" "unbekannt"
continue
fi
DB_RECORD="${DB_FILE_RECORD[$file]}"
IFS='|' read -r DB_HASH DB_DATE DB_SIZE _ <<< "$DB_RECORD"
db_mark_seen "$file"
if [[ "$DEST_QUICK_LIST_HAS_SIZE" -eq 0 ]]; then
CURRENT_SIZE=$(stat_size "$file")
fi
if [[ "$CURRENT_SIZE" != "$DB_SIZE" ]]; then
COUNT_DEST_SIZE_MISMATCH=$((COUNT_DEST_SIZE_MISMATCH + 1))
handle_dest_integrity_mismatch "$file" "Größe stimmt nicht mit der DB überein"
continue
fi
if [[ "$DEST_CHECK_LEVEL" == "hash" ]]; then
if ! calculate_file_hash "$file"; then
COUNT_DEST_HASH_MISMATCH=$((COUNT_DEST_HASH_MISMATCH + 1))
handle_dest_integrity_mismatch "$file" "SHA256 konnte nicht sicher berechnet werden"
continue
fi
CURRENT_HASH="$HASH_RESULT"
if [[ "$CURRENT_HASH" != "$DB_HASH" ]]; then
COUNT_DEST_HASH_MISMATCH=$((COUNT_DEST_HASH_MISMATCH + 1))
handle_dest_integrity_mismatch "$file" "SHA256 stimmt nicht mit der DB überein"
continue
fi
fi
# DB_DATE wurde beim Laden derselben DB bereits vollständig validiert.
# Die Strukturprüfung verwendet dieses Ergebnis, statt pro Zieldatei
# denselben externen date-Aufruf ein zweites Mal zu starten.
if ! is_db_path_plausible "$file" "$DB_DATE" 1; then
COUNT_DEST_STRUCTURE_MISMATCH=$((COUNT_DEST_STRUCTURE_MISMATCH + 1))
process_existing_dest_file "$file" "struktur"
continue
fi
done < "$FILE_LIST"
db_remove_unseen_after_scan
cleanup_empty_recovery_dirs
log_ok "Schnelle Zielprüfung abgeschlossen"
log_info "Ziel-Dateien geprüft: $COUNT_DEST_SCANNED"
log_info "Unbekannte Ziel-Dateien: $COUNT_DEST_UNKNOWN"
log_info "Fehlende DB-Dateien entfernt: $COUNT_DB_REMOVED"
log_info "Größenabweichungen: $COUNT_DEST_SIZE_MISMATCH"
log_info "Hash-Abweichungen: $COUNT_DEST_HASH_MISMATCH"
log_info "Strukturabweichungen: $COUNT_DEST_STRUCTURE_MISMATCH"
log_info "Einzeln neu behandelt: $COUNT_DEST_PROCESSED"
else
# Auch ohne allgemeine Zielprüfung bleiben verwaiste Transferdateien geschützt.
scan_orphan_transfer_temps || {
log_error "Verwaiste Transferdateien konnten nicht sicher behandelt werden"
exit 1
}
cleanup_empty_recovery_dirs
fi
# ---------------------------------------------
# INPUT VERARBEITEN
# ---------------------------------------------
log_info "Starte Verarbeitung des Input-Ordners"
# Feste Momentaufnahme des Inputs. Neu eintreffende Dateien werden dadurch nicht
# gleichzeitig im laufenden und im folgenden Watch-Durchgang verarbeitet. Der
# inotify-Controller bleibt aktiv und startet für neue Ereignisse einen Folgelauf.
if ! find "$SRC" -xdev -type f -print0 > "$INPUT_FILE_LIST"; then
log_error "Input-Dateien konnten nicht vollständig erfasst werden"
exit 1
fi
while IFS= read -r -d '' file
do
[[ -f "$file" && ! -L "$file" ]] || continue
media_path_supported "$file" || continue
EXT="${file##*.}"
EXT="${EXT,,}"
if ! is_media_extension "$EXT"; then
if [[ "$EXT" == "zip" ]]; then
record_skipped_zip "$file"
else
COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
fi
continue
fi
if ! read_file_snapshot "$file"; then
COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
mark_runtime_error "Dateizustand konnte nicht sicher gelesen werden: $file"
continue
fi
SIZE="$FILE_SNAPSHOT_SIZE"
MTIME="$FILE_SNAPSHOT_MTIME"
CTIME="$FILE_SNAPSHOT_CTIME"
DEVINO="$FILE_SNAPSHOT_DEVINO"
SOURCE_STATE_KEY=""
if source_state_should_skip "$file" "$SIZE" "$MTIME"; then
# ctime und Datei-Identität dürfen sich bei unverändertem Pfad, Größe und
# mtime ändern, ohne den sicheren Schnellpfad oder ein DB-Neuschreiben
# auszulösen. Der vorhandene Datensatz bleibt deshalb exakt erhalten.
source_state_mark_seen "$file"
COUNT_SOURCE_STATE_WRITTEN=$((COUNT_SOURCE_STATE_WRITTEN + 1))
COUNT_SOURCE_STATE_SKIPPED=$((COUNT_SOURCE_STATE_SKIPPED + 1))
continue
fi
SOURCE_STATE_KEY="$SOURCE_STATE_CURRENT_KEY"
set_datetime_from_exif "$file"
if [[ "$DATETIME" == "UNKNOWN" ]]; then
COUNT_UNKNOWN=$((COUNT_UNKNOWN + 1))
fi
HASH=""
IS_DUPLICATE=0
if [[ "$FAST_MODE" == "on" ]]; then
if [[ -n "$SIZE" && -z "${DB_SIZE_COUNT[$SIZE]+x}" ]]; then
IS_DUPLICATE=0
else
if ! calculate_file_hash "$file"; then
COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
mark_runtime_error "SHA256 konnte nicht sicher berechnet werden: $file"
continue
fi
HASH="$HASH_RESULT"
fi
else
if ! calculate_file_hash "$file"; then
COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
mark_runtime_error "SHA256 konnte nicht sicher berechnet werden: $file"
continue
fi
HASH="$HASH_RESULT"
fi
if [[ -n "$HASH" ]] && db_find_hash "$HASH"; then
if [[ "$DRY_RUN" == "on" || -f "$DB_LOOKUP_FILE" ]]; then
IS_DUPLICATE=1
COUNT_DUPLICATE=$((COUNT_DUPLICATE + 1))
fi
fi
if [[ "$IS_DUPLICATE" -eq 1 && "$EFFECTIVE_DUPLICATE_ACTION" == "skip" ]]; then
if [[ -n "$HASH" ]]; then
source_state_write_current \
"$file" "$EXT" "$SIZE" "$MTIME" "$CTIME" "$DEVINO" \
"$HASH" "$SOURCE_STATE_KEY"
fi
COUNT_DUPLICATE_SKIPPED=$((COUNT_DUPLICATE_SKIPPED + 1))
continue
fi
if [[ "$IS_DUPLICATE" -eq 1 ]]; then
CATEGORY="DUPLICATES"
else
set_category_from_ext "$EXT"
CATEGORY="$CATEGORY_RESULT"
fi
if ! get_unique_target "$CATEGORY" "$YEAR" "$MONTH" "$DATETIME" "$EXT" "$IS_DUPLICATE" ""; then
COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
mark_runtime_error "Sicheres Ziel konnte nicht bestimmt werden: $file"
continue
fi
TARGET="$UNIQUE_TARGET_RESULT"
if [[ -z "$HASH" ]]; then
if ! calculate_file_hash "$file"; then
COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
mark_runtime_error "SHA256 konnte nicht sicher berechnet werden: $file"
continue
fi
HASH="$HASH_RESULT"
fi
if [[ "$IS_DUPLICATE" -eq 0 ]]; then
if ! journal_append_original "$HASH" "$DATETIME" "$SIZE" "$TARGET"; then
COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
mark_runtime_error "Journal konnte vor dem Transfer nicht sicher geschrieben werden: $file"
continue
fi
fi
if ! transfer_file "$file" "$TARGET" "$HASH" "$SIZE"; then
COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
mark_runtime_error "Datei konnte nicht sicher verarbeitet werden: $file"
continue
fi
source_state_write_current \
"$file" "$EXT" "$SIZE" "$MTIME" "$CTIME" "$DEVINO" \
"$HASH" "$SOURCE_STATE_KEY"
if [[ "$IS_DUPLICATE" -eq 1 ]]; then
COUNT_DUPLICATE_STORED=$((COUNT_DUPLICATE_STORED + 1))
continue
fi
FINAL_SIZE="$SIZE"
if [[ "$DRY_RUN" == "off" ]]; then
FINAL_SIZE=$(stat_size "$TARGET")
fi
db_remove_hash_memory "$HASH"
if ! db_add_memory "$HASH" "$DATETIME" "$FINAL_SIZE" "$TARGET"; then
COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
mark_runtime_error "Neues Original konnte nicht eindeutig in die DB aufgenommen werden: $TARGET"
continue
fi
DB_DIRTY=1
COUNT_ORIGINAL=$((COUNT_ORIGINAL + 1))
done < "$INPUT_FILE_LIST"
log_ok "Input-Verarbeitung abgeschlossen"
if ! write_zip_report; then
mark_runtime_error "ZIP-Bericht konnte nicht sicher aktualisiert werden"
fi
# ---------------------------------------------
# SOURCE-STATE UND DB FINAL
# ---------------------------------------------
if ! source_state_finalize; then
mark_runtime_error "Source-State-Cache konnte nicht sicher geschrieben werden"
fi
if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" == "on" ]]; then
log_ok "Source-State-Cache aktualisiert"
log_info "Unveränderte Quelldateien übersprungen: $COUNT_SOURCE_STATE_SKIPPED"
log_info "Source-State-Einträge erfasst: $COUNT_SOURCE_STATE_WRITTEN"
log_info "Nicht mehr vorhandene Quelleinträge entfernt: $COUNT_SOURCE_STATE_REMOVED"
fi
log_info "Prüfe, ob die Datenbank neu geschrieben werden muss"
if ! db_write_if_dirty; then
mark_runtime_error "Datenbank konnte nicht sicher final geschrieben werden"
fi
# Das Journal wird nur nach einer sicher geschriebenen DB und einem fehlerfreien
# Lauf entfernt. Ohne echten Originaltransfer wird künftig gar keines angelegt.
if [[ "$COUNT_TRANSFER_FAILED" -eq 0 && \
"$COUNT_RECOVERY_FAILED" -eq 0 && \
"$SCRIPT_HAD_ERROR" -eq 0 ]]; then
if [[ "$JOURNAL_NEEDS_CLEAR" -eq 1 ]] || path_entry_exists "$JOURNAL_FILE"; then
journal_clear || mark_runtime_error "Journal konnte nicht sicher entfernt werden"
fi
else
log_warn "Journal bleibt wegen fehlgeschlagener Transfers/Recovery für den nächsten Start erhalten"
fi
# ---------------------------------------------
# LEERE ORDNER UND RECOVERY AUFRÄUMEN
# ---------------------------------------------
log_info "Entferne leere Ordner"
if [[ "$PROCESS_MODE" == "move" && "$DELETE_EMPTY_SRC_DIRS" == "on" ]]; then
if [[ "$DRY_RUN" == "off" ]]; then
find "$SRC" -xdev -mindepth 1 -type d -empty -delete
fi
log_ok "Leere Input-Unterordner entfernt"
else
log_info "Input-Unterordner bleiben unverändert"
fi
if [[ "$DELETE_EMPTY_DEST_DIRS" == "on" ]]; then
if [[ "$DRY_RUN" == "off" ]]; then
find "$DEST" -xdev -depth -mindepth 1 -type d -empty \
! -path "$RECOVERY_DIR" \
! -path "$RECOVERY_QUARANTINE_DIR" \
-delete
fi
log_ok "Leere Output-Unterordner entfernt"
fi
cleanup_empty_recovery_dirs
# ---------------------------------------------
# RECHTE INNERHALB DES ZIELORDNERS
# ---------------------------------------------
if [[ "$SET_PERMISSIONS" == "on" ]]; then
if [[ "$RECURSIVE_PERMISSION_FIX" == "on" ]]; then
apply_recursive_permissions || mark_runtime_error "Rekursive Rechtekorrektur innerhalb des Zielordners war nicht vollständig erfolgreich"
else
log_ok "Rechte wurden auf neu erstellte/ersetzte Dateien und neue Ziel-Unterordner angewendet"
fi
else
log_info "Datei- und Ordnerrechte wurden nicht zusätzlich verändert"
fi
# ---------------------------------------------
# ABSCHLIESSENDER SYNC
# ---------------------------------------------
# DB, Journal, Source-State und Transfers wurden bereits an ihrer jeweiligen
# Sicherheitsgrenze synchronisiert. Hier genügt ein abschließender Dateisystem-
# Sync für DEST; dieselben Dateien werden nicht nochmals einzeln synchronisiert.
if [[ "$RUN_SYNC" == "on" && "$DRY_RUN" == "off" ]]; then
log_info "Synchronisiere abschließende Metadatenänderungen"
safe_sync_dir "$DEST" || mark_runtime_error "Abschließender Sync von DEST ist fehlgeschlagen"
log_ok "Sync abgeschlossen"
fi
# ---------------------------------------------
# ENDE
# ---------------------------------------------
END_TIME=$(date +%s)
RUNTIME=$((END_TIME - START_TIME))
echo "========================================"
echo "[FERTIG] Medien-Sortierung abgeschlossen"
echo "----------------------------------------"
echo "Modus : $PROCESS_MODE"
echo "Duplikat-Aktion : $EFFECTIVE_DUPLICATE_ACTION"
echo "Dry-Run : $DRY_RUN"
echo "Fast-Mode : $FAST_MODE"
echo "Struktur : Kategorie/Medientyp/$SORT_STRUCTURE"
echo "Datenbank-Modus : $DATABASE_MODE"
echo "Watch-Modus : $WATCH_MODE_EFFECTIVE"
echo "Watch-Backend (konfiguriert) : $WATCH_BACKEND"
echo "Watch-Backend (verwendet) : $WATCH_BACKEND_EFFECTIVE"
echo "Schnellprüfung Zielordner : $QUICK_DEST_CHECK ($DEST_CHECK_LEVEL)"
echo "Source-State-Cache : $SOURCE_STATE_CACHE_EFFECTIVE"
echo "Neue Originale : $COUNT_ORIGINAL"
echo "Duplikate erkannt : $COUNT_DUPLICATE"
echo "Duplikate gespeichert : $COUNT_DUPLICATE_STORED"
echo "Duplikate übersprungen : $COUNT_DUPLICATE_SKIPPED"
echo "UNKNOWN Dateien : $COUNT_UNKNOWN"
echo "Übersprungene ZIP-Dateien : $COUNT_ZIP_SKIPPED"
echo "Übersprungene sonstige Dateien: $COUNT_SKIPPED"
echo "Fehlgeschlagene Transfers : $COUNT_TRANSFER_FAILED"
echo "Recovery abgeschlossen : $COUNT_RECOVERY_COMPLETED"
echo "Recovery repariert : $COUNT_RECOVERY_REPAIRED"
echo "Recovery-Fehler : $COUNT_RECOVERY_FAILED"
echo "Recovery-Quarantäne : $COUNT_RECOVERY_QUARANTINED"
echo "DB-Rebuild : $([[ "$DB_REBUILD" -eq 1 ]] && echo "Ja" || echo "Nein")"
echo "DB-Rebuild-Grund : $DB_REBUILD_REASON"
echo "DB-Einträge entfernt : $COUNT_DB_REMOVED"
echo "Journal wiederhergestellt : $COUNT_JOURNAL_RECOVERED"
echo "Ziel-Dateien geprüft : $COUNT_DEST_SCANNED"
echo "Unbekannte Ziel-Dateien : $COUNT_DEST_UNKNOWN"
echo "Größenabweichungen : $COUNT_DEST_SIZE_MISMATCH"
echo "Hash-Abweichungen : $COUNT_DEST_HASH_MISMATCH"
echo "Strukturabweichungen : $COUNT_DEST_STRUCTURE_MISMATCH"
echo "Nicht unterstützte Pfade : $COUNT_UNSUPPORTED_NAMES"
echo "Einzeln neu behandelt : $COUNT_DEST_PROCESSED"
echo "Source-State geladen : $COUNT_SOURCE_STATE_LOADED"
echo "Source-State übersprungen : $COUNT_SOURCE_STATE_SKIPPED"
echo "Source-State erfasst : $COUNT_SOURCE_STATE_WRITTEN"
echo "Source-State entfernt : $COUNT_SOURCE_STATE_REMOVED"
echo "Source-State ungültig : $COUNT_SOURCE_STATE_INVALID"
echo "DB-Format : HASH|DATETIME|SIZE|FILE"
echo "Source-State-Format : SRC_KEY|SIZE|MTIME|CTIME|DEVINO|BASENAME|EXT|HASH|SRC_FILE"
echo "Laufzeit : ${RUNTIME}s"
echo "========================================"
if [[ "$SCRIPT_HAD_ERROR" -ne 0 || \
"$COUNT_TRANSFER_FAILED" -gt 0 || \
"$COUNT_RECOVERY_FAILED" -gt 0 ]]; then
exit 1
fi
SCRIPT_COMPLETED=1
exit 0
BilderAktuellScript_v1.0.6.sh