August 1Aug 1 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-ModusSortierung nach Kategorie, Medientyp, Jahr und Monatalternativ nur Jahr oder eine flache OrdnerstrukturDatumsermittlung über Metadaten, Dateinamen und DateisystemdatenSHA-256-basierte Duplikaterkennungpersistente oder temporäre Datenbankschneller Source-State-Abgleich im Copy-ModusWatch-Modus über Inotify oder PollingDry-Run für Testläufeabgesicherte Übertragung über temporäre Dateien und Hash-PrüfungRecovery-Funktion nach Abbruch oder Stromausfalloptionale Rechtekorrektur und Bereinigung leerer OrdnerDas 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 Edited August 1Aug 1 by funflo
August 2Aug 2 12 hours ago, funflo said:ich habe für meinen eigenen Unraid-Server ein kleines Bash-Skript entwickelt, das Bilder und Videos automatisch sortiert.Moin, "klein" ist doch relativ ;) für das was es macht, vielleicht solltest du anhand eines Beispiels beschreiben für was es angedacht ist.ich sehe es in der Kurzform so das hier "Handy" Bilder und Videos in Ordner kopiert werden (nur Jahr oder Jahr / Monat).Wenn es mehr kann außer die "ausgeprägte" Sicherheit um wirklich alles und aktuell kopiert zu haben korrigier mich gerne.ein Hinweis, du schreibst copy or move, dein "move" ist jedoch "copy and delete" was ein großer Unterschied wäre bei vielen oder großen Dateien, nur als Hinweis ... viele bevorzugen bei Medien einen move (aber den echten move)Danke fürs Teilen wenn es 2jtzt auch nichts für mich ist.
August 2Aug 2 Author Moin,ja das ganze ist leider etwas ausgeartet 😅.war eigentlich auf der suche nach was fertigem und hatte nix gefunden und habe dann ein wenig selber und dann mit ChatGPT rumgespielt.das Skript dient der automatisierten Sortierung von Bildern und Videos anhand ihres Aufnahmedatums. Dieses wird primär über ExifTool aus den Metadaten der Dateien extrahiert. Ist das Tool nicht vorhanden (Pfad und installiert) bricht es ab. Sind keine Daten in den Meta Daten vorhanden landen die als Rückfall in Bilder/0000 Name der Datei unkown gestellt.Zusätzlich werden die Dateien nach dem ermittelten Datum standardisiert umbenannt und mittels eines SHA-256-Hashes auf Duplikate geprüft.Es stehen zwei Betriebsmodi zur Verfügung:Move-ModusIm Move-Modus werden Dateien sicher in das Zielverzeichnis verschoben. Die ursprüngliche Datei im Eingangsverzeichnis wird erst dann entfernt, wenn die Übertragung sowie die anschließende Prüfung erfolgreich abgeschlossen wurden. Hier habe ich absichtlich kein normales move verwendet da ich einmal ein problem mit der usv hatte.Erkannte Duplikate werden nicht gelöscht, sondern in ein separates Duplikatverzeichnis verschoben.Copy-ModusIm Copy-Modus werden Dateien in das Zielverzeichnis kopiert, während die Originaldateien im Eingangsverzeichnis erhalten bleiben.Bereits bekannte Duplikate werden übersprungen. Das Skript verwendet eine Datenbank, um bereits geprüfte Dateien zu speichern. Dadurch ist es nicht erforderlich, bei wiederholten Durchläufen den gesamten Bestand erneut zu hashen oder vollständig zu analysieren. Dies reduziert die Verarbeitungszeit bei unveränderten Daten erheblich.Die Medien werden zunächst anhand ihres Dateiformats in drei Kategorien klassifiziert:Apple: z. B. HEIC, HEIF, DNG und RAWCompatible: gängige Formate wie JPG, PNG, MP4 und MOVOther: weitere Formate wie TIFF, WEBP oder GIFInnerhalb jeder Kategorie erfolgt eine zusätzliche Trennung in Bilder und Videos.Die Zielordnerstruktur ist konfigurierbar und unterstützt folgende Varianten:direkte Ablage in Bild- bzw. VideoordnernSortierung nach JahrSortierung nach Jahr und MonatEin Beispiel für eine Zielstruktur lautet:APPLE/BILDER/2026/08/Die Dateien werden einheitlich nach Datum und Uhrzeit benannt. Dadurch ist eine chronologische Sortierung unabhängig von der Ordnerstruktur gewährleistet.ZIP-Dateien sowie nicht unterstützte Dateiformate werden nicht verarbeitet und verbleiben im Eingangsverzeichnis. Im Copy-Modus werden diese Dateien zusätzlich erkannt und protokolliert, sodass nachvollziehbar ist, wo sie sich befinden. Dies ist insbesondere bei Downloads aus Apple iCloud relevant, da dort Bilder häufig in ZIP-Archiven bereitgestellt werden.Zusätzlich bietet das Skript einen Dry-Run-Modus, einen Watch-Modus, eine Recovery-Funktion für unterbrochene Übertragungen sowie eine optionale Anpassung der Dateiberechtigungen. Edited August 2Aug 2 by funflo
Join the conversation
You can post now and register later. If you have an account, sign in now to post with your account.
Note: Your post will require moderator approval before it will be visible.