Skip to content
View in the app

A better way to browse. Learn more.

Unraid

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.

Medien-Sortierskript für Unraid/Linux – Copy, Move, Duplikaterkennung und Watch-Modus

Featured Replies

Moin zusammen,

ich habe für meinen eigenen Unraid-Server ein kleines Bash-Skript entwickelt, das Bilder und Videos automatisch sortiert. Da es eventuell auch für andere nützlich sein könnte, stelle ich die aktuelle Version 1.0.6 hier als Beta-/Testversion zur Verfügung.

Das Skript bietet unter anderem:

  • Copy- und Move-Modus

  • Sortierung nach Kategorie, Medientyp, Jahr und Monat

  • alternativ nur Jahr oder eine flache Ordnerstruktur

  • Datumsermittlung über Metadaten, Dateinamen und Dateisystemdaten

  • SHA-256-basierte Duplikaterkennung

  • persistente oder temporäre Datenbank

  • schneller Source-State-Abgleich im Copy-Modus

  • Watch-Modus über Inotify oder Polling

  • Dry-Run für Testläufe

  • abgesicherte Übertragung über temporäre Dateien und Hash-Prüfung

  • Recovery-Funktion nach Abbruch oder Stromausfall

  • optionale Rechtekorrektur und Bereinigung leerer Ordner

Das Skript wurde für meinen eigenen Anwendungsfall entwickelt und auf meinem System ausführlich getestet. Andere Ordnerstrukturen, Dateisysteme, Rechtekonfigurationen oder ungewöhnliche Mediendateien können sich jedoch anders verhalten.

Bitte unbedingt beachten:

  • Vor der ersten Verwendung ein unabhängiges Backup erstellen.

  • Zuerst mit DRY_RUN="on" und Kopien in separaten Testordnern ausprobieren.

  • Alle Pfade und Einstellungen sorgfältig kontrollieren.

  • Den Move-Modus niemals direkt mit ungesicherten Originaldateien testen.

  • Die Verwendung erfolgt vollständig auf eigene Verantwortung.

Der schnelle Source-State-Abgleich verwendet im Copy-Modus den vollständigen Quellpfad, die Dateigröße und den mtime-Zeitstempel. Neue oder erkennbar veränderte Dateien werden vollständig per SHA-256 geprüft. Wird eine Datei jedoch gezielt so ersetzt, dass Pfad, Größe und mtime identisch bleiben, kann der Schnellvergleich diese Manipulation nicht erkennen.

Wichtiger Hinweis zum Support:

Ich stelle das Skript so zur Verfügung, wie ich es selbst verwende, kann dafür aber keinen Support anbieten. Das betrifft insbesondere Hilfe bei Installation und Einrichtung, die Prüfung fremder Konfigurationen, individuelle Anpassungen sowie die Fehleranalyse auf anderen Systemen. Auch regelmäßige Updates oder die Umsetzung von Änderungswünschen kann ich nicht versprechen.

Erfahrungsberichte und Hinweise dürfen natürlich trotzdem gerne im Thema geteilt werden – bitte jedoch ohne die Erwartung, dass ich jeden Fall untersuchen oder eine angepasste Version erstellen kann.

Im Anhang befindet sich die aktuelle Skriptversion. Bitte vor der Verwendung zunächst den Einstellungsbereich vollständig durchgehen.

#!/bin/bash
#backgroundOnly=true
#arrayStarted=true
#noParity=true
# Version: 1.0.6


# Medien-Sortierer
# Ausgelegt für Unraid, Raspberry Pi OS, Fedora und ähnliche Linux-Systeme.
# Es werden keine Pakete installiert und keine Systemeinstellungen verändert.

START_TIME=$(date +%s)
START_TIME_TEXT=$(date '+%d.%m.%Y %H:%M:%S')
SCRIPT_PATH="${BASH_SOURCE[0]}"

# ---------------------------------------------
# EINSTELLUNGEN
# ---------------------------------------------
# Nur dieser Bereich ist für die normale Einrichtung gedacht.
# Alle Werte werden vor dem ersten Zugriff auf SRC, DEST oder Datenbanken geprüft.
#
# Empfohlener erster Test nach einer Änderung:
#   DRY_RUN auf "on" setzen
#   WATCH_MODE auf "off" setzen
# Danach Log, Quelle, Zielstruktur und Zähler kontrollieren.

# ===== 1. QUELLE UND ZIEL =====

# Ordner mit den neu zu verarbeitenden Dateien.
# Der Quellordner muss bereits existieren. Fehlt er, bricht das Script ab.
SRC="/mnt/user/Der/Eingang"

# Zielordner der sortierten Dateien.
# Der Zielordner muss bereits existieren. Fehlt er, bricht das Script ab.
# Eingehängte Unter-Dateisysteme unter SRC oder DEST werden aus Sicherheitsgründen
# nicht rekursiv verarbeitet oder bereinigt.
DEST="/mnt/user/dein/Ziel"

# ===== 2. GRUNDMODUS =====

# move = Datei sicher ins Ziel kopieren, vollständig prüfen und synchronisieren;
#        erst danach die unveränderte Quelle löschen.
# copy = Datei sicher ins Ziel kopieren und die Quelle unverändert lassen.
PROCESS_MODE="move"

# on  = Testlauf. Keine Nutzdaten, Zielordner, Datenbanken oder Statusdateien ändern.
#       Nur der Instanz-Lock und temporäre Laufzeitdateien unter /tmp sind erlaubt.
# off = echter Lauf.
DRY_RUN="off"

# auto  = bei move Duplikate speichern, bei copy Duplikate überspringen
# store = Duplikate immer speichern
# skip  = Duplikate immer überspringen
DUPLICATE_ACTION="auto"

# ===== 3. ZIELSTRUKTUR =====

# Die erste Ebene ist immer die Formatgruppe, die zweite Ebene der Medientyp:
#   COMPATIBLE/BILDER   = jpg, jpeg, png
#   COMPATIBLE/VIDEOS   = mp4, avi, mkv, mpg, mpeg
#   APPLE/BILDER        = heic, heif, dng, raw
#   APPLE/VIDEOS        = mov, m4v
#   OTHER/BILDER        = webp, gif, bmp, tif, tiff
#   OTHER/VIDEOS        = 3gp, 3gpp, webm
#   DUPLICATES/BILDER   = gespeicherte Bild-Duplikate
#   DUPLICATES/VIDEOS   = gespeicherte Video-Duplikate
#
# year_month = Kategorie/Medientyp/Jahr/Monat,
#              z. B. APPLE/VIDEOS/2026/07
# year       = Kategorie/Medientyp/Jahr,
#              z. B. APPLE/VIDEOS/2026
# flat       = Kategorie/Medientyp ohne Datumsordner,
#              z. B. APPLE/VIDEOS
SORT_STRUCTURE="year_month"

# ===== 4. DATUM UND EXIFTOOL =====

# auto   = zuerst EXIFTOOL_PATH prüfen, danach exiftool aus PATH verwenden
# system = ausschließlich exiftool aus PATH verwenden
# path   = ausschließlich EXIFTOOL_PATH verwenden
# ExifTool ist zwingend. Bei einem Fehler wird vor jeder Verarbeitung abgebrochen.
EXIFTOOL_MODE="auto"
EXIFTOOL_PATH="/mnt/user/das/EXIFTOOL/mussExtraRuntergeladenWerden"

# ===== 5. DUPLIKAT- UND DATENBANKPRÜFUNG =====

# on  = Dateigröße als schneller Vorfilter; SHA256 bleibt die finale Prüfung
# off = SHA256 für jede Eingabedatei sofort berechnen
FAST_MODE="on"

# on  = den Zielbestand in genau einem schnellen Durchgang prüfen. Dabei werden
#       relativer Speicherort/Dateiname und Dateigröße mit der DB verglichen.
#       Nur auffällige Dateien werden mit ExifTool und SHA256 tief geprüft.
#       Verwaiste .part-Dateien werden in demselben Durchgang erkannt.
#       In copy und move werden Pfad und Größe – sofern vom installierten find
#       unterstützt – gemeinsam eingesammelt. So entfällt der separate stat-Aufruf
#       pro Zieldatei. Ohne diese find-Funktion gilt automatisch der bisherige Weg.
# off = keine allgemeine Zielprüfung; Recovery-Marker und .part-Dateien werden
#       trotzdem gezielt geprüft.
QUICK_DEST_CHECK="on"

# size = schneller Vergleich von Pfad/Name, Größe und Zielstruktur
# hash = zusätzlich jede bekannte Zieldatei vollständig mit SHA256 prüfen
# hash erkennt auch Inhaltsänderungen bei gleicher Größe, liest aber jede Datei komplett.
DEST_CHECK_LEVEL="size"

# persistent = Hash-DB, Journal und Source-State dauerhaft in DEST speichern
# Die Verarbeitung von Medieninhalten und rekursiven Dateilisten erfolgt streamend.
# Im RAM liegen nur kompakte Zuordnungen für Hash, Pfad und Größe; keine Dateiinhalte
# und keine vollständigen rekursiven Dateilisten.
# temporary  = Datenbanken nur für den aktuellen Lauf unter /tmp aufbauen;
#              vorhandene persistente DB-Dateien bleiben unberührt
DATABASE_MODE="persistent"

# on  = im copy-Modus unveränderte, bereits bekannte Quellen überspringen
# off = jede Quelle erneut prüfen
# Der schnelle Vergleich verwendet ausschließlich den exakten vollständigen
# Quellpfad einschließlich Dateiname, die Dateigröße und mtime. Stimmen alle drei
# Werte und die gespeicherte SHA256-Zielzuordnung, ist kein Inhalts-Hash nötig.
# Weicht ein Wert ab oder fehlt die sichere Zielzuordnung, folgt automatisch die
# normale Verarbeitung mit vollständigem SHA256-Hash. ctime und Datei-Identität
# bleiben für das abwärtskompatible Dateiformat gespeichert, lösen allein aber
# weder einen Hash noch ein Neuschreiben der Source-State-Datei aus.
# Im move-Modus wird diese Funktion automatisch deaktiviert.
# Eindeutig verwaiste .source_state.tmp.XXXXXX-Dateien werden bei aktivem Lock
# sicher erkannt und vor dem nächsten Lauf automatisch entfernt.
SOURCE_STATE_CACHE="on"

# ===== 6. WATCH-MODUS =====

# on  = SRC dauerhaft überwachen und nach einer Ruhezeit sortieren
# off = genau einen Sortierlauf ausführen und danach beenden
WATCH_MODE="on"

# inotify = empfohlene Voreinstellung; keine regelmäßigen Verzeichnisscans
# poll    = regelmäßige Scans; kann Festplatten wach halten oder aufwecken
# auto    = inotify verwenden, sonst automatisch auf poll zurückfallen
#          ACHTUNG: auto kann dadurch unbemerkt Polling aktivieren.
# Es wird niemals etwas installiert oder am System verändert.
WATCH_BACKEND="inotify"

# Startverzögerung nur für den äußeren Watcher. Bei WATCH_MODE=off sowie bei
# internen Sortierläufen des Watchers wird sie vollständig ignoriert.
# 0 = deaktiviert.
START_DELAY_SECONDS=15

# Nach dem letzten erkannten Schreib-/Verschiebeereignis so lange warten.
# 0 = Sortierlauf unmittelbar nach dem Ereignis starten.
WATCH_IDLE_SECONDS=120

# on  = beim Watcher-Start bereits vorhandene Dateien einmal verarbeiten
# off = ausschließlich auf danach eintreffende Dateien reagieren
# Offene Recovery-Marker oder ein vorhandenes Journal starten unabhängig davon
# sofort einen Worker, damit auch ein nach dem Move bereits leerer Input repariert wird.
WATCH_PROCESS_EXISTING="on"

# Prüfintervall bei WATCH_BACKEND=poll. Mindestwert: 1 Sekunde.
WATCH_POLL_SECONDS=5

# ===== 7. AUFRÄUMEN =====

# Nur leere Unterordner entfernen; SRC und DEST selbst werden niemals gelöscht.
# Im copy-Modus wird SRC grundsätzlich nicht bereinigt.
DELETE_EMPTY_SRC_DIRS="on"
DELETE_EMPTY_DEST_DIRS="on"

# ===== 8. RECHTE =====

# on  = Rechte ausschließlich auf Dateien und Unterordner innerhalb von DEST anwenden.
#       DEST selbst wird weder mit chmod noch mit chown verändert.
# off = keine Rechte gezielt durch das Script ändern.
SET_PERMISSIONS="on"

# on  = am Ende jedes echten Sortierlaufs den gesamten vorhandenen Inhalt unterhalb
#       von DEST rekursiv auf die unten eingestellten Rechte bringen. Das ist unter Unraid
#       sinnvoll, wenn über SMB sonst einzelne Dateien wegen abweichender Owner-
#       oder Moduswerte nicht geöffnet beziehungsweise verändert werden können.
#
#       Sicherheits- und RAM-Verhalten:
#       - ausschließlich unterhalb von DEST; DEST selbst bleibt unverändert
#       - bleibt mit -xdev auf demselben Dateisystem
#       - folgt keinen symbolischen Links
#       - durchläuft den Zielbaum einmal, arbeitet mit find -exec in kleinen
#         Stapeln und lädt keine vollständige Dateiliste in den Arbeitsspeicher
#
# off = nur neu angelegte Dateien und Ordner korrigieren.
# Diese Einstellung hat nur Wirkung, wenn SET_PERMISSIONS="on" ist.
RECURSIVE_PERMISSION_FIX="on"

# Unter Unraid üblich: nobody:users. Für andere Linux-Systeme kann der Wert
# leer gelassen werden; dann wird kein chown ausgeführt.
PERMISSION_OWNER="nobody:users"
PERMISSION_DIR_MODE="777"
PERMISSION_FILE_MODE="666"

# Die wirksame umask wird intern automatisch aus den beiden Modi berechnet.
# Beispiele: 777/666 -> 000, 775/664 -> 002, 750/640 -> 027.
# Beide Modi müssen mit derselben umask darstellbar sein.

# ===== 9. SCHUTZ UND SYNCHRONISIERUNG =====

# on  = parallele Läufe mit demselben Ziel verhindern
# off = kein Lock; nicht empfohlen
ENABLE_LOCK="on"

# on  = Temp-Datei, Ziel, Transaktionsmarker und Verzeichnisse synchronisieren,
#       bevor bei move die Quelle gelöscht wird
# off = zusätzliche Sync-Schritte deaktivieren; reduziert Stromausfallschutz
RUN_SYNC="on"

# Freier Platz, der zusätzlich zur aktuellen Dateigröße im Ziel verbleiben muss.
# 0 = Prüfung deaktivieren. Wert in MiB.
MIN_FREE_SPACE_MB=256

# ---------------------------------------------
# INTERNE DATEIEN
# Diese Namen nicht auf andere Dateisysteme außerhalb von DEST umbiegen.
# ---------------------------------------------

REAL_HASH_DB="$DEST/.hashes.db"
REAL_JOURNAL_DB="$DEST/.hashes.journal"
REAL_SOURCE_STATE_DB="$DEST/.source_state.db"

RECOVERY_DIR="$DEST/.medien_sortierer_recovery"
RECOVERY_QUARANTINE_DIR="$RECOVERY_DIR/quarantine"

ZIP_REPORT_FILE="$DEST/Uebersprungene_ZIP-Dateien.txt"
STATUS_RUN_FILE="$DEST/RUN.txt"
STATUS_ERROR_FILE="$DEST/ERROR.txt"
STATUS_FINISH_FILE="$DEST/FINISH.txt"

# ---------------------------------------------
# ZÄHLER UND LAUFSTATUS
# ---------------------------------------------

COUNT_ORIGINAL=0
COUNT_DUPLICATE=0
COUNT_DUPLICATE_STORED=0
COUNT_DUPLICATE_SKIPPED=0
COUNT_UNKNOWN=0
COUNT_SKIPPED=0
COUNT_ZIP_SKIPPED=0
COUNT_DB_REMOVED=0
COUNT_REBUILD_ADDED=0
COUNT_REBUILD_DUPES=0
COUNT_REBUILD_FIXED=0
COUNT_TRANSFER_FAILED=0
COUNT_JOURNAL_RECOVERED=0
COUNT_RECOVERY_COMPLETED=0
COUNT_RECOVERY_REPAIRED=0
COUNT_RECOVERY_FAILED=0
COUNT_RECOVERY_QUARANTINED=0

COUNT_DEST_SCANNED=0
COUNT_DEST_UNKNOWN=0
COUNT_DEST_SIZE_MISMATCH=0
COUNT_DEST_STRUCTURE_MISMATCH=0
COUNT_DEST_PROCESSED=0
COUNT_DEST_HASH_MISMATCH=0
COUNT_UNSUPPORTED_NAMES=0

COUNT_SOURCE_STATE_LOADED=0
COUNT_SOURCE_STATE_SKIPPED=0
COUNT_SOURCE_STATE_WRITTEN=0
COUNT_SOURCE_STATE_REMOVED=0
COUNT_SOURCE_STATE_INVALID=0

DB_REBUILD=0
DB_REBUILD_REASON="Nein"
SOURCE_STATE_CACHE_EFFECTIVE="off"
EFFECTIVE_DUPLICATE_ACTION=""

SCRIPT_HAD_ERROR=0
LAST_ERROR_MESSAGE=""
SCRIPT_COMPLETED=0
STATUS_TRACKING_ACTIVE=0
LOCK_OWNED=0
LOCK_DIR=""
WATCH_CHILD_PID=""
ACTIVE_TRANSFER_PID=""
ACTIVE_WAIT_PID=""
WATCH_MONITOR_PID=""
WATCH_EVENT_FILE=""
WATCH_BACKEND_EFFECTIVE="off"
CURRENT_UID=$(id -u 2>/dev/null || echo "")
CURRENT_GID=$(id -g 2>/dev/null || echo "")
SRC_CANONICAL=""
DEST_CANONICAL=""
PERMISSION_UMASK=""

TMP_ROOT=""
DB_FILE=""
JOURNAL_FILE=""
SOURCE_STATE_FILE=""
SOURCE_STATE_NEW=""
FILE_LIST=""
INPUT_FILE_LIST=""
ZIP_PATH_LIST=""
EXIFTOOL=""
EXIFTOOL_VERSION=""

HASH_RESULT=""
FILE_SNAPSHOT_SIZE=""
FILE_SNAPSHOT_MTIME=""
FILE_SNAPSHOT_CTIME=""
FILE_SNAPSHOT_DEVINO=""
SOURCE_STATE_CURRENT_KEY=""
TARGET_DIR_RESULT=""
UNIQUE_TARGET_RESULT=""
DB_LOOKUP_HASH=""
DB_LOOKUP_FILE=""

DB_DIRTY=0
DB_RECORD_COUNT=0
JOURNAL_NEEDS_CLEAR=0
ATOMIC_PUBLISH_VERIFIED=0
SOURCE_STATE_DIRTY=0
SOURCE_STATE_FILE_EXISTED=0

# Interner Worker eines Watchers:
# - keine zweite Startverzögerung
# - kein zweiter Lock
# - immer genau ein Sortierlauf
INTERNAL_WATCH_WORKER="${MEDIEN_SORTIERER_WORKER:-0}"
EXTERNAL_LOCK_HELD="${MEDIEN_SORTIERER_LOCK_HELD:-0}"

if [[ "$INTERNAL_WATCH_WORKER" == "1" ]]; then
    WATCH_MODE_EFFECTIVE="off"
    START_DELAY_EFFECTIVE=0
else
    WATCH_MODE_EFFECTIVE="$WATCH_MODE"

    if [[ "$WATCH_MODE" == "on" ]]; then
        START_DELAY_EFFECTIVE="$START_DELAY_SECONDS"
    else
        # Bei einem normalen Einmallauf ergibt eine Watcher-Startverzögerung keinen Sinn.
        START_DELAY_EFFECTIVE=0
    fi
fi

# ---------------------------------------------
# LOGGING
# ---------------------------------------------

log_info() { printf "[INFO] %s\n" "$1" >&2; }
log_ok()   { printf "[OK] %s\n" "$1" >&2; }
log_warn() { printf "[WARN] %s\n" "$1" >&2; }

log_error() {
    SCRIPT_HAD_ERROR=1
    LAST_ERROR_MESSAGE="$1"
    printf "[FEHLER] %s\n" "$1" >&2
}

mark_runtime_error() {
    SCRIPT_HAD_ERROR=1
    LAST_ERROR_MESSAGE="$1"
    log_warn "$1"
}

# ---------------------------------------------
# GRUNDHILFEN
# ---------------------------------------------

is_on_off() {
    case "$1" in
        on|off) return 0 ;;
        *) return 1 ;;
    esac
}

check_on_off_setting() {
    local name="$1"
    local value="$2"

    if ! is_on_off "$value"; then
        log_error "$name muss on oder off sein"
        exit 1
    fi
}

is_uint() {
    [[ "$1" =~ ^[0-9]+$ ]]
}

valid_hash() {
    [[ "$1" =~ ^[a-fA-F0-9]{64}$ ]]
}

valid_size() {
    [[ "$1" =~ ^[0-9]+$ ]]
}

valid_time_number() {
    [[ "$1" =~ ^[0-9]+$ ]]
}

valid_stat_time_number() {
    [[ "$1" =~ ^-?[0-9]+$ ]]
}

valid_devino() {
    [[ "$1" =~ ^[0-9]+:[0-9]+$ ]]
}

valid_calendar_datetime_components() {
    local year_text="$1"
    local month_text="$2"
    local day_text="$3"
    local hour_text="$4"
    local minute_text="$5"
    local second_text="$6"
    local year=0
    local month=0
    local day=0
    local hour=0
    local minute=0
    local second=0
    local max_day=0

    [[ "$year_text" =~ ^[0-9]{4}$ ]] || return 1
    [[ "$month_text" =~ ^[0-9]{2}$ ]] || return 1
    [[ "$day_text" =~ ^[0-9]{2}$ ]] || return 1
    [[ "$hour_text" =~ ^[0-9]{2}$ ]] || return 1
    [[ "$minute_text" =~ ^[0-9]{2}$ ]] || return 1
    [[ "$second_text" =~ ^[0-9]{2}$ ]] || return 1

    year=$((10#$year_text))
    month=$((10#$month_text))
    day=$((10#$day_text))
    hour=$((10#$hour_text))
    minute=$((10#$minute_text))
    second=$((10#$second_text))

    (( month >= 1 && month <= 12 )) || return 1
    (( hour <= 23 && minute <= 59 && second <= 59 )) || return 1

    case "$month" in
        1|3|5|7|8|10|12)
            max_day=31
            ;;
        4|6|9|11)
            max_day=30
            ;;
        2)
            max_day=28
            if (( year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) )); then
                max_day=29
            fi
            ;;
    esac

    (( day >= 1 && day <= max_day ))
}

valid_datetime_value() {
    local value="$1"

    [[ "$value" == "UNKNOWN" ]] && return 0

    if [[ "$value" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})_([0-9]{3})$ ]]; then
        valid_calendar_datetime_components \
            "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" \
            "${BASH_REMATCH[4]}" "${BASH_REMATCH[5]}" "${BASH_REMATCH[6]}"
        return $?
    fi

    return 1
}

path_entry_exists() {
    [[ -e "$1" || -L "$1" ]]
}

has_unsupported_record_chars() {
    case "$1" in
        *'|'*|*$'\n'*|*$'\r'*) return 0 ;;
        *) return 1 ;;
    esac
}

media_path_supported() {
    local file="$1"
    local readable=""

    if has_unsupported_record_chars "$file"; then
        printf -v readable '%q' "$file"
        COUNT_UNSUPPORTED_NAMES=$((COUNT_UNSUPPORTED_NAMES + 1))
        log_warn "Datei bleibt unverändert, weil ihr Pfad | oder einen Zeilenumbruch enthält: $readable"
        return 1
    fi

    return 0
}

canonicalize_path() {
    local input_path="$1"

    if command -v realpath >/dev/null 2>&1; then
        realpath -m -- "$input_path" 2>/dev/null
    elif command -v readlink >/dev/null 2>&1; then
        readlink -m -- "$input_path" 2>/dev/null
    else
        return 1
    fi
}

# Prüft gespeicherte absolute Pfade ohne einen eigenen realpath-Prozess pro
# Datensatz. Die Prüfung ist der kanonischen Auflösung gleichwertig: Der Pfad
# muss direkt unterhalb des bereits kanonisierten Wurzelpfads liegen, darf keine
# leeren, .- oder ..-Komponenten enthalten und keine Symlink-Komponente benutzen.
record_path_is_canonical_within() {
    local file="$1"
    local root="$2"
    local relative=""
    local current="$root"
    local component=""
    local index=0
    local last_index=0
    local old_ifs=""
    local -a components=()

    [[ -n "$file" && -n "$root" ]] || return 1
    has_unsupported_record_chars "$file" && return 1
    [[ "$file" == "$root/"* ]] || return 1

    relative="${file#"$root"/}"
    [[ -n "$relative" && "$relative" != */ && "$relative" != *//* ]] || return 1

    old_ifs="$IFS"
    IFS='/'
    read -r -a components <<< "$relative"
    IFS="$old_ifs"

    (( ${#components[@]} > 0 )) || return 1
    last_index=$((${#components[@]} - 1))

    for index in "${!components[@]}"
    do
        component="${components[$index]}"
        [[ -n "$component" && "$component" != "." && "$component" != ".." ]] || return 1
        current="$current/$component"

        [[ ! -L "$current" ]] || return 1

        if (( index < last_index )) && path_entry_exists "$current"; then
            [[ -d "$current" ]] || return 1
        fi
    done

    [[ "$current" == "$file" ]]
}

path_is_within() {
    local child="$1"
    local parent="$2"

    [[ "$child" == "$parent" || "$child" == "$parent/"* ]]
}

update_internal_paths() {
    REAL_HASH_DB="$DEST/.hashes.db"
    REAL_JOURNAL_DB="$DEST/.hashes.journal"
    REAL_SOURCE_STATE_DB="$DEST/.source_state.db"

    RECOVERY_DIR="$DEST/.medien_sortierer_recovery"
    RECOVERY_QUARANTINE_DIR="$RECOVERY_DIR/quarantine"

    ZIP_REPORT_FILE="$DEST/Uebersprungene_ZIP-Dateien.txt"
    STATUS_RUN_FILE="$DEST/RUN.txt"
    STATUS_ERROR_FILE="$DEST/ERROR.txt"
    STATUS_FINISH_FILE="$DEST/FINISH.txt"
}

validate_permission_mode() {
    [[ "$1" =~ ^[0-7]{3,4}$ ]]
}

calculate_permission_umask() {
    local dir_mode="${PERMISSION_DIR_MODE: -3}"
    local file_mode="${PERMISSION_FILE_MODE: -3}"
    local dir_value=0
    local file_value=0
    local umask_value=0
    local expected_file_value=0

    dir_value=$((8#$dir_mode))
    file_value=$((8#$file_mode))
    umask_value=$((0777 & ~dir_value))
    expected_file_value=$((0666 & ~umask_value))

    (( file_value == expected_file_value )) || return 1

    printf -v PERMISSION_UMASK '%03o' "$umask_value"
    return 0
}

apply_file_permissions() {
    local file="$1"

    [[ "$SET_PERMISSIONS" == "on" && -e "$file" && ! -L "$file" ]] || return 0
    [[ "$file" == "$DEST/"* ]] || return 0

    if [[ -n "$PERMISSION_OWNER" ]]; then
        chown -- "$PERMISSION_OWNER" "$file" 2>/dev/null || \
            log_warn "Owner konnte nicht gesetzt werden: $file"
    fi

    chmod -- "$PERMISSION_FILE_MODE" "$file" 2>/dev/null || \
        log_warn "Dateirechte konnten nicht gesetzt werden: $file"
}

apply_dir_permissions() {
    local dir="$1"

    [[ "$SET_PERMISSIONS" == "on" && -d "$dir" && ! -L "$dir" ]] || return 0
    [[ "$dir" == "$DEST/"* ]] || return 0

    if [[ -n "$PERMISSION_OWNER" ]]; then
        chown -- "$PERMISSION_OWNER" "$dir" 2>/dev/null || \
            log_warn "Owner konnte nicht gesetzt werden: $dir"
    fi

    chmod -- "$PERMISSION_DIR_MODE" "$dir" 2>/dev/null || \
        log_warn "Ordnerrechte konnten nicht gesetzt werden: $dir"
}

apply_recursive_permissions() {
    local permission_failed=0

    [[ "$SET_PERMISSIONS" == "on" && "$RECURSIVE_PERMISSION_FIX" == "on" ]] || return 0
    [[ "$DRY_RUN" == "off" ]] || return 0
    [[ -d "$DEST" && ! -L "$DEST" ]] || return 1
    [[ "$(canonicalize_path "$DEST")" == "$DEST" ]] || return 1

    log_info "Korrigiere Rechte innerhalb des Zielordners für den SMB-Zugriff"

    # Ein streamender find-Durchlauf behandelt Ordner und Dateien mit getrennten
    # Modi. DEST selbst wird mit -mindepth 1 ausdrücklich ausgeschlossen. Owner
    # und Modus werden weiterhin pro kleinem Stapel gemeinsam gesetzt.
    find "$DEST" -xdev -mindepth 1 \
        \( -type d -exec sh -c '
            owner=$1
            mode=$2
            shift 2
            rc=0
            if [ -n "$owner" ]; then
                chown -- "$owner" "$@" || rc=1
            fi
            chmod -- "$mode" "$@" || rc=1
            exit "$rc"
        ' sh "$PERMISSION_OWNER" "$PERMISSION_DIR_MODE" {} + \) -o \
        \( -type f -exec sh -c '
            owner=$1
            mode=$2
            shift 2
            rc=0
            if [ -n "$owner" ]; then
                chown -- "$owner" "$@" || rc=1
            fi
            chmod -- "$mode" "$@" || rc=1
            exit "$rc"
        ' sh "$PERMISSION_OWNER" "$PERMISSION_FILE_MODE" {} + \) \
        2>/dev/null || permission_failed=1

    if [[ "$permission_failed" -ne 0 ]]; then
        log_warn "Mindestens ein Owner oder Dateimodus innerhalb des Zielordners konnte nicht angepasst werden"
        return 1
    fi

    log_ok "Rechte innerhalb des Zielordners rekursiv korrigiert"
    return 0
}
ensure_safe_directory() {
    local dir="$1"
    local relative=""
    local current="$DEST"
    local component=""
    local old_ifs=""
    local -a dir_parts=()

    dir="${dir%/}"
    path_is_within "$dir" "$DEST" || return 1
    has_unsupported_record_chars "$dir" && return 1

    if [[ "$dir" == "$DEST" ]]; then
        [[ -d "$DEST" && ! -L "$DEST" ]]
        return
    fi

    relative="${dir#"$DEST"/}"
    old_ifs="$IFS"
    IFS='/'
    read -r -a dir_parts <<< "$relative"
    IFS="$old_ifs"

    for component in "${dir_parts[@]}"
    do
        [[ -n "$component" && "$component" != "." && "$component" != ".." ]] || return 1
        current="$current/$component"

        if path_entry_exists "$current"; then
            [[ -d "$current" && ! -L "$current" ]] || return 1
        else
            mkdir -- "$current" || return 1
            apply_dir_permissions "$current"
        fi
    done

    [[ "$(canonicalize_path "$current")" == "$current" ]]
}

safe_parent_directory_for_target() {
    local target_file="$1"
    local parent=""

    path_is_within "$target_file" "$DEST" || return 1
    parent="${target_file%/*}"
    ensure_safe_directory "$parent"
}

validate_control_paths() {
    local control_path=""

    for control_path in \
        "$REAL_HASH_DB" "$REAL_JOURNAL_DB" "$REAL_SOURCE_STATE_DB" \
        "$ZIP_REPORT_FILE" "$STATUS_RUN_FILE" "$STATUS_ERROR_FILE" "$STATUS_FINISH_FILE" \
        "$RECOVERY_DIR" "$RECOVERY_QUARANTINE_DIR"
    do
        if [[ -L "$control_path" ]]; then
            log_error "Sicherheitsabbruch: interner Pfad ist ein symbolischer Link: $control_path"
            return 1
        fi

        path_is_within "$(canonicalize_path "$control_path")" "$DEST" || {
            log_error "Sicherheitsabbruch: interner Pfad liegt außerhalb von DEST: $control_path"
            return 1
        }
    done

    return 0
}

safe_remove_runtime_dir() {
    local dir="$1"

    if [[ -z "$dir" ]]; then
        return 0
    fi

    if [[ "$dir" =~ ^/tmp/medien_sortierer\.[A-Za-z0-9]+$ ]] || \
       [[ "$dir" =~ ^/tmp/medien_sortierer_[a-fA-F0-9]{64}\.lockdir$ ]]; then
        rm -rf -- "$dir" 2>/dev/null || true
        return 0
    fi

    log_warn "Unsicherer Cleanup-Pfad wurde nicht entfernt: $dir"
    return 1
}

check_free_space_for_file() {
    local target_dir="$1"
    local file_size="$2"
    local df_output=""
    local available_kib=""
    local required_kib=""
    local -a df_lines=()

    (( MIN_FREE_SPACE_MB > 0 )) || return 0
    valid_size "$file_size" || return 1

    df_output=$(df -Pk -- "$target_dir" 2>/dev/null) || return 1
    mapfile -t df_lines <<< "$df_output"
    (( ${#df_lines[@]} >= 2 )) || return 1
    IFS=' ' read -r _ _ _ available_kib _ <<< "${df_lines[1]}"
    valid_size "$available_kib" || return 1

    required_kib=$(( (file_size + 1023) / 1024 + MIN_FREE_SPACE_MB * 1024 ))
    (( available_kib >= required_kib ))
}

verify_atomic_publish_support() {
    local test_dir="${1:-$DEST}"
    local test_source=""
    local test_target=""

    [[ "$DRY_RUN" == "off" ]] || return 0
    [[ "$ATOMIC_PUBLISH_VERIFIED" -eq 0 ]] || return 0
    [[ -d "$test_dir" && ! -L "$test_dir" ]] || return 1
    path_is_within "$(canonicalize_path "$test_dir")" "$DEST" || return 1

    test_source=$(mktemp "$test_dir/.medien-linktest.XXXXXX") || return 1
    test_target="${test_source}.link"

    printf "test\n" > "$test_source" || {
        rm -f -- "$test_source"
        return 1
    }

    if ! ln -- "$test_source" "$test_target" 2>/dev/null; then
        rm -f -- "$test_source" "$test_target"
        return 1
    fi

    rm -f -- "$test_source" "$test_target"
    safe_sync_dir "$test_dir" || return 1
    ATOMIC_PUBLISH_VERIFIED=1
    return 0
}
stat_size() {
    stat -c%s "$1" 2>/dev/null || echo ""
}

stat_mtime() {
    stat -c%Y "$1" 2>/dev/null || echo ""
}

stat_ctime() {
    stat -c%Z "$1" 2>/dev/null || echo ""
}

stat_devino() {
    stat -c '%d:%i' "$1" 2>/dev/null || echo ""
}

# Liest alle für Source-State und Transfer-Sicherheit benötigten stat-Werte in
# genau einem Prozess. Die Ausgabe enthält ausschließlich numerische Felder.
read_file_snapshot() {
    local file="$1"

    FILE_SNAPSHOT_SIZE=""
    FILE_SNAPSHOT_MTIME=""
    FILE_SNAPSHOT_CTIME=""
    FILE_SNAPSHOT_DEVINO=""

    if ! IFS='|' read -r \
        FILE_SNAPSHOT_SIZE \
        FILE_SNAPSHOT_MTIME \
        FILE_SNAPSHOT_CTIME \
        FILE_SNAPSHOT_DEVINO \
        < <(stat -c '%s|%Y|%Z|%d:%i' -- "$file" 2>/dev/null); then
        return 1
    fi

    valid_size "$FILE_SNAPSHOT_SIZE" && \
        valid_stat_time_number "$FILE_SNAPSHOT_MTIME" && \
        valid_stat_time_number "$FILE_SNAPSHOT_CTIME" && \
        valid_devino "$FILE_SNAPSHOT_DEVINO"
}

snapshot_matches_expected() {
    local expected_size="$1"
    local expected_devino="$2"
    local expected_mtime="$3"
    local expected_ctime="$4"

    [[ "$FILE_SNAPSHOT_SIZE" == "$expected_size" ]] || return 1

    # Leere Identitätswerte kommen ausschließlich aus alten v1-Markern.
    [[ -z "$expected_devino" || "$FILE_SNAPSHOT_DEVINO" == "$expected_devino" ]] || return 1
    [[ -z "$expected_mtime" || "$FILE_SNAPSHOT_MTIME" == "$expected_mtime" ]] || return 1
    [[ -z "$expected_ctime" || "$FILE_SNAPSHOT_CTIME" == "$expected_ctime" ]] || return 1
}

# Liest SHA256 ohne nachgeschalteten awk-Prozess. GNU sha256sum markiert Namen
# mit Backslash durch einen führenden Backslash; dieser gehört nicht zum Hash.
calculate_file_hash() {
    local file="$1"

    HASH_RESULT=""

    if ! IFS=' ' read -r HASH_RESULT _ < <(sha256sum -- "$file" 2>/dev/null); then
        HASH_RESULT=""
        return 1
    fi

    HASH_RESULT="${HASH_RESULT#\\}"
    valid_hash "$HASH_RESULT"
}

calculate_text_hash() {
    local value="$1"

    HASH_RESULT=""

    if ! IFS=' ' read -r HASH_RESULT _ < <(printf '%s' "$value" | sha256sum); then
        HASH_RESULT=""
        return 1
    fi

    valid_hash "$HASH_RESULT"
}

safe_sync_path() {
    local path="$1"

    if [[ "$RUN_SYNC" != "on" || "$DRY_RUN" != "off" ]]; then
        return 0
    fi

    # GNU/coreutils unterstützt gezielten Sync eines Pfades. Falls eine kleinere
    # Linux-Variante das nicht kann, wird sicherheitshalber global synchronisiert.
    sync -- "$path" >/dev/null 2>&1 || sync >/dev/null 2>&1
}

safe_sync_dir() {
    local dir="$1"

    [[ -d "$dir" && ! -L "$dir" ]] || return 1

    if [[ "$RUN_SYNC" != "on" || "$DRY_RUN" != "off" ]]; then
        return 0
    fi

    sync -f -- "$dir" >/dev/null 2>&1 || sync >/dev/null 2>&1
}

set_single_file_permissions() {
    apply_file_permissions "$1"
}

write_status_file() {
    local target_file="$1"
    local content="$2"
    local target_dir=""
    local temp_file=""

    target_dir="${target_file%/*}"
    [[ -d "$target_dir" && ! -L "$target_dir" ]] || return 1
    [[ ! -L "$target_file" ]] || return 1

    temp_file=$(mktemp "$target_dir/.medien-status.XXXXXX") || return 1

    if printf "%s\n" "$content" > "$temp_file" 2>/dev/null && \
       safe_sync_path "$temp_file" && \
       mv -f -- "$temp_file" "$target_file" 2>/dev/null; then
        set_single_file_permissions "$target_file"
        safe_sync_path "$target_file" || return 1
        safe_sync_dir "$target_dir" || return 1
        return 0
    fi

    rm -f -- "$temp_file"
    return 1
}

# ---------------------------------------------
# LOCK, SIGNALE UND STATUS
# ---------------------------------------------

acquire_lock() {
    local lock_id=""
    local lock_target=""
    local old_pid=""

    if [[ "$ENABLE_LOCK" != "on" || "$EXTERNAL_LOCK_HELD" == "1" ]]; then
        return 0
    fi

    lock_target=$(canonicalize_path "$DEST") || {
        log_error "Lock-Ziel konnte nicht sicher aufgelöst werden"
        exit 1
    }

    calculate_text_hash "$lock_target" || {
        log_error "Lock-ID konnte nicht sicher berechnet werden"
        exit 1
    }
    lock_id="$HASH_RESULT"
    LOCK_DIR="/tmp/medien_sortierer_${lock_id}.lockdir"

    if mkdir "$LOCK_DIR" 2>/dev/null; then
        chmod 700 "$LOCK_DIR" 2>/dev/null || true
        printf "%s\n" "$$" > "$LOCK_DIR/pid" 2>/dev/null || true
        LOCK_OWNED=1
        log_info "Lock aktiv"
        return 0
    fi

    if [[ -f "$LOCK_DIR/pid" ]]; then
        old_pid=$(cat "$LOCK_DIR/pid" 2>/dev/null || true)
    fi

    if is_uint "$old_pid" && kill -0 "$old_pid" 2>/dev/null; then
        log_error "Script läuft bereits für dieses Ziel"
        exit 1
    fi

    log_warn "Verwaister Lock wird entfernt: $LOCK_DIR"
    safe_remove_runtime_dir "$LOCK_DIR"

    if path_entry_exists "$LOCK_DIR"; then
        log_error "Verwaister Lock konnte nicht entfernt werden"
        exit 1
    fi

    if ! mkdir "$LOCK_DIR" 2>/dev/null; then
        log_error "Lock konnte nicht angelegt werden"
        exit 1
    fi

    chmod 700 "$LOCK_DIR" 2>/dev/null || true
    printf "%s\n" "$$" > "$LOCK_DIR/pid" 2>/dev/null || true
    LOCK_OWNED=1
    log_info "Lock aktiv"
}

cleanup_stale_source_state_temps() {
    local candidate=""
    local basename_value=""
    local nullglob_was_set=0
    local removed_count=0
    local failed_count=0
    local -a candidates=()
    local -a removable=()

    [[ -d "$DEST" && ! -L "$DEST" ]] || return 1
    [[ "$(canonicalize_path "$DEST")" == "$DEST" ]] || return 1

    if shopt -q nullglob; then
        nullglob_was_set=1
    fi
    shopt -s nullglob
    candidates=("$DEST"/.source_state.tmp.*)
    if (( nullglob_was_set == 0 )); then
        shopt -u nullglob
    fi

    for candidate in "${candidates[@]}"
    do
        basename_value="${candidate##*/}"

        # Nur exakt die von mktemp mit sechs Zufallszeichen erzeugte Form ist
        # entbehrlich. Ähnlich benannte Dateien, Links und Ordner bleiben stehen.
        if [[ ! "$basename_value" =~ ^[.]source_state[.]tmp[.][A-Za-z0-9]{6}$ ]]; then
            log_warn "Ähnlich benannte Source-State-Datei wird nicht automatisch entfernt: $candidate"
            continue
        fi

        if [[ -L "$candidate" || ! -f "$candidate" ]]; then
            log_warn "Source-State-Temp-Pfad ist keine reguläre Datei und bleibt unverändert: $candidate"
            continue
        fi

        removable+=("$candidate")
    done

    (( ${#removable[@]} > 0 )) || return 0

    if [[ "$DRY_RUN" == "on" ]]; then
        log_info "Testlauf: verwaiste Source-State-Temp-Dateien gefunden: ${#removable[@]}"
        return 0
    fi

    # Ohne exklusiven Ziel-Lock könnte eine Datei zu einem parallelen Lauf
    # gehören. In diesem Fall wird bewusst nur gewarnt und nichts gelöscht.
    if [[ "$ENABLE_LOCK" != "on" || \
          ( "$LOCK_OWNED" -ne 1 && "$EXTERNAL_LOCK_HELD" != "1" ) ]]; then
        log_warn "Verwaiste Source-State-Temp-Dateien nicht entfernt, weil kein exklusiver Ziel-Lock aktiv ist: ${#removable[@]}"
        return 0
    fi

    for candidate in "${removable[@]}"
    do
        if rm -f -- "$candidate" 2>/dev/null && ! path_entry_exists "$candidate"; then
            removed_count=$((removed_count + 1))
        else
            failed_count=$((failed_count + 1))
            log_warn "Verwaiste Source-State-Temp-Datei konnte nicht entfernt werden: $candidate"
        fi
    done

    if (( removed_count > 0 )); then
        log_ok "Verwaiste Source-State-Temp-Dateien entfernt: $removed_count"
    fi

    (( failed_count == 0 ))
}

status_start() {
    local content=""

    if [[ "$DRY_RUN" != "off" ]]; then
        return 0
    fi

    rm -f -- "$STATUS_RUN_FILE" "$STATUS_ERROR_FILE" "$STATUS_FINISH_FILE"

    content=$(cat <<EOF
Status: RUN
Gestartet: $START_TIME_TEXT
Modus: $PROCESS_MODE
Quelle: $SRC
Ziel: $DEST
Prozess-ID: $$
EOF
)

    if write_status_file "$STATUS_RUN_FILE" "$content"; then
        STATUS_TRACKING_ACTIVE=1
    else
        log_warn "RUN.txt konnte nicht erstellt werden: $STATUS_RUN_FILE"
    fi
}


status_watch_standby() {
    local last_worker_exit="${1:-}"
    local now_text=""
    local last_result="Noch kein Sortierlauf in dieser Watch-Sitzung"
    local content=""

    [[ "$DRY_RUN" == "off" ]] || return 0
    [[ -d "$DEST" && ! -L "$DEST" ]] || return 1

    now_text=$(date '+%d.%m.%Y %H:%M:%S')

    if [[ -n "$last_worker_exit" ]]; then
        if [[ "$last_worker_exit" -eq 0 ]]; then
            last_result="Erfolgreich abgeschlossen"
            rm -f -- "$STATUS_ERROR_FILE"
        else
            last_result="Mit Exit-Code $last_worker_exit beendet; ERROR.txt bleibt erhalten"
        fi
    fi

    rm -f -- "$STATUS_FINISH_FILE"

    content=$(cat <<EOF
Status: STANDBY
Watcher gestartet: $START_TIME_TEXT
Stand aktualisiert: $now_text
Zustand: Wartet auf neue oder geänderte Dateien im Input
Backend: $WATCH_BACKEND_EFFECTIVE
Ruhezeit nach letztem Ereignis: ${WATCH_IDLE_SECONDS}s
Letzter Sortierlauf: $last_result
Modus: $PROCESS_MODE
Quelle: $SRC
Ziel: $DEST
Watcher-Prozess-ID: $$
EOF
)

    if write_status_file "$STATUS_RUN_FILE" "$content"; then
        STATUS_TRACKING_ACTIVE=1
        return 0
    fi

    log_warn "Standby-Status konnte nicht in RUN.txt geschrieben werden"
    return 1
}

cleanup_and_finalize() {
    local exit_code="$1"
    local end_time=""
    local end_time_text=""
    local runtime=""
    local status_content=""
    local final_error=0
    local error_message=""

    rm -f -- \
        "${SOURCE_STATE_NEW:-}" \
        "${FILE_LIST:-}" \
        "${INPUT_FILE_LIST:-}" \
        "${ZIP_PATH_LIST:-}" 2>/dev/null || true

    if [[ -n "${TMP_ROOT:-}" && -d "$TMP_ROOT" ]]; then
        safe_remove_runtime_dir "$TMP_ROOT"
    fi

    if [[ "$LOCK_OWNED" -eq 1 && -n "$LOCK_DIR" ]]; then
        safe_remove_runtime_dir "$LOCK_DIR"
    fi

    if [[ "$STATUS_TRACKING_ACTIVE" -ne 1 ]]; then
        return 0
    fi

    end_time=$(date +%s)
    end_time_text=$(date '+%d.%m.%Y %H:%M:%S')
    runtime=$((end_time - START_TIME))

    if [[ "$exit_code" -ne 0 || \
          "$SCRIPT_HAD_ERROR" -ne 0 || \
          "$COUNT_TRANSFER_FAILED" -gt 0 || \
          "$COUNT_RECOVERY_FAILED" -gt 0 || \
          "$SCRIPT_COMPLETED" -ne 1 ]]; then
        final_error=1
    fi

    rm -f -- "$STATUS_RUN_FILE"

    if [[ "$final_error" -eq 1 ]]; then
        rm -f -- "$STATUS_FINISH_FILE"

        error_message="$LAST_ERROR_MESSAGE"
        if [[ -z "$error_message" && "$COUNT_TRANSFER_FAILED" -gt 0 ]]; then
            error_message="Mindestens ein Datei-Transfer ist fehlgeschlagen."
        elif [[ -z "$error_message" && "$COUNT_RECOVERY_FAILED" -gt 0 ]]; then
            error_message="Mindestens eine Wiederherstellung ist fehlgeschlagen."
        elif [[ -z "$error_message" ]]; then
            error_message="Der Scriptlauf wurde nicht vollständig abgeschlossen."
        fi

        status_content=$(cat <<EOF
Status: ERROR
Gestartet: $START_TIME_TEXT
Beendet/abgebrochen: $end_time_text
Laufzeit: ${runtime}s
Modus: $PROCESS_MODE
Exit-Code: $exit_code
Fehlgeschlagene Transfers: $COUNT_TRANSFER_FAILED
Recovery-Fehler: $COUNT_RECOVERY_FAILED
Letzte Fehlerangabe: $error_message
Quelle: $SRC
Ziel: $DEST
EOF
)

        write_status_file "$STATUS_ERROR_FILE" "$status_content" || true
    else
        rm -f -- "$STATUS_ERROR_FILE"

        status_content=$(cat <<EOF
Status: FINISH
Gestartet: $START_TIME_TEXT
Beendet: $end_time_text
Laufzeit: ${runtime}s
Modus: $PROCESS_MODE
Neue Originale: $COUNT_ORIGINAL
Duplikate erkannt: $COUNT_DUPLICATE
Übersprungene ZIP-Dateien: $COUNT_ZIP_SKIPPED
Übersprungene sonstige Dateien: $COUNT_SKIPPED
Fehlgeschlagene Transfers: $COUNT_TRANSFER_FAILED
Recovery abgeschlossen: $COUNT_RECOVERY_COMPLETED
Recovery repariert: $COUNT_RECOVERY_REPAIRED
Quelle: $SRC
Ziel: $DEST
EOF
)

        write_status_file "$STATUS_FINISH_FILE" "$status_content" || true
    fi
}

handle_signal() {
    local signal_name="$1"
    local exit_code="$2"

    SCRIPT_HAD_ERROR=1
    LAST_ERROR_MESSAGE="Script durch Signal $signal_name abgebrochen"

    if [[ -n "$ACTIVE_TRANSFER_PID" ]] && kill -0 "$ACTIVE_TRANSFER_PID" 2>/dev/null; then
        kill -TERM "$ACTIVE_TRANSFER_PID" 2>/dev/null || true
        wait "$ACTIVE_TRANSFER_PID" 2>/dev/null || true
        ACTIVE_TRANSFER_PID=""
    fi

    if [[ -n "$WATCH_MONITOR_PID" ]] && kill -0 "$WATCH_MONITOR_PID" 2>/dev/null; then
        kill -TERM "$WATCH_MONITOR_PID" 2>/dev/null || true
        wait "$WATCH_MONITOR_PID" 2>/dev/null || true
        WATCH_MONITOR_PID=""
    fi

    if [[ -n "$ACTIVE_WAIT_PID" ]] && kill -0 "$ACTIVE_WAIT_PID" 2>/dev/null; then
        kill -TERM "$ACTIVE_WAIT_PID" 2>/dev/null || true
        wait "$ACTIVE_WAIT_PID" 2>/dev/null || true
        ACTIVE_WAIT_PID=""
    fi

    if [[ -n "$WATCH_CHILD_PID" ]] && kill -0 "$WATCH_CHILD_PID" 2>/dev/null; then
        kill -TERM "$WATCH_CHILD_PID" 2>/dev/null || true
        wait "$WATCH_CHILD_PID" 2>/dev/null || true
    fi

    exit "$exit_code"
}

trap 'cleanup_and_finalize $?' EXIT
trap 'handle_signal INT 130' INT
trap 'handle_signal TERM 143' TERM
trap 'handle_signal HUP 129' HUP

# ---------------------------------------------
# STARTVERZÖGERUNG
# ---------------------------------------------

if ! command -v sha256sum >/dev/null 2>&1; then
    log_error "sha256sum wurde nicht gefunden"
    exit 1
fi

check_on_off_setting "ENABLE_LOCK" "$ENABLE_LOCK"

# Quelle und Ziel müssen bereits vorhanden sein. Diese frühe Prüfung erfolgt vor
# Lock und Startverzögerung, damit bei einem falschen Pfad nichts angelegt wird.
if [[ ! -d "$SRC" || -L "$SRC" ]]; then
    log_error "SRC existiert nicht als echter Ordner: $SRC"
    exit 1
fi

if [[ ! -d "$DEST" || -L "$DEST" ]]; then
    log_error "DEST existiert nicht als echter Ordner: $DEST"
    exit 1
fi

acquire_lock

if ! is_uint "$START_DELAY_EFFECTIVE"; then
    log_error "START_DELAY_SECONDS muss eine nichtnegative Ganzzahl sein"
    exit 1
fi

if (( START_DELAY_EFFECTIVE > 0 )); then
    log_info "Startverzögerung: ${START_DELAY_EFFECTIVE}s"

    sleep "$START_DELAY_EFFECTIVE" &
    ACTIVE_WAIT_PID=$!

    if ! wait "$ACTIVE_WAIT_PID"; then
        ACTIVE_WAIT_PID=""
        log_error "Startverzögerung wurde unerwartet beendet"
        exit 1
    fi

    ACTIVE_WAIT_PID=""
fi

echo "========================================"
echo "[START] Medien-Sortierung gestartet"
echo "========================================"

# ---------------------------------------------
# EINSTELLUNGEN UND PROGRAMME PRÜFEN
# ---------------------------------------------

for setting_name in \
    DRY_RUN FAST_MODE QUICK_DEST_CHECK SOURCE_STATE_CACHE \
    DELETE_EMPTY_SRC_DIRS DELETE_EMPTY_DEST_DIRS SET_PERMISSIONS RECURSIVE_PERMISSION_FIX \
    ENABLE_LOCK RUN_SYNC WATCH_MODE WATCH_PROCESS_EXISTING
do
    check_on_off_setting "$setting_name" "${!setting_name}"
done

if [[ "$PROCESS_MODE" != "move" && "$PROCESS_MODE" != "copy" ]]; then
    log_error "PROCESS_MODE muss move oder copy sein"
    exit 1
fi

if [[ "$DUPLICATE_ACTION" != "auto" && \
      "$DUPLICATE_ACTION" != "store" && \
      "$DUPLICATE_ACTION" != "skip" ]]; then
    log_error "DUPLICATE_ACTION muss auto, store oder skip sein"
    exit 1
fi

if [[ "$EXIFTOOL_MODE" != "auto" && \
      "$EXIFTOOL_MODE" != "system" && \
      "$EXIFTOOL_MODE" != "path" ]]; then
    log_error "EXIFTOOL_MODE muss auto, system oder path sein"
    exit 1
fi

if [[ "$SORT_STRUCTURE" != "year_month" && \
      "$SORT_STRUCTURE" != "year" && \
      "$SORT_STRUCTURE" != "flat" ]]; then
    log_error "SORT_STRUCTURE muss year_month, year oder flat sein"
    exit 1
fi

if [[ "$DATABASE_MODE" != "persistent" && "$DATABASE_MODE" != "temporary" ]]; then
    log_error "DATABASE_MODE muss persistent oder temporary sein"
    exit 1
fi

if [[ "$DEST_CHECK_LEVEL" != "size" && "$DEST_CHECK_LEVEL" != "hash" ]]; then
    log_error "DEST_CHECK_LEVEL muss size oder hash sein"
    exit 1
fi

if [[ "$WATCH_BACKEND" != "auto" && \
      "$WATCH_BACKEND" != "inotify" && \
      "$WATCH_BACKEND" != "poll" ]]; then
    log_error "WATCH_BACKEND muss auto, inotify oder poll sein"
    exit 1
fi

if ! is_uint "$WATCH_IDLE_SECONDS"; then
    log_error "WATCH_IDLE_SECONDS muss eine nichtnegative Ganzzahl sein"
    exit 1
fi

if ! is_uint "$WATCH_POLL_SECONDS" || (( WATCH_POLL_SECONDS < 1 )); then
    log_error "WATCH_POLL_SECONDS muss eine Ganzzahl ab 1 sein"
    exit 1
fi

if ! is_uint "$MIN_FREE_SPACE_MB"; then
    log_error "MIN_FREE_SPACE_MB muss eine nichtnegative Ganzzahl sein"
    exit 1
fi

if ! validate_permission_mode "$PERMISSION_DIR_MODE" || \
   ! validate_permission_mode "$PERMISSION_FILE_MODE"; then
    log_error "PERMISSION_DIR_MODE und PERMISSION_FILE_MODE müssen numerische Oktalwerte sein"
    exit 1
fi

if [[ "$SET_PERMISSIONS" == "on" ]] && ! calculate_permission_umask; then
    log_error "PERMISSION_DIR_MODE und PERMISSION_FILE_MODE müssen mit derselben umask darstellbar sein, z. B. 777/666 oder 775/664"
    exit 1
fi

if (( BASH_VERSINFO[0] < 4 )); then
    log_error "Dieses Script benötigt Bash 4 oder neuer"
    exit 1
fi

for required_command in \
    cat chmod chown cp date df find id ln mktemp mv \
    rm rmdir sha256sum sh sleep sort stat sync
 do
    if ! command -v "$required_command" >/dev/null 2>&1; then
        log_error "Benötigtes Programm fehlt: $required_command"
        exit 1
    fi
 done

if ! command -v realpath >/dev/null 2>&1 && ! command -v readlink >/dev/null 2>&1; then
    log_error "Für sichere Pfadprüfung wird realpath oder readlink benötigt"
    exit 1
fi

find_exiftool() {
    local system_exiftool=""

    case "$EXIFTOOL_MODE" in
        path)
            [[ -x "$EXIFTOOL_PATH" ]] || return 1
            EXIFTOOL="$EXIFTOOL_PATH"
            ;;
        system)
            system_exiftool=$(command -v exiftool 2>/dev/null || true)
            [[ -n "$system_exiftool" && -x "$system_exiftool" ]] || return 1
            EXIFTOOL="$system_exiftool"
            ;;
        auto)
            if [[ -n "$EXIFTOOL_PATH" && -x "$EXIFTOOL_PATH" ]]; then
                EXIFTOOL="$EXIFTOOL_PATH"
            else
                system_exiftool=$(command -v exiftool 2>/dev/null || true)
                [[ -n "$system_exiftool" && -x "$system_exiftool" ]] || return 1
                EXIFTOOL="$system_exiftool"
            fi
            ;;
    esac

    EXIFTOOL_VERSION=$("$EXIFTOOL" -ver 2>/dev/null) || return 1
    [[ "$EXIFTOOL_VERSION" =~ ^[0-9]+([.][0-9]+)*$ ]]
}

if ! find_exiftool; then
    log_error "ExifTool wurde nicht gefunden oder die Versionsprüfung ist fehlgeschlagen"
    exit 1
fi

log_ok "ExifTool bereit: $EXIFTOOL ($EXIFTOOL_VERSION)"

SRC="${SRC%/}"
DEST="${DEST%/}"

if [[ -z "$SRC" || -z "$DEST" ]]; then
    log_error "SRC und DEST dürfen nicht leer sein"
    exit 1
fi

if has_unsupported_record_chars "$SRC" || has_unsupported_record_chars "$DEST"; then
    log_error "SRC und DEST dürfen kein | und keine Zeilenumbrüche enthalten"
    exit 1
fi

if [[ -L "$SRC" || -L "$DEST" ]]; then
    log_error "SRC und DEST dürfen aus Sicherheitsgründen keine symbolischen Links sein"
    exit 1
fi

SRC_CANONICAL=$(canonicalize_path "$SRC") || {
    log_error "SRC konnte nicht sicher aufgelöst werden: $SRC"
    exit 1
}
DEST_CANONICAL=$(canonicalize_path "$DEST") || {
    log_error "DEST konnte nicht sicher aufgelöst werden: $DEST"
    exit 1
}

SRC="$SRC_CANONICAL"
DEST="$DEST_CANONICAL"
update_internal_paths

case "$SRC" in
    /|/bin|/boot|/dev|/etc|/home|/lib|/lib64|/mnt|/mnt/user|/opt|/proc|/root|/run|/sbin|/srv|/sys|/tmp|/usr|/var)
        log_error "Gefährlicher SRC-Pfad: $SRC"
        exit 1
        ;;
esac

case "$DEST" in
    /|/bin|/boot|/dev|/etc|/home|/lib|/lib64|/mnt|/mnt/user|/opt|/proc|/root|/run|/sbin|/srv|/sys|/tmp|/usr|/var)
        log_error "Gefährlicher DEST-Pfad: $DEST"
        exit 1
        ;;
esac

if [[ "$SRC" == "$DEST" ]]; then
    log_error "SRC und DEST dürfen nicht identisch sein"
    exit 1
fi

if path_is_within "$DEST" "$SRC"; then
    log_error "DEST darf nicht innerhalb von SRC liegen"
    exit 1
fi

if path_is_within "$SRC" "$DEST"; then
    log_error "SRC darf nicht innerhalb von DEST liegen"
    exit 1
fi

if [[ ! -d "$SRC" || -L "$SRC" ]]; then
    log_error "SRC existiert nicht als echter Ordner: $SRC"
    exit 1
fi

if [[ ! -d "$DEST" || -L "$DEST" ]]; then
    log_error "DEST existiert nicht als echter Ordner: $DEST"
    exit 1
fi

if [[ "$DRY_RUN" == "off" ]]; then
    validate_control_paths || exit 1
else
    log_warn "DRY_RUN ist aktiv. Es werden keine echten Änderungen ausgeführt."
fi

if [[ "$SET_PERMISSIONS" == "on" ]]; then
    umask "$PERMISSION_UMASK"
    log_info "Dynamische umask aus den Rechtevariablen: $PERMISSION_UMASK"
fi

if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
    log_warn "Script läuft als root; strenge Pfad-, Symlink- und Überschreibschutzprüfungen sind aktiv."
fi

if [[ "$DUPLICATE_ACTION" == "auto" ]]; then
    if [[ "$PROCESS_MODE" == "move" ]]; then
        EFFECTIVE_DUPLICATE_ACTION="store"
    else
        EFFECTIVE_DUPLICATE_ACTION="skip"
    fi
else
    EFFECTIVE_DUPLICATE_ACTION="$DUPLICATE_ACTION"
fi

if [[ "$PROCESS_MODE" == "copy" && "$SOURCE_STATE_CACHE" == "on" ]]; then
    SOURCE_STATE_CACHE_EFFECTIVE="on"
else
    SOURCE_STATE_CACHE_EFFECTIVE="off"
fi

if ! cleanup_stale_source_state_temps; then
    log_warn "Verwaiste Source-State-Temp-Dateien konnten nicht vollständig bereinigt werden"
fi

if [[ "$RUN_SYNC" == "off" && "$DRY_RUN" == "off" ]]; then
    log_warn "RUN_SYNC=off reduziert den Schutz bei plötzlichem Stromausfall"
fi

# ---------------------------------------------
# WATCH-CONTROLLER
# ---------------------------------------------

run_watch_worker() {
    local worker_exit=0

    log_info "Starte überwachten Sortierlauf"

    MEDIEN_SORTIERER_WORKER=1 \
    MEDIEN_SORTIERER_LOCK_HELD=1 \
        bash "$SCRIPT_PATH" &

    WATCH_CHILD_PID=$!

    if [[ "$LOCK_OWNED" -eq 1 && -n "$LOCK_DIR" ]]; then
        printf "%s\n" "$WATCH_CHILD_PID" > "$LOCK_DIR/pid" 2>/dev/null || true
    fi

    wait "$WATCH_CHILD_PID"
    worker_exit=$?
    WATCH_CHILD_PID=""

    if [[ "$LOCK_OWNED" -eq 1 && -n "$LOCK_DIR" ]]; then
        printf "%s\n" "$$" > "$LOCK_DIR/pid" 2>/dev/null || true
    fi

    if [[ "$worker_exit" -eq 0 ]]; then
        log_ok "Überwachter Sortierlauf abgeschlossen"
    else
        log_warn "Überwachter Sortierlauf endete mit Exit-Code $worker_exit"
    fi

    status_watch_standby "$worker_exit" || true
    return "$worker_exit"
}

input_has_any_file() {
    local candidate=""

    IFS= read -r -d '' candidate < <(
        find "$SRC" -xdev -type f -print0 -quit 2>/dev/null
    )
}

recovery_work_pending() {
    local marker=""

    if [[ "$DATABASE_MODE" == "persistent" ]] && path_entry_exists "$REAL_JOURNAL_DB"; then
        return 0
    fi

    [[ -d "$RECOVERY_DIR" && ! -L "$RECOVERY_DIR" ]] || return 1

    IFS= read -r -d '' marker < <(
        find "$RECOVERY_DIR" -xdev -maxdepth 1 -type f -name '*.txn' -print0 -quit 2>/dev/null
    )
}

watch_initial_worker_needed() {
    if recovery_work_pending; then
        log_info "Offene Recovery- oder Journal-Arbeit gefunden; starte sofort einen sicheren Sortierlauf."
        return 0
    fi

    [[ "$WATCH_PROCESS_EXISTING" == "on" ]] && input_has_any_file
}

input_has_processable_media() {
    local candidate=""
    local ext=""

    while IFS= read -r -d '' candidate
    do
        has_unsupported_record_chars "$candidate" && continue
        ext="${candidate##*.}"
        ext="${ext,,}"
        case "$ext" in
            jpg|jpeg|png|heic|heif|webp|gif|bmp|tif|tiff|dng|raw|mp4|mov|m4v|avi|mkv|mpg|mpeg|3gp|3gpp|webm)
                return 0
                ;;
        esac
    done < <(find "$SRC" -xdev -type f -print0 2>/dev/null)

    return 1
}

resolve_watch_backend() {
    case "$WATCH_BACKEND" in
        auto)
            if command -v inotifywait >/dev/null 2>&1; then
                WATCH_BACKEND_EFFECTIVE="inotify"
            else
                WATCH_BACKEND_EFFECTIVE="poll"
                log_warn "WATCH_BACKEND=auto fällt auf Polling zurück; dies kann Festplatten wach halten."
            fi
            ;;
        inotify)
            if ! command -v inotifywait >/dev/null 2>&1; then
                log_error "WATCH_BACKEND=inotify benötigt inotifywait. Es wird nichts automatisch installiert."
                return 1
            fi
            WATCH_BACKEND_EFFECTIVE="inotify"
            ;;
        poll)
            WATCH_BACKEND_EFFECTIVE="poll"
            ;;
    esac

    return 0
}

poll_snapshot_hash() {
    local snapshot_hash=""

    # NUL-getrennte und sortierte Pfade halten auch Leerzeichen und Zeilenumbrüche aus.
    # Größe, mtime und ctime erkennen neue, gelöschte und weiter beschriebene Dateien.
    if ! IFS=' ' read -r snapshot_hash _ < <({
        while IFS= read -r -d '' poll_file
        do
            printf 'FILE\0%s\0' "$poll_file"

            if [[ -f "$poll_file" ]]; then
                stat -c 'SIZE\0%s\0MTIME\0%Y\0CTIME\0%Z\0' "$poll_file" 2>/dev/null || \
                    printf 'MISSING\0'
            else
                printf 'MISSING\0'
            fi
        done < <(find "$SRC" -xdev -type f -print0 2>/dev/null | sort -z)
    } | sha256sum); then
        return 1
    fi

    valid_hash "$snapshot_hash" || return 1
    printf '%s' "$snapshot_hash"
}

watch_sleep() {
    local seconds="$1"
    local sleep_exit=0

    sleep "$seconds" &
    ACTIVE_WAIT_PID=$!
    wait "$ACTIVE_WAIT_PID" || sleep_exit=$?
    ACTIVE_WAIT_PID=""
    return "$sleep_exit"
}

watch_event_file_size() {
    stat -c%s "$WATCH_EVENT_FILE" 2>/dev/null || echo ""
}

start_inotify_monitor() {
    if [[ "$ENABLE_LOCK" == "on" && -n "$LOCK_DIR" && -d "$LOCK_DIR" ]]; then
        WATCH_EVENT_FILE="$LOCK_DIR/inotify.events"
        : > "$WATCH_EVENT_FILE" || return 1
    else
        WATCH_EVENT_FILE=$(mktemp "/tmp/medien_sortierer.watch.XXXXXX") || return 1
    fi

    inotifywait -q -m -r \
        -e close_write,moved_to \
        --format '1' \
        "$SRC" >> "$WATCH_EVENT_FILE" 2>/dev/null &
    WATCH_MONITOR_PID=$!

    watch_sleep 1 || return 1
    kill -0 "$WATCH_MONITOR_PID" 2>/dev/null
}

stop_inotify_monitor() {
    if [[ -n "$WATCH_MONITOR_PID" ]] && kill -0 "$WATCH_MONITOR_PID" 2>/dev/null; then
        kill -TERM "$WATCH_MONITOR_PID" 2>/dev/null || true
        wait "$WATCH_MONITOR_PID" 2>/dev/null || true
    fi
    WATCH_MONITOR_PID=""

    if [[ -n "$WATCH_EVENT_FILE" && "$WATCH_EVENT_FILE" == /tmp/medien_sortierer.watch.* ]]; then
        rm -f -- "$WATCH_EVENT_FILE" 2>/dev/null || true
    fi
}

wait_for_inotify_event_after() {
    local previous_size="$1"
    local current_size=""

    while true
    do
        kill -0 "$WATCH_MONITOR_PID" 2>/dev/null || return 1
        current_size=$(watch_event_file_size)
        valid_size "$current_size" || return 1

        if (( current_size > previous_size )); then
            WATCH_EVENT_SIZE="$current_size"
            return 0
        fi

        watch_sleep 1 || return 1
    done
}

wait_for_inotify_quiet() {
    local current_size=""
    local last_size=""
    local last_change=""
    local now=""

    current_size=$(watch_event_file_size)
    valid_size "$current_size" || return 1
    last_size="$current_size"
    last_change=$(date +%s)

    if (( WATCH_IDLE_SECONDS == 0 )); then
        WATCH_EVENT_SIZE="$last_size"
        return 0
    fi

    while true
    do
        kill -0 "$WATCH_MONITOR_PID" 2>/dev/null || return 1
        watch_sleep 1 || return 1

        current_size=$(watch_event_file_size)
        valid_size "$current_size" || return 1
        now=$(date +%s)

        if [[ "$current_size" != "$last_size" ]]; then
            last_size="$current_size"
            last_change="$now"
            continue
        fi

        if (( now - last_change >= WATCH_IDLE_SECONDS )); then
            WATCH_EVENT_SIZE="$last_size"
            return 0
        fi
    done
}

run_worker_with_inotify_drain() {
    local baseline_size=""
    local after_size=""
    local worker_exit=0

    while true
    do
        baseline_size=$(watch_event_file_size)
        valid_size "$baseline_size" || return 1

        run_watch_worker || worker_exit=$?

        after_size=$(watch_event_file_size)
        valid_size "$after_size" || return 1

        if [[ "$after_size" == "$baseline_size" ]]; then
            return "$worker_exit"
        fi

        log_info "Während der Sortierung wurden weitere Dateien erkannt; warte erneut auf Ruhe."
        wait_for_inotify_quiet || return 1
        worker_exit=0
    done
}

watch_controller_inotify() {
    local event_size=0

    log_info "Überwachungsart: inotify"
    log_info "Die Überwachung bleibt auch während eines Sortierlaufs aktiv."
    log_info "Im Leerlauf wird ausschließlich eine Ereignisdatei unter /tmp geprüft."

    start_inotify_monitor || {
        log_error "Dauerhafte inotify-Überwachung konnte nicht gestartet werden"
        return 1
    }

    event_size=$(watch_event_file_size)
    valid_size "$event_size" || {
        stop_inotify_monitor
        return 1
    }

    if watch_initial_worker_needed; then
        run_worker_with_inotify_drain || true
        event_size=$(watch_event_file_size)
    fi

    # STANDBY bedeutet nun wirklich: Monitor läuft und neue Ereignisse können
    # nicht mehr unbemerkt zwischen Statusdatei und Watcher-Start eintreffen.
    status_watch_standby || true

    while true
    do
        if ! wait_for_inotify_event_after "$event_size"; then
            stop_inotify_monitor
            log_error "inotify-Überwachung wurde beendet oder SRC ist nicht mehr verfügbar"
            return 1
        fi

        wait_for_inotify_quiet || {
            stop_inotify_monitor
            log_error "inotify-Ruhezeit konnte nicht sicher überwacht werden"
            return 1
        }

        event_size="$WATCH_EVENT_SIZE"
        run_worker_with_inotify_drain || true
        event_size=$(watch_event_file_size)
    done
}

wait_for_poll_quiet() {
    local previous=""
    local current=""
    local last_change=""
    local now=""

    previous=$(poll_snapshot_hash) || return 1
    last_change=$(date +%s)

    if (( WATCH_IDLE_SECONDS == 0 )); then
        return 0
    fi

    while true
    do
        watch_sleep "$WATCH_POLL_SECONDS" || return 1
        current=$(poll_snapshot_hash) || return 1
        now=$(date +%s)

        if [[ "$current" != "$previous" ]]; then
            previous="$current"
            last_change="$now"
            continue
        fi

        (( now - last_change >= WATCH_IDLE_SECONDS )) && return 0
    done
}

run_worker_with_poll_drain() {
    local before_snapshot=""
    local after_snapshot=""
    local worker_exit=0
    local followup_runs=0

    while true
    do
        before_snapshot=$(poll_snapshot_hash) || return 1
        worker_exit=0
        run_watch_worker || worker_exit=$?
        after_snapshot=$(poll_snapshot_hash) || return 1

        if [[ "$PROCESS_MODE" == "copy" ]]; then
            [[ "$after_snapshot" == "$before_snapshot" ]] && return "$worker_exit"
        else
            input_has_processable_media || return "$worker_exit"
        fi

        followup_runs=$((followup_runs + 1))
        if (( followup_runs >= 10 )); then
            log_warn "Polling: maximal 10 direkte Folgeläufe erreicht; verbleibende Dateien werden beim nächsten Ereignis erneut geprüft."
            return "$worker_exit"
        fi

        log_info "Polling: Während der Sortierung änderte sich der Input; warte erneut auf Ruhe."
        wait_for_poll_quiet || return 1
    done
}

watch_controller_poll() {
    local previous_snapshot=""
    local current_snapshot=""
    local last_change=0
    local now=0
    local pending_run=0

    if ! command -v sort >/dev/null 2>&1; then
        log_error "WATCH_BACKEND=poll benötigt das Programm sort"
        return 1
    fi

    log_info "Überwachungsart: Polling"
    log_info "Prüfintervall: ${WATCH_POLL_SECONDS}s"
    log_warn "Polling liest den Input regelmäßig und kann dadurch Festplatten wach halten oder aufwecken."

    previous_snapshot=$(poll_snapshot_hash) || {
        log_error "Erster Polling-Schnappschuss konnte nicht erstellt werden"
        return 1
    }

    if watch_initial_worker_needed; then
        run_worker_with_poll_drain || true
        previous_snapshot=$(poll_snapshot_hash) || return 1
    fi

    status_watch_standby || true

    while true
    do
        watch_sleep "$WATCH_POLL_SECONDS" || return 1

        current_snapshot=$(poll_snapshot_hash) || {
            log_error "Polling-Schnappschuss konnte nicht erstellt werden"
            return 1
        }

        now=$(date +%s)

        if [[ "$current_snapshot" != "$previous_snapshot" ]]; then
            previous_snapshot="$current_snapshot"
            last_change="$now"
            pending_run=1
            continue
        fi

        if [[ "$pending_run" -eq 1 ]] && \
           (( WATCH_IDLE_SECONDS == 0 || now - last_change >= WATCH_IDLE_SECONDS )); then
            run_worker_with_poll_drain || true
            previous_snapshot=$(poll_snapshot_hash) || return 1
            pending_run=0
        fi
    done
}

watch_controller() {
    if ! resolve_watch_backend; then
        return 1
    fi

    log_info "Watch-Modus aktiv"
    log_info "Überwachter Ordner: $SRC"
    log_info "Ruhezeit: ${WATCH_IDLE_SECONDS}s"
    log_info "Gewähltes Backend: $WATCH_BACKEND"
    log_info "Verwendetes Backend: $WATCH_BACKEND_EFFECTIVE"

    case "$WATCH_BACKEND_EFFECTIVE" in
        inotify) watch_controller_inotify ;;
        poll)    watch_controller_poll ;;
        *)
            log_error "Interner Fehler: unbekanntes Watch-Backend"
            return 1
            ;;
    esac
}

if [[ "$WATCH_MODE_EFFECTIVE" == "on" ]]; then
    watch_controller
    WATCH_EXIT=$?
    exit "$WATCH_EXIT"
fi

# ---------------------------------------------
# STATUS UND ARBEITSDATEIEN VORBEREITEN
# ---------------------------------------------

status_start

TMP_ROOT=$(mktemp -d "/tmp/medien_sortierer.XXXXXX") || {
    log_error "Temporäres Arbeitsverzeichnis konnte nicht erstellt werden"
    exit 1
}
chmod 700 "$TMP_ROOT" 2>/dev/null || true

FILE_LIST="$TMP_ROOT/filelist"
INPUT_FILE_LIST="$TMP_ROOT/input_filelist"
ZIP_PATH_LIST="$TMP_ROOT/zip_paths"
: > "$ZIP_PATH_LIST"
chmod 600 "$ZIP_PATH_LIST" 2>/dev/null || true

if [[ "$DRY_RUN" == "on" ]]; then
    DB_FILE="$TMP_ROOT/hashes.db"
    JOURNAL_FILE="$TMP_ROOT/hashes.journal"

    if [[ -f "$REAL_HASH_DB" && ! -L "$REAL_HASH_DB" ]]; then
        cp -- "$REAL_HASH_DB" "$DB_FILE"
    else
        : > "$DB_FILE"
    fi

    if [[ -f "$REAL_JOURNAL_DB" && ! -L "$REAL_JOURNAL_DB" ]]; then
        cp -- "$REAL_JOURNAL_DB" "$JOURNAL_FILE"
    else
        : > "$JOURNAL_FILE"
    fi

    if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" == "on" ]]; then
        SOURCE_STATE_FILE="$TMP_ROOT/source_state.db"

        if [[ -f "$REAL_SOURCE_STATE_DB" && ! -L "$REAL_SOURCE_STATE_DB" ]]; then
            cp -- "$REAL_SOURCE_STATE_DB" "$SOURCE_STATE_FILE"
        else
            : > "$SOURCE_STATE_FILE"
        fi
    fi

elif [[ "$DATABASE_MODE" == "temporary" ]]; then
    DB_FILE="$TMP_ROOT/hashes.db"
    JOURNAL_FILE="$TMP_ROOT/hashes.journal"
    : > "$DB_FILE"
    : > "$JOURNAL_FILE"

    if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" == "on" ]]; then
        SOURCE_STATE_FILE="$TMP_ROOT/source_state.db"
        : > "$SOURCE_STATE_FILE"
    fi

else
    DB_FILE="$REAL_HASH_DB"
    JOURNAL_FILE="$REAL_JOURNAL_DB"

    # Source-State wird ausschließlich im copy-Modus mit aktivem Cache benötigt.
    # Eine Temp-Datei entsteht erst beim finalen Schreiben und nur dann, wenn
    # sich der erfasste Quellzustand tatsächlich geändert hat.
    if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" == "on" ]]; then
        SOURCE_STATE_FILE="$REAL_SOURCE_STATE_DB"
    fi
fi

# Recovery-Verzeichnisse und der Hardlink-Funktionstest werden absichtlich erst
# beim ersten echten Transfer beziehungsweise Quarantänefall angelegt/ausgeführt.
# Ein unveränderter Leerlauf hinterlässt dadurch keinen leeren Recovery-Ordner.

# ---------------------------------------------
# DATEITYPEN UND DATUM
# ---------------------------------------------

is_media_extension() {
    case "$1" in
        jpg|jpeg|png|heic|heif|webp|gif|bmp|tif|tiff|dng|raw|mp4|mov|m4v|avi|mkv|mpg|mpeg|3gp|3gpp|webm)
            return 0
            ;;
        *)
            return 1
            ;;
    esac
}

set_category_from_ext() {
    case "$1" in
        jpg|jpeg|png|mp4|avi|mkv|mpg|mpeg)
            CATEGORY_RESULT="COMPATIBLE"
            ;;
        heic|heif|dng|raw|mov|m4v)
            CATEGORY_RESULT="APPLE"
            ;;
        *)
            CATEGORY_RESULT="OTHER"
            ;;
    esac
}

set_media_type_from_ext() {
    case "$1" in
        jpg|jpeg|png|heic|heif|webp|gif|bmp|tif|tiff|dng|raw)
            MEDIA_TYPE_RESULT="BILDER"
            ;;
        mp4|mov|m4v|avi|mkv|mpg|mpeg|3gp|3gpp|webm)
            MEDIA_TYPE_RESULT="VIDEOS"
            ;;
        *)
            MEDIA_TYPE_RESULT=""
            return 1
            ;;
    esac

    return 0
}

normalize_subseconds_to_milliseconds() {
    local value="$1"

    MILLISECONDS_RESULT="000"

    value="${value#"${value%%[![:space:]]*}"}"
    value="${value%"${value##*[![:space:]]}"}"

    if [[ "$value" =~ ^0[\.,]([0-9]+)$ ]]; then
        value="${BASH_REMATCH[1]}"
    elif [[ "$value" =~ ^[\.,]([0-9]+)$ ]]; then
        value="${BASH_REMATCH[1]}"
    fi

    if [[ "$value" =~ ^[0-9]+$ ]]; then
        value="${value}000"
        MILLISECONDS_RESULT="${value:0:3}"
    fi
}

parse_exif_datetime_value() {
    local value="$1"
    local parsed_year=""
    local parsed_month=""
    local parsed_day=""
    local parsed_hour=""
    local parsed_minute=""
    local parsed_second=""
    local parsed_subseconds=""

    PARSED_DATETIME=""
    PARSED_SUBSECONDS=""

    value="${value#"${value%%[![:space:]]*}"}"
    value="${value%"${value##*[![:space:]]}"}"

    if [[ -z "$value" || "$value" == "-" ]]; then
        return 1
    fi

    if [[ "$value" =~ ^([0-9]{4})[-:]([0-9]{2})[-:]([0-9]{2})[[:space:]T_]([0-9]{2}):([0-9]{2}):([0-9]{2})([\.,]([0-9]+))?[[:space:]]*([zZ]|[+-][0-9]{2}:?[0-9]{2})?$ ]]; then
        parsed_year="${BASH_REMATCH[1]}"
        parsed_month="${BASH_REMATCH[2]}"
        parsed_day="${BASH_REMATCH[3]}"
        parsed_hour="${BASH_REMATCH[4]}"
        parsed_minute="${BASH_REMATCH[5]}"
        parsed_second="${BASH_REMATCH[6]}"
        parsed_subseconds="${BASH_REMATCH[8]}"

        valid_calendar_datetime_components \
            "$parsed_year" "$parsed_month" "$parsed_day" \
            "$parsed_hour" "$parsed_minute" "$parsed_second" || return 1

        PARSED_DATETIME="${parsed_year}-${parsed_month}-${parsed_day}_${parsed_hour}-${parsed_minute}-${parsed_second}"
        PARSED_SUBSECONDS="$parsed_subseconds"
        return 0
    fi

    return 1
}

set_datetime_from_exif() {
    local input_file="$1"
    local ext=""
    local metadata_line=""
    local raw_subsec_datetime_original=""
    local raw_datetime_original=""
    local raw_subsec_original=""
    local raw_keys_creation_date=""
    local raw_userdata_datetime_original=""
    local raw_quicktime_create_date=""
    local raw_media_create_date=""
    local raw_subsec_create_date=""
    local raw_create_date=""
    local raw_subsec_digitized=""
    local selected_datetime=""
    local selected_subseconds=""

    DATETIME=""
    YEAR=""
    MONTH=""

    ext="${input_file##*.}"
    ext="${ext,,}"

    # QuickTime:CreateDate und MediaCreateDate sind UTC-Zeitwerte. Mit
    # QuickTimeUTC=1 gibt ExifTool sie in der lokalen Systemzeitzone aus und
    # berücksichtigt die zum Aufnahmedatum passende Sommer-/Winterzeit.
    #
    # iPhone-Videos enthalten häufig Keys:CreationDate oder
    # UserData:DateTimeOriginal. Diese lokalen Aufnahmezeiten werden bevorzugt,
    # weil sie bei bearbeiteten Videos eher das ursprüngliche Datum bewahren.
    # QuickTimeUTC beeinflusst normale Bild-Metadaten nicht.
    metadata_line=$("$EXIFTOOL" \
        -api QuickTimeUTC=1 \
        -T \
        -SubSecDateTimeOriginal \
        -DateTimeOriginal \
        -SubSecTimeOriginal \
        -Keys:CreationDate \
        -UserData:DateTimeOriginal \
        -QuickTime:CreateDate \
        -MediaCreateDate \
        -SubSecCreateDate \
        -CreateDate \
        -SubSecTimeDigitized \
        "$input_file" 2>/dev/null)

    IFS=$'\t' read -r \
        raw_subsec_datetime_original \
        raw_datetime_original \
        raw_subsec_original \
        raw_keys_creation_date \
        raw_userdata_datetime_original \
        raw_quicktime_create_date \
        raw_media_create_date \
        raw_subsec_create_date \
        raw_create_date \
        raw_subsec_digitized \
        <<< "$metadata_line"

    set_media_type_from_ext "$ext" || true

    if [[ "$MEDIA_TYPE_RESULT" == "VIDEOS" ]]; then
        # Apple-/QuickTime-spezifische Originalzeit zuerst, danach UTC-Fallbacks.
        if parse_exif_datetime_value "$raw_keys_creation_date"; then
            selected_datetime="$PARSED_DATETIME"
            selected_subseconds="$PARSED_SUBSECONDS"
        elif parse_exif_datetime_value "$raw_userdata_datetime_original"; then
            selected_datetime="$PARSED_DATETIME"
            selected_subseconds="$PARSED_SUBSECONDS"
        elif parse_exif_datetime_value "$raw_quicktime_create_date"; then
            selected_datetime="$PARSED_DATETIME"
            selected_subseconds="$PARSED_SUBSECONDS"
        elif parse_exif_datetime_value "$raw_media_create_date"; then
            selected_datetime="$PARSED_DATETIME"
            selected_subseconds="$PARSED_SUBSECONDS"
        elif parse_exif_datetime_value "$raw_subsec_datetime_original"; then
            selected_datetime="$PARSED_DATETIME"
            selected_subseconds="$PARSED_SUBSECONDS"
        elif parse_exif_datetime_value "$raw_datetime_original"; then
            selected_datetime="$PARSED_DATETIME"
            if [[ -n "$PARSED_SUBSECONDS" ]]; then
                selected_subseconds="$PARSED_SUBSECONDS"
            else
                selected_subseconds="$raw_subsec_original"
            fi
        elif parse_exif_datetime_value "$raw_subsec_create_date"; then
            selected_datetime="$PARSED_DATETIME"
            selected_subseconds="$PARSED_SUBSECONDS"
        elif parse_exif_datetime_value "$raw_create_date"; then
            selected_datetime="$PARSED_DATETIME"
            if [[ -n "$PARSED_SUBSECONDS" ]]; then
                selected_subseconds="$PARSED_SUBSECONDS"
            else
                selected_subseconds="$raw_subsec_digitized"
            fi
        fi
    else
        # Bild-Reihenfolge bleibt unverändert: Aufnahmezeit vor Erstellungszeit.
        if parse_exif_datetime_value "$raw_subsec_datetime_original"; then
            selected_datetime="$PARSED_DATETIME"
            selected_subseconds="$PARSED_SUBSECONDS"
        elif parse_exif_datetime_value "$raw_datetime_original"; then
            selected_datetime="$PARSED_DATETIME"
            if [[ -n "$PARSED_SUBSECONDS" ]]; then
                selected_subseconds="$PARSED_SUBSECONDS"
            else
                selected_subseconds="$raw_subsec_original"
            fi
        elif parse_exif_datetime_value "$raw_subsec_create_date"; then
            selected_datetime="$PARSED_DATETIME"
            selected_subseconds="$PARSED_SUBSECONDS"
        elif parse_exif_datetime_value "$raw_create_date"; then
            selected_datetime="$PARSED_DATETIME"
            if [[ -n "$PARSED_SUBSECONDS" ]]; then
                selected_subseconds="$PARSED_SUBSECONDS"
            else
                selected_subseconds="$raw_subsec_digitized"
            fi
        fi
    fi

    if [[ -z "$selected_datetime" ]] || \
       [[ "$selected_datetime" =~ ^0000 ]] || \
       [[ "$selected_datetime" == "1970-01-01_00-00-00" ]]; then
        DATETIME="UNKNOWN"
        YEAR="0000"
        MONTH="00"
    else
        normalize_subseconds_to_milliseconds "$selected_subseconds"
        DATETIME="${selected_datetime}_${MILLISECONDS_RESULT}"
        YEAR="${DATETIME:0:4}"
        MONTH="${DATETIME:5:2}"
    fi
}

get_year_month_from_datetime() {
    local date_value="$1"

    YEAR_RESULT=""
    MONTH_RESULT=""

    if [[ "$date_value" == "UNKNOWN" ]]; then
        YEAR_RESULT="0000"
        MONTH_RESULT="00"
    elif [[ "$date_value" =~ ^([0-9]{4})-([0-9]{2})-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}_[0-9]{3}$ ]]; then
        YEAR_RESULT="${BASH_REMATCH[1]}"
        MONTH_RESULT="${BASH_REMATCH[2]}"
    fi
}

target_dir_for() {
    local category="$1"
    local media_type="$2"
    local year="$3"
    local month="$4"

    TARGET_DIR_RESULT=""
    [[ -n "$category" && -n "$media_type" ]] || return 1

    case "$SORT_STRUCTURE" in
        year_month)
            TARGET_DIR_RESULT="$DEST/$category/$media_type/$year/$month"
            ;;
        year)
            TARGET_DIR_RESULT="$DEST/$category/$media_type/$year"
            ;;
        flat)
            TARGET_DIR_RESULT="$DEST/$category/$media_type"
            ;;
    esac

    return 0
}

run_mkdir() {
    local dir="$1"

    if [[ "$DRY_RUN" == "off" ]]; then
        ensure_safe_directory "$dir" || {
            log_error "Zielordner konnte nicht sicher erstellt oder geprüft werden: $dir"
            return 1
        }
    fi
}

is_db_path_plausible() {
    local file="$1"
    local date_value="$2"
    local date_already_validated="${3:-0}"
    local name=""
    local base=""
    local ext=""
    local expected_year=""
    local expected_month=""
    local expected_category=""
    local expected_media_type=""
    local expected_dir=""
    local current_dir=""

    if [[ "$date_already_validated" != "1" ]] && ! valid_datetime_value "$date_value"; then
        return 1
    fi

    name="${file##*/}"
    base="${name%.*}"
    ext="${name##*.}"
    ext="${ext,,}"

    if ! is_media_extension "$ext"; then
        return 1
    fi

    get_year_month_from_datetime "$date_value"
    expected_year="$YEAR_RESULT"
    expected_month="$MONTH_RESULT"

    if [[ -z "$expected_year" || -z "$expected_month" ]]; then
        return 1
    fi

    set_category_from_ext "$ext"
    expected_category="$CATEGORY_RESULT"
    set_media_type_from_ext "$ext" || return 1
    expected_media_type="$MEDIA_TYPE_RESULT"
    target_dir_for "$expected_category" "$expected_media_type" "$expected_year" "$expected_month" || return 1
    expected_dir="$TARGET_DIR_RESULT"
    current_dir="${file%/*}"

    if [[ "$current_dir" != "$expected_dir" ]]; then
        return 1
    fi

    if [[ "$base" == "$date_value" ]] || [[ "$base" =~ ^${date_value}_[0-9]+$ ]]; then
        return 0
    fi

    return 1
}

get_unique_target() {
    local category="$1"
    local year="$2"
    local month="$3"
    local datetime="$4"
    local ext="$5"
    local is_duplicate="$6"
    local current_file="$7"
    local media_type=""
    local target_dir=""
    local target=""
    local counter=1

    UNIQUE_TARGET_RESULT=""
    set_media_type_from_ext "$ext" || return 1
    media_type="$MEDIA_TYPE_RESULT"
    target_dir_for "$category" "$media_type" "$year" "$month" || return 1
    target_dir="$TARGET_DIR_RESULT"
    run_mkdir "$target_dir" || return 1

    if [[ "$is_duplicate" -eq 1 ]]; then
        while true
        do
            target=$(printf "%s/%s_DUP%03d.%s" \
                "$target_dir" "$datetime" "$counter" "$ext")

            if [[ "$target" == "$current_file" ]] || ! path_entry_exists "$target"; then
                UNIQUE_TARGET_RESULT="$target"
                return 0
            fi

            counter=$((counter + 1))
        done
    else
        target="$target_dir/$datetime.$ext"

        if [[ "$target" == "$current_file" ]] || ! path_entry_exists "$target"; then
            UNIQUE_TARGET_RESULT="$target"
            return 0
        fi

        while true
        do
            target="$target_dir/${datetime}_${counter}.${ext}"

            if [[ "$target" == "$current_file" ]] || ! path_entry_exists "$target"; then
                UNIQUE_TARGET_RESULT="$target"
                return 0
            fi

            counter=$((counter + 1))
        done
    fi
}

# ---------------------------------------------
# DB IM ARBEITSSPEICHER
# ---------------------------------------------
# Pro Original werden nur zwei kompakte Zuordnungen gehalten:
#   Hash  -> Datei
#   Datei -> Hash|Datum|Größe|interner Prüfstatus
# Zusätzlich existiert ein kleiner Größenzähler für den FAST_MODE. Dateiinhalte
# und rekursive Dateilisten werden nicht in den Arbeitsspeicher geladen.

declare -A DB_HASH_FILE
declare -A DB_FILE_RECORD
declare -A DB_SIZE_COUNT

db_clear_memory() {
    DB_HASH_FILE=()
    DB_FILE_RECORD=()
    DB_SIZE_COUNT=()
    DB_RECORD_COUNT=0
}

db_increment_size() {
    local size="$1"
    local count=0

    [[ -n "$size" ]] || return 0
    count="${DB_SIZE_COUNT[$size]:-0}"
    DB_SIZE_COUNT["$size"]=$((count + 1))
}

db_decrement_size() {
    local size="$1"
    local count=0

    [[ -n "$size" && -n "${DB_SIZE_COUNT[$size]+x}" ]] || return 0
    count="${DB_SIZE_COUNT[$size]}"

    if (( count <= 1 )); then
        unset "DB_SIZE_COUNT[$size]"
    else
        DB_SIZE_COUNT["$size"]=$((count - 1))
    fi
}

db_add_memory() {
    local hash="$1"
    local date="$2"
    local size="$3"
    local file="$4"
    local old_record=""
    local old_file=""
    local old_hash=""

    if [[ -n "${DB_HASH_FILE[$hash]+x}" ]]; then
        old_file="${DB_HASH_FILE[$hash]}"
        [[ "$old_file" == "$file" ]] || return 1
        return 0
    fi

    if [[ -n "${DB_FILE_RECORD[$file]+x}" ]]; then
        old_record="${DB_FILE_RECORD[$file]}"
        IFS='|' read -r old_hash _ <<< "$old_record"
        [[ "$old_hash" == "$hash" ]] || return 1
        return 0
    fi

    DB_HASH_FILE["$hash"]="$file"
    DB_FILE_RECORD["$file"]="$hash|$date|$size|0"
    db_increment_size "$size"
    DB_RECORD_COUNT=$((DB_RECORD_COUNT + 1))
    return 0
}

db_remove_hash_memory() {
    local hash="$1"
    local file=""
    local record=""
    local record_hash=""
    local date=""
    local size=""
    local seen=""

    [[ -n "${DB_HASH_FILE[$hash]+x}" ]] || return 0

    file="${DB_HASH_FILE[$hash]}"
    if [[ -n "${DB_FILE_RECORD[$file]+x}" ]]; then
        record="${DB_FILE_RECORD[$file]}"
        IFS='|' read -r record_hash date size seen <<< "$record"
    fi

    unset "DB_HASH_FILE[$hash]"
    [[ -n "$file" ]] && unset "DB_FILE_RECORD[$file]"
    db_decrement_size "$size"
    (( DB_RECORD_COUNT > 0 )) && DB_RECORD_COUNT=$((DB_RECORD_COUNT - 1))
    DB_DIRTY=1
}

db_remove_file_memory() {
    local file="$1"
    local record=""
    local hash=""
    local date=""
    local size=""
    local seen=""

    [[ -n "${DB_FILE_RECORD[$file]+x}" ]] || return 0

    record="${DB_FILE_RECORD[$file]}"
    IFS='|' read -r hash date size seen <<< "$record"
    db_remove_hash_memory "$hash"
}

db_find_hash() {
    local hash="$1"
    local file=""
    local record=""

    DB_LOOKUP_HASH=""
    DB_LOOKUP_FILE=""

    [[ -n "${DB_HASH_FILE[$hash]+x}" ]] || return 1

    file="${DB_HASH_FILE[$hash]}"
    [[ -n "${DB_FILE_RECORD[$file]+x}" ]] || return 1
    record="${DB_FILE_RECORD[$file]}"
    IFS='|' read -r DB_LOOKUP_HASH _ <<< "$record"

    [[ "$DB_LOOKUP_HASH" == "$hash" ]] || return 1
    DB_LOOKUP_FILE="$file"
    return 0
}

db_mark_seen() {
    local file="$1"
    local record=""
    local hash=""
    local date=""
    local size=""
    local seen=""

    [[ -n "${DB_FILE_RECORD[$file]+x}" ]] || return 0
    record="${DB_FILE_RECORD[$file]}"
    IFS='|' read -r hash date size seen <<< "$record"
    DB_FILE_RECORD["$file"]="$hash|$date|$size|1"
}

db_remove_unseen_after_scan() {
    local file=""
    local record=""
    local hash=""
    local date=""
    local size=""
    local seen=""

    # Die Schlüsselliste wird von Bash einmalig als Schleifen-Snapshot erzeugt.
    # Dadurch kann der jeweilige Eintrag ohne zusätzliche Missing-Liste entfernt werden.
    for file in "${!DB_FILE_RECORD[@]}"
    do
        record="${DB_FILE_RECORD[$file]}"
        IFS='|' read -r hash date size seen <<< "$record"

        if [[ "$seen" != "1" ]]; then
            db_remove_file_memory "$file"
            COUNT_DB_REMOVED=$((COUNT_DB_REMOVED + 1))
        fi
    done
}

db_record_path_valid() {
    local file="$1"

    record_path_is_canonical_within "$file" "$DEST"
}

create_db_temp() {
    local db_dir=""

    db_dir="${DB_FILE%/*}"
    [[ -d "$db_dir" && ! -L "$db_dir" ]] || return 1
    mktemp "$db_dir/.hashes-work.XXXXXX"
}

db_load_once() {
    local hash=""
    local date=""
    local size=""
    local file=""

    db_clear_memory

    if [[ ! -f "$DB_FILE" || -L "$DB_FILE" || ! -s "$DB_FILE" ]]; then
        return 2
    fi

    while IFS='|' read -r hash date size file
    do
        [[ -z "$hash$date$size$file" ]] && continue

        if ! valid_hash "$hash" || \
           ! valid_datetime_value "$date" || \
           ! valid_size "$size" || \
           ! db_record_path_valid "$file" || \
           ! db_add_memory "$hash" "$date" "$size" "$file"; then
            db_clear_memory
            return 3
        fi
    done < "$DB_FILE"

    (( DB_RECORD_COUNT > 0 )) || return 2
    return 0
}

db_write_full() {
    local hash=""
    local file=""
    local record=""
    local record_hash=""
    local date=""
    local size=""
    local seen=""
    local db_temp=""

    db_temp=$(create_db_temp) || {
        mark_runtime_error "Temporäre DB-Datei konnte nicht sicher erstellt werden"
        return 1
    }

    for hash in "${!DB_HASH_FILE[@]}"
    do
        file="${DB_HASH_FILE[$hash]}"
        record="${DB_FILE_RECORD[$file]}"
        IFS='|' read -r record_hash date size seen <<< "$record"

        if db_record_path_valid "$file" && [[ "$record_hash" == "$hash" ]]; then
            printf "%s|%s|%s|%s\n" "$hash" "$date" "$size" "$file" >> "$db_temp" || {
                rm -f -- "$db_temp"
                return 1
            }
        else
            rm -f -- "$db_temp"
            mark_runtime_error "Ungültiger oder widersprüchlicher Pfad im DB-Arbeitsspeicher: $file"
            return 1
        fi
    done

    safe_sync_path "$db_temp" || {
        rm -f -- "$db_temp"
        mark_runtime_error "Temporäre DB-Datei konnte nicht synchronisiert werden"
        return 1
    }

    [[ ! -L "$DB_FILE" ]] || {
        rm -f -- "$db_temp"
        mark_runtime_error "DB-Ziel ist unerwartet ein symbolischer Link: $DB_FILE"
        return 1
    }

    mv -f -- "$db_temp" "$DB_FILE" || {
        rm -f -- "$db_temp"
        mark_runtime_error "Datenbank konnte nicht atomar ersetzt werden: $DB_FILE"
        return 1
    }

    set_single_file_permissions "$DB_FILE"
    safe_sync_path "$DB_FILE" || return 1
    safe_sync_dir "${DB_FILE%/*}" || return 1
    DB_DIRTY=0
    return 0
}

db_write_if_dirty() {
    if [[ "$DB_DIRTY" -eq 0 ]]; then
        log_info "Datenbank unverändert; kein Neuschreiben erforderlich"
        return 0
    fi

    db_write_full
}

journal_append_original() {
    local hash="$1"
    local date="$2"
    local size="$3"
    local file="$4"

    if [[ "$DRY_RUN" == "off" ]]; then
        valid_hash "$hash" || return 1
        valid_datetime_value "$date" || return 1
        valid_size "$size" || return 1
        db_record_path_valid "$file" || return 1
        [[ ! -L "$JOURNAL_FILE" ]] || return 1

        printf "%s|%s|%s|%s\n" "$hash" "$date" "$size" "$file" >> "$JOURNAL_FILE" || return 1
        set_single_file_permissions "$JOURNAL_FILE"
        safe_sync_path "$JOURNAL_FILE" || return 1
        safe_sync_dir "${JOURNAL_FILE%/*}" || return 1
        JOURNAL_NEEDS_CLEAR=1
    fi
}

journal_clear() {
    local journal_dir=""

    [[ "$DRY_RUN" == "off" ]] || return 0
    [[ -n "$JOURNAL_FILE" ]] || return 0
    journal_dir="${JOURNAL_FILE%/*}"
    [[ -d "$journal_dir" && ! -L "$journal_dir" && ! -L "$JOURNAL_FILE" ]] || return 1

    if path_entry_exists "$JOURNAL_FILE"; then
        [[ -f "$JOURNAL_FILE" ]] || return 1
        rm -f -- "$JOURNAL_FILE" || return 1
        safe_sync_dir "$journal_dir" || return 1
    fi

    JOURNAL_NEEDS_CLEAR=0
    return 0
}

journal_recover() {
    local hash=""
    local date=""
    local size=""
    local file=""
    local current_size=""
    local current_hash=""

    if [[ ! -e "$JOURNAL_FILE" ]]; then
        return 0
    fi

    [[ -f "$JOURNAL_FILE" && ! -L "$JOURNAL_FILE" ]] || return 1

    if [[ ! -s "$JOURNAL_FILE" ]]; then
        JOURNAL_NEEDS_CLEAR=1
        return 0
    fi

    log_warn "Journal gefunden, stelle noch nicht geschriebene DB-Einträge wieder her"
    JOURNAL_NEEDS_CLEAR=1

    while IFS='|' read -r hash date size file
    do
        [[ -z "$hash$date$size$file" ]] && continue

        if valid_hash "$hash" && \
           valid_datetime_value "$date" && \
           valid_size "$size" && \
           db_record_path_valid "$file" && \
           [[ -f "$file" && ! -L "$file" ]]; then

            current_size=$(stat_size "$file")

            if [[ "$current_size" == "$size" ]]; then
                if ! calculate_file_hash "$file"; then
                    continue
                fi
                current_hash="$HASH_RESULT"

                if [[ "$current_hash" == "$hash" ]]; then
                    if [[ -z "${DB_HASH_FILE[$hash]+x}" ]]; then
                        db_add_memory "$hash" "$date" "$size" "$file" || return 1
                        DB_DIRTY=1
                    fi
                    COUNT_JOURNAL_RECOVERED=$((COUNT_JOURNAL_RECOVERED + 1))
                fi
            fi
        fi
    done < "$JOURNAL_FILE"

    log_ok "Journal eingelesen"
    log_info "Wiederhergestellte Journal-Einträge: $COUNT_JOURNAL_RECOVERED"
    return 0
}

# ---------------------------------------------
# SOURCE-STATE-CACHE
# ---------------------------------------------
# Nur im copy-Modus aktiv. Im Arbeitsspeicher dient der exakte vollständige
# Quellpfad direkt als Schlüssel; dadurch ist bei bekannten Quellen kein eigener
# SHA256-Prozess für einen Pfadschlüssel nötig. Für den Skip werden nur der exakte
# vollständige Quellpfad, die Größe und mtime verglichen. Das persistente Format
# bleibt einschließlich ctime und Datei-Identität abwärtskompatibel:
#   Pfadschlüssel|Größe|mtime|ctime|devino|Basename|Erweiterung|Hash|Quellpfad

declare -A SS_RECORD
declare -A SS_SEEN_KEY

source_state_key() {
    local file="$1"
    local existing_record=""
    local existing_key=""

    SOURCE_STATE_CURRENT_KEY=""

    if [[ -n "${SS_RECORD[$file]+x}" ]]; then
        existing_record="${SS_RECORD[$file]}"
        existing_key="${existing_record%%|*}"
        valid_hash "$existing_key" || return 1
        SOURCE_STATE_CURRENT_KEY="$existing_key"
        return 0
    fi

    calculate_text_hash "$file" || return 1
    SOURCE_STATE_CURRENT_KEY="$HASH_RESULT"
    return 0
}

source_state_clear_memory() {
    SS_RECORD=()
    SS_SEEN_KEY=()
}

source_state_load() {
    local key=""
    local size=""
    local mtime=""
    local ctime=""
    local devino=""
    local basename_value=""
    local ext=""
    local hash=""
    local src_file=""
    local record=""

    source_state_clear_memory
    SOURCE_STATE_DIRTY=0
    SOURCE_STATE_FILE_EXISTED=0

    if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" != "on" ]]; then
        return 0
    fi

    [[ -n "$SOURCE_STATE_FILE" ]] || return 1

    if ! path_entry_exists "$SOURCE_STATE_FILE"; then
        SOURCE_STATE_DIRTY=1
        return 0
    fi

    [[ -f "$SOURCE_STATE_FILE" && ! -L "$SOURCE_STATE_FILE" ]] || return 1
    SOURCE_STATE_FILE_EXISTED=1

    while IFS='|' read -r key size mtime ctime devino basename_value ext hash src_file
    do
        [[ -z "$key$size$mtime$ctime$devino$basename_value$ext$hash$src_file" ]] && continue

        if valid_hash "$key" && \
           valid_size "$size" && \
           valid_time_number "$mtime" && \
           valid_time_number "$ctime" && \
           valid_devino "$devino" && \
           [[ -n "$basename_value" ]] && \
           [[ -n "$ext" ]] && \
           valid_hash "$hash" && \
           [[ -n "$src_file" ]] && \
           ! has_unsupported_record_chars "$basename_value" && \
           ! has_unsupported_record_chars "$src_file" && \
           record_path_is_canonical_within "$src_file" "$SRC" && \
           [[ -z "${SS_RECORD[$src_file]+x}" ]]; then

            record="$key|$size|$mtime|$ctime|$devino|$basename_value|$ext|$hash|$src_file"
            SS_RECORD["$src_file"]="$record"
            COUNT_SOURCE_STATE_LOADED=$((COUNT_SOURCE_STATE_LOADED + 1))
        else
            COUNT_SOURCE_STATE_INVALID=$((COUNT_SOURCE_STATE_INVALID + 1))
            SOURCE_STATE_DIRTY=1
        fi
    done < "$SOURCE_STATE_FILE"
}

source_state_should_skip() {
    local file="$1"
    local size="$2"
    local mtime="$3"
    local key=""
    local record=""
    local old_size=""
    local old_mtime=""
    local old_ctime=""
    local old_devino=""
    local old_basename=""
    local old_ext=""
    local old_hash=""
    local old_file=""
    local old_target=""

    SOURCE_STATE_CURRENT_KEY=""

    if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" != "on" ]]; then
        return 1
    fi

    if [[ -z "${SS_RECORD[$file]+x}" ]]; then
        source_state_key "$file" || return 1
        return 1
    fi

    record="${SS_RECORD[$file]}"
    IFS='|' read -r key old_size old_mtime old_ctime old_devino old_basename old_ext old_hash old_file <<< "$record"
    valid_hash "$key" || return 1
    SOURCE_STATE_CURRENT_KEY="$key"

    old_target="${DB_HASH_FILE[$old_hash]:-}"

    if [[ "$old_size" == "$size" && \
          "$old_mtime" == "$mtime" && \
          "$old_file" == "$file" && \
          -n "$old_target" && \
          -f "$old_target" && \
          ! -L "$old_target" ]]; then

        return 0
    fi

    return 1
}

source_state_mark_seen() {
    local file="$1"

    [[ "$SOURCE_STATE_CACHE_EFFECTIVE" == "on" ]] || return 0
    [[ -n "$file" ]] || return 1
    SS_SEEN_KEY["$file"]=1
}

source_state_write_current() {
    local file="$1"
    local ext="$2"
    local size="$3"
    local mtime="$4"
    local ctime="$5"
    local devino="$6"
    local hash="$7"
    local key="${8:-$SOURCE_STATE_CURRENT_KEY}"
    local basename_value=""
    local new_record=""

    [[ "$SOURCE_STATE_CACHE_EFFECTIVE" == "on" ]] || return 0
    valid_hash "$hash" || return 0

    basename_value="${file##*/}"

    if has_unsupported_record_chars "$file" || \
       has_unsupported_record_chars "$basename_value" || \
       ! record_path_is_canonical_within "$file" "$SRC" || \
       ! valid_size "$size" || \
       ! valid_stat_time_number "$mtime" || \
       ! valid_stat_time_number "$ctime" || \
       ! valid_devino "$devino" || \
       [[ -z "$ext" ]]; then
        COUNT_SOURCE_STATE_INVALID=$((COUNT_SOURCE_STATE_INVALID + 1))
        return 0
    fi

    if ! valid_hash "$key"; then
        source_state_key "$file" || {
            COUNT_SOURCE_STATE_INVALID=$((COUNT_SOURCE_STATE_INVALID + 1))
            return 0
        }
        key="$SOURCE_STATE_CURRENT_KEY"
    fi

    new_record="$key|$size|$mtime|$ctime|$devino|$basename_value|$ext|$hash|$file"

    if [[ -z "${SS_RECORD[$file]+x}" || "${SS_RECORD[$file]}" != "$new_record" ]]; then
        SOURCE_STATE_DIRTY=1
    fi

    SS_RECORD["$file"]="$new_record"
    source_state_mark_seen "$file" || return 0

    COUNT_SOURCE_STATE_WRITTEN=$((COUNT_SOURCE_STATE_WRITTEN + 1))
}

source_state_finalize() {
    local file=""
    local record=""
    local source_state_dir=""

    if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" != "on" ]]; then
        return 0
    fi

    for file in "${!SS_RECORD[@]}"
    do
        if [[ -z "${SS_SEEN_KEY[$file]+x}" ]]; then
            COUNT_SOURCE_STATE_REMOVED=$((COUNT_SOURCE_STATE_REMOVED + 1))
            SOURCE_STATE_DIRTY=1
        fi
    done

    [[ -n "$SOURCE_STATE_FILE" && ! -L "$SOURCE_STATE_FILE" ]] || return 1

    if [[ "$SOURCE_STATE_DIRTY" -eq 0 && "$SOURCE_STATE_FILE_EXISTED" -eq 1 ]]; then
        log_info "Source-State unverändert; kein Neuschreiben erforderlich"
        return 0
    fi

    source_state_dir="${SOURCE_STATE_FILE%/*}"
    [[ -d "$source_state_dir" && ! -L "$source_state_dir" ]] || return 1

    SOURCE_STATE_NEW=$(mktemp "$source_state_dir/.source_state.tmp.XXXXXX") || return 1

    for file in "${!SS_SEEN_KEY[@]}"
    do
        [[ -n "${SS_RECORD[$file]+x}" ]] || {
            rm -f -- "$SOURCE_STATE_NEW"
            SOURCE_STATE_NEW=""
            return 1
        }

        record="${SS_RECORD[$file]}"
        printf '%s\n' "$record" >> "$SOURCE_STATE_NEW" || {
            rm -f -- "$SOURCE_STATE_NEW"
            SOURCE_STATE_NEW=""
            return 1
        }
    done

    [[ -f "$SOURCE_STATE_NEW" && ! -L "$SOURCE_STATE_NEW" ]] || return 1
    safe_sync_path "$SOURCE_STATE_NEW" || return 1

    mv -f -- "$SOURCE_STATE_NEW" "$SOURCE_STATE_FILE" || return 1
    SOURCE_STATE_NEW=""
    set_single_file_permissions "$SOURCE_STATE_FILE"
    safe_sync_path "$SOURCE_STATE_FILE" || return 1
    safe_sync_dir "${SOURCE_STATE_FILE%/*}"
}

# ---------------------------------------------
# ABSTURZSICHERE TRANSFERS UND RECOVERY
# ---------------------------------------------

ensure_recovery_root() {
    ensure_safe_directory "$RECOVERY_DIR" || return 1
    chmod 700 "$RECOVERY_DIR" 2>/dev/null || true

    if [[ -n "$CURRENT_UID" && -n "$CURRENT_GID" ]]; then
        chown "$CURRENT_UID:$CURRENT_GID" "$RECOVERY_DIR" 2>/dev/null || true
    fi

    return 0
}

ensure_recovery_quarantine() {
    ensure_recovery_root || return 1
    ensure_safe_directory "$RECOVERY_QUARANTINE_DIR" || return 1
    chmod 700 "$RECOVERY_QUARANTINE_DIR" 2>/dev/null || true

    if [[ -n "$CURRENT_UID" && -n "$CURRENT_GID" ]]; then
        chown "$CURRENT_UID:$CURRENT_GID" "$RECOVERY_QUARANTINE_DIR" 2>/dev/null || true
    fi

    return 0
}

cleanup_empty_recovery_dirs() {
    [[ "$DRY_RUN" == "off" ]] || return 0

    if [[ -d "$RECOVERY_QUARANTINE_DIR" && ! -L "$RECOVERY_QUARANTINE_DIR" ]]; then
        rmdir -- "$RECOVERY_QUARANTINE_DIR" 2>/dev/null || true
    fi

    if [[ -d "$RECOVERY_DIR" && ! -L "$RECOVERY_DIR" ]]; then
        rmdir -- "$RECOVERY_DIR" 2>/dev/null || true
    fi

    return 0
}

transaction_id_for() {
    local source_file="$1"
    local target_file="$2"
    local expected_hash="$3"

    HASH_RESULT=""

    if ! IFS=' ' read -r HASH_RESULT _ < <(
        printf "%s\0%s\0%s" "$source_file" "$target_file" "$expected_hash" |
            sha256sum
    ); then
        HASH_RESULT=""
        return 1
    fi

    valid_hash "$HASH_RESULT"
}

source_matches_snapshot() {
    local file="$1"
    local expected_hash="$2"
    local expected_size="$3"
    local expected_devino="$4"
    local expected_mtime="$5"
    local expected_ctime="$6"

    [[ -f "$file" && ! -L "$file" ]] || return 1

    # Vollständiger Schnappschuss vor und nach dem Inhaltslesen: So bleiben alle
    # bisherigen Identitäts-, Zeit- und Größenprüfungen erhalten, gleichzeitig
    # werden statt vier einzelner stat-Prozesse nur zwei kohärente Abrufe benötigt.
    read_file_snapshot "$file" || return 1
    snapshot_matches_expected \
        "$expected_size" "$expected_devino" "$expected_mtime" "$expected_ctime" || return 1

    calculate_file_hash "$file" || return 1
    [[ "$HASH_RESULT" == "$expected_hash" ]] || return 1

    read_file_snapshot "$file" || return 1
    snapshot_matches_expected \
        "$expected_size" "$expected_devino" "$expected_mtime" "$expected_ctime" || return 1

    return 0
}

write_transaction_marker() {
    local marker="$1"
    local mode="$2"
    local source_file="$3"
    local temp_file="$4"
    local target_file="$5"
    local expected_hash="$6"
    local expected_size="$7"
    local expected_devino="$8"
    local expected_mtime="$9"
    local expected_ctime="${10}"
    local marker_temp=""

    ensure_recovery_root || return 1
    [[ ! -L "$marker" ]] || return 1

    marker_temp=$(mktemp "$RECOVERY_DIR/.marker.XXXXXX") || return 1

    {
        printf '%s\0' "2"
        printf '%s\0' "$mode"
        printf '%s\0' "$source_file"
        printf '%s\0' "$temp_file"
        printf '%s\0' "$target_file"
        printf '%s\0' "$expected_hash"
        printf '%s\0' "$expected_size"
        printf '%s\0' "$expected_devino"
        printf '%s\0' "$expected_mtime"
        printf '%s\0' "$expected_ctime"
    } > "$marker_temp" || {
        rm -f -- "$marker_temp"
        return 1
    }

    chmod 600 "$marker_temp" 2>/dev/null || true
    safe_sync_path "$marker_temp" || {
        rm -f -- "$marker_temp"
        return 1
    }

    mv -f -- "$marker_temp" "$marker" || {
        rm -f -- "$marker_temp"
        return 1
    }

    chmod 600 "$marker" 2>/dev/null || true
    safe_sync_path "$marker" && safe_sync_dir "$RECOVERY_DIR"
}

read_transaction_marker() {
    local marker="$1"
    local version=""

    REC_MODE=""
    REC_SOURCE=""
    REC_TEMP=""
    REC_TARGET=""
    REC_HASH=""
    REC_SIZE=""
    REC_DEVINO=""
    REC_MTIME=""
    REC_CTIME=""
    REC_LEGACY=0

    [[ -f "$marker" && ! -L "$marker" ]] || return 1

    exec 9< "$marker" || return 1

    IFS= read -r -d '' version <&9 || { exec 9<&-; return 1; }
    IFS= read -r -d '' REC_MODE <&9 || { exec 9<&-; return 1; }
    IFS= read -r -d '' REC_SOURCE <&9 || { exec 9<&-; return 1; }
    IFS= read -r -d '' REC_TEMP <&9 || { exec 9<&-; return 1; }
    IFS= read -r -d '' REC_TARGET <&9 || { exec 9<&-; return 1; }
    IFS= read -r -d '' REC_HASH <&9 || { exec 9<&-; return 1; }
    IFS= read -r -d '' REC_SIZE <&9 || { exec 9<&-; return 1; }

    if [[ "$version" == "2" ]]; then
        IFS= read -r -d '' REC_DEVINO <&9 || { exec 9<&-; return 1; }
        IFS= read -r -d '' REC_MTIME <&9 || { exec 9<&-; return 1; }
        IFS= read -r -d '' REC_CTIME <&9 || { exec 9<&-; return 1; }
    elif [[ "$version" == "1" ]]; then
        REC_LEGACY=1
    else
        exec 9<&-
        return 1
    fi

    exec 9<&-

    [[ "$REC_MODE" == "move" || "$REC_MODE" == "copy" ]] || return 1
    valid_hash "$REC_HASH" || return 1
    valid_size "$REC_SIZE" || return 1

    if [[ "$version" == "2" ]]; then
        [[ -n "$REC_DEVINO" ]] || return 1
        valid_time_number "$REC_MTIME" || return 1
        valid_time_number "$REC_CTIME" || return 1
    fi

    has_unsupported_record_chars "$REC_SOURCE" && return 1
    has_unsupported_record_chars "$REC_TARGET" && return 1
    has_unsupported_record_chars "$REC_TEMP" && return 1

    local raw_source="$REC_SOURCE"
    local raw_target="$REC_TARGET"
    local raw_temp="$REC_TEMP"

    REC_SOURCE=$(canonicalize_path "$raw_source") || return 1
    REC_TARGET=$(canonicalize_path "$raw_target") || return 1
    REC_TEMP=$(canonicalize_path "$raw_temp") || return 1

    [[ "$REC_SOURCE" == "$raw_source" ]] || return 1
    [[ "$REC_TARGET" == "$raw_target" ]] || return 1
    [[ "$REC_TEMP" == "$raw_temp" ]] || return 1

    if ! path_is_within "$REC_SOURCE" "$SRC" && ! path_is_within "$REC_SOURCE" "$DEST"; then
        return 1
    fi

    path_is_within "$REC_TARGET" "$DEST" || return 1
    path_is_within "$REC_TEMP" "$DEST" || return 1

    [[ "${REC_TEMP%/*}" == "${REC_TARGET%/*}" ]] || return 1

    case "${REC_TEMP##*/}" in
        .medien-sortierer-*.part) ;;
        *) return 1 ;;
    esac

    return 0
}

file_matches_expected() {
    local file="$1"
    local expected_hash="$2"
    local expected_size="$3"
    local current_size=""
    local current_hash=""

    [[ -f "$file" && ! -L "$file" ]] || return 1

    current_size=$(stat_size "$file")
    [[ "$current_size" == "$expected_size" ]] || return 1

    calculate_file_hash "$file" || return 1
    current_hash="$HASH_RESULT"
    [[ "$current_hash" == "$expected_hash" ]]
}

run_copy_to_temp() {
    local source_file="$1"
    local temp_file="$2"
    local copy_exit=0

    cp -p -- "$source_file" "$temp_file" &
    ACTIVE_TRANSFER_PID=$!

    wait "$ACTIVE_TRANSFER_PID" || copy_exit=$?
    ACTIVE_TRANSFER_PID=""

    return "$copy_exit"
}

publish_temp_no_clobber() {
    local temp_file="$1"
    local target_file="$2"
    local target_dir=""

    [[ -f "$temp_file" && ! -L "$temp_file" ]] || return 1
    ! path_entry_exists "$target_file" || return 1

    target_dir="${target_file%/*}"
    [[ -d "$target_dir" && ! -L "$target_dir" ]] || return 1

    # Das Erzeugen eines Hardlinks ist atomar und schlägt fehl, wenn der Zielname
    # bereits existiert. Dadurch wird auch bei einem Rennen nichts überschrieben.
    ln -- "$temp_file" "$target_file" || return 1
    safe_sync_dir "$target_dir" || return 1

    rm -f -- "$temp_file" || return 1
    safe_sync_dir "$target_dir" || return 1

    return 0
}

quarantine_file() {
    local file="$1"
    local transaction_id="$2"
    local source_hash=""
    local source_size=""
    local source_devino=""
    local source_mtime=""
    local source_ctime=""
    local quarantine_temp=""
    local quarantine_target=""
    local counter=1

    [[ -f "$file" && ! -L "$file" ]] || return 1
    path_is_within "$(canonicalize_path "$file")" "$DEST" || return 1
    ensure_recovery_quarantine || return 1
    verify_atomic_publish_support "$RECOVERY_QUARANTINE_DIR" || return 1

    read_file_snapshot "$file" || return 1
    source_size="$FILE_SNAPSHOT_SIZE"
    source_devino="$FILE_SNAPSHOT_DEVINO"
    source_mtime="$FILE_SNAPSHOT_MTIME"
    source_ctime="$FILE_SNAPSHOT_CTIME"
    calculate_file_hash "$file" || return 1
    source_hash="$HASH_RESULT"

    valid_size "$source_size" || return 1
    valid_hash "$source_hash" || return 1

    quarantine_temp=$(mktemp "$RECOVERY_QUARANTINE_DIR/.quarantine.XXXXXX") || return 1

    if ! run_copy_to_temp "$file" "$quarantine_temp" || \
       ! file_matches_expected "$quarantine_temp" "$source_hash" "$source_size" || \
       ! source_matches_snapshot "$file" "$source_hash" "$source_size" \
            "$source_devino" "$source_mtime" "$source_ctime" || \
       ! safe_sync_path "$quarantine_temp"; then
        rm -f -- "$quarantine_temp"
        return 1
    fi

    while true
    do
        quarantine_target="$RECOVERY_QUARANTINE_DIR/${transaction_id}_${counter}.corrupt"
        ! path_entry_exists "$quarantine_target" && break
        counter=$((counter + 1))
    done

    publish_temp_no_clobber "$quarantine_temp" "$quarantine_target" || {
        rm -f -- "$quarantine_temp"
        return 1
    }

    file_matches_expected "$quarantine_target" "$source_hash" "$source_size" || return 1
    source_matches_snapshot "$file" "$source_hash" "$source_size" \
        "$source_devino" "$source_mtime" "$source_ctime" || return 1

    rm -f -- "$file" || return 1
    safe_sync_dir "${file%/*}" || return 1

    chmod 600 "$quarantine_target" 2>/dev/null || true
    COUNT_RECOVERY_QUARANTINED=$((COUNT_RECOVERY_QUARANTINED + 1))
    log_warn "Datei wurde verlustfrei in die Recovery-Quarantäne verschoben: $quarantine_target"
    return 0
}

remove_transaction_marker() {
    local marker="$1"

    [[ -f "$marker" && ! -L "$marker" ]] || return 1
    rm -f -- "$marker" || return 1
    safe_sync_dir "$RECOVERY_DIR"
}

durable_transfer() {
    local source_file="$1"
    local target_file="$2"
    local expected_hash="$3"
    local expected_size="$4"
    local mode="$5"
    local existing_marker="${6:-}"
    local existing_temp="${7:-}"
    local target_dir=""
    local transaction_id=""
    local temp_file=""
    local marker=""
    local source_devino=""
    local source_mtime=""
    local source_ctime=""

    if [[ "$DRY_RUN" == "on" ]]; then
        return 0
    fi

    valid_hash "$expected_hash" || return 1
    valid_size "$expected_size" || return 1
    [[ "$mode" == "move" || "$mode" == "copy" ]] || return 1
    [[ -f "$source_file" && ! -L "$source_file" ]] || return 1

    local source_canonical=""
    local target_canonical=""

    safe_parent_directory_for_target "$target_file" || return 1

    source_canonical=$(canonicalize_path "$source_file") || return 1
    target_canonical=$(canonicalize_path "$target_file") || return 1

    # Ein abweichender kanonischer Pfad weist auf .. oder einen Symlink in einer
    # Pfadkomponente hin. Beides wird bei Root-Ausführung nicht akzeptiert.
    [[ "$source_canonical" == "$source_file" ]] || return 1
    [[ "$target_canonical" == "$target_file" ]] || return 1

    source_file="$source_canonical"
    target_file="$target_canonical"

    if ! path_is_within "$source_file" "$SRC" && ! path_is_within "$source_file" "$DEST"; then
        return 1
    fi
    path_is_within "$target_file" "$DEST" || return 1

    read_file_snapshot "$source_file" || return 1
    source_devino="$FILE_SNAPSHOT_DEVINO"
    source_mtime="$FILE_SNAPSHOT_MTIME"
    source_ctime="$FILE_SNAPSHOT_CTIME"

    source_matches_snapshot "$source_file" "$expected_hash" "$expected_size" \
        "$source_devino" "$source_mtime" "$source_ctime" || return 1

    ! path_entry_exists "$target_file" || return 1

    target_dir="${target_file%/*}"
    [[ -d "$target_dir" && ! -L "$target_dir" ]] || return 1
    verify_atomic_publish_support "$target_dir" || {
        log_warn "Ziel unterstützt das sichere atomare Veröffentlichen ohne Überschreiben nicht: $target_dir"
        return 1
    }
    check_free_space_for_file "$target_dir" "$expected_size" || {
        log_warn "Nicht genügend freier Platz für Datei plus Reserve: $source_file"
        return 1
    }

    transaction_id_for "$source_file" "$target_file" "$expected_hash" || return 1
    transaction_id="$HASH_RESULT"

    if [[ -n "$existing_temp" ]]; then
        temp_file="$existing_temp"
    else
        temp_file="$target_dir/.medien-sortierer-${transaction_id}.part"
    fi

    if [[ -n "$existing_marker" ]]; then
        marker="$existing_marker"
    else
        marker="$RECOVERY_DIR/${transaction_id}.txn"
    fi

    if [[ -L "$temp_file" || -L "$marker" ]]; then
        return 1
    fi

    write_transaction_marker \
        "$marker" "$mode" "$source_file" "$temp_file" "$target_file" \
        "$expected_hash" "$expected_size" "$source_devino" "$source_mtime" "$source_ctime" || return 1

    if path_entry_exists "$temp_file"; then
        if [[ -f "$temp_file" && ! -L "$temp_file" ]]; then
            rm -f -- "$temp_file" || return 1
        else
            return 1
        fi
    fi

    run_copy_to_temp "$source_file" "$temp_file" || return 1

    if ! file_matches_expected "$temp_file" "$expected_hash" "$expected_size" || \
       ! source_matches_snapshot "$source_file" "$expected_hash" "$expected_size" \
            "$source_devino" "$source_mtime" "$source_ctime"; then
        quarantine_file "$temp_file" "${transaction_id}_source_changed_before_publish" || true
        remove_transaction_marker "$marker" || true
        return 1
    fi

    safe_sync_path "$temp_file" || return 1

    if ! publish_temp_no_clobber "$temp_file" "$target_file"; then
        if [[ -f "$temp_file" && ! -L "$temp_file" ]]; then
            quarantine_file "$temp_file" "${transaction_id}_target_conflict" || true
        fi
        remove_transaction_marker "$marker" || true
        return 1
    fi

    if ! file_matches_expected "$target_file" "$expected_hash" "$expected_size"; then
        return 1
    fi

    # Die Quelle wird auch im copy-Modus noch einmal geprüft. Eine während des
    # Transfers weitergeschriebene Datei darf nicht als fertige Kopie gelten.
    if ! source_matches_snapshot "$source_file" "$expected_hash" "$expected_size" \
            "$source_devino" "$source_mtime" "$source_ctime"; then
        quarantine_file "$target_file" "${transaction_id}_source_changed_after_publish" || true
        remove_transaction_marker "$marker" || true
        return 1
    fi

    apply_file_permissions "$target_file"
    safe_sync_path "$target_file" || return 1
    safe_sync_dir "$target_dir" || return 1

    if [[ "$mode" == "move" ]]; then
        source_matches_snapshot "$source_file" "$expected_hash" "$expected_size" \
            "$source_devino" "$source_mtime" "$source_ctime" || return 1

        rm -f -- "$source_file" || return 1
        safe_sync_dir "${source_file%/*}" || return 1
    fi

    remove_transaction_marker "$marker" || return 1
    return 0
}

move_existing_file() {
    local source_file="$1"
    local target_file="$2"
    local known_hash="${3:-}"
    local known_size="${4:-}"
    local hash="$known_hash"
    local size="$known_size"

    if [[ "$DRY_RUN" == "on" ]]; then
        return 0
    fi

    [[ -f "$source_file" && ! -L "$source_file" ]] || return 1

    [[ -n "$size" ]] || size=$(stat_size "$source_file")
    if [[ -z "$hash" ]]; then
        calculate_file_hash "$source_file" || return 1
        hash="$HASH_RESULT"
    fi

    durable_transfer "$source_file" "$target_file" "$hash" "$size" "move"
}

transfer_file() {
    local source_file="$1"
    local target_file="$2"
    local hash="$3"
    local size="$4"

    durable_transfer "$source_file" "$target_file" "$hash" "$size" "$PROCESS_MODE"
}

recover_transactions() {
    local marker=""
    local marker_name=""
    local transaction_id=""
    local source_exists=0
    local source_ok=0
    local temp_ok=0
    local target_ok=0

    if [[ "$DRY_RUN" != "off" || ! -d "$RECOVERY_DIR" ]]; then
        return 0
    fi

    shopt -s nullglob

    for marker in "$RECOVERY_DIR"/*.txn
    do
        source_exists=0
        source_ok=0
        temp_ok=0
        target_ok=0

        marker_name="${marker##*/}"
        transaction_id="${marker_name%.txn}"

        if ! read_transaction_marker "$marker"; then
            COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
            mark_runtime_error "Ungültiger oder unsicherer Recovery-Marker: $marker"
            continue
        fi

        path_entry_exists "$REC_SOURCE" && source_exists=1
        source_matches_snapshot "$REC_SOURCE" "$REC_HASH" "$REC_SIZE" \
            "$REC_DEVINO" "$REC_MTIME" "$REC_CTIME" && source_ok=1
        file_matches_expected "$REC_TEMP" "$REC_HASH" "$REC_SIZE" && temp_ok=1
        file_matches_expected "$REC_TARGET" "$REC_HASH" "$REC_SIZE" && target_ok=1

        # Existiert die Quelle noch, hat sich aber verändert, gehören Ziel/Temp zu
        # einem alten Zwischenstand. Sie werden gesichert und die aktuelle Quelle
        # bleibt unangetastet für einen neuen Lauf.
        if [[ "$source_exists" -eq 1 && "$source_ok" -eq 0 ]]; then
            if [[ "$target_ok" -eq 1 ]]; then
                quarantine_file "$REC_TARGET" "${transaction_id}_stale_target" || {
                    COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                    mark_runtime_error "Veraltetes Recovery-Ziel konnte nicht gesichert werden: $REC_TARGET"
                    continue
                }
            elif path_entry_exists "$REC_TARGET"; then
                COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                mark_runtime_error "Unsicheres Recovery-Ziel bei veränderter Quelle: $REC_TARGET"
                continue
            fi

            if [[ "$temp_ok" -eq 1 ]]; then
                rm -f -- "$REC_TEMP" || {
                    COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                    continue
                }
                safe_sync_dir "${REC_TEMP%/*}" || true
            elif path_entry_exists "$REC_TEMP"; then
                COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                mark_runtime_error "Unsichere Recovery-Temp-Datei bei veränderter Quelle: $REC_TEMP"
                continue
            fi

            remove_transaction_marker "$marker" || {
                COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                continue
            }

            COUNT_RECOVERY_COMPLETED=$((COUNT_RECOVERY_COMPLETED + 1))
            COUNT_RECOVERY_REPAIRED=$((COUNT_RECOVERY_REPAIRED + 1))
            log_warn "Quelle wurde während eines alten Transfers verändert und wird neu verarbeitet: $REC_SOURCE"
            continue
        fi

        if [[ "$target_ok" -eq 1 ]]; then
            if [[ "$temp_ok" -eq 1 ]]; then
                rm -f -- "$REC_TEMP" || {
                    COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                    continue
                }
                safe_sync_dir "${REC_TEMP%/*}" || true
            elif path_entry_exists "$REC_TEMP"; then
                COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                mark_runtime_error "Ungültige Temp-Datei neben intaktem Recovery-Ziel: $REC_TEMP"
                continue
            fi

            if [[ "$REC_MODE" == "move" && "$source_ok" -eq 1 ]]; then
                if [[ "$REC_LEGACY" -eq 1 ]]; then
                    log_warn "Alter v1-Recovery-Marker: intakte Quelle bleibt vorsichtshalber erhalten: $REC_SOURCE"
                else
                    rm -f -- "$REC_SOURCE" || {
                        COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                        mark_runtime_error "Recovery konnte intakte Quelle nicht entfernen: $REC_SOURCE"
                        continue
                    }
                    safe_sync_dir "${REC_SOURCE%/*}" || {
                        COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                        continue
                    }
                fi
            fi

            remove_transaction_marker "$marker" || {
                COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                continue
            }

            COUNT_RECOVERY_COMPLETED=$((COUNT_RECOVERY_COMPLETED + 1))
            continue
        fi

        if path_entry_exists "$REC_TARGET"; then
            if [[ "$source_ok" -eq 1 || "$temp_ok" -eq 1 ]]; then
                quarantine_file "$REC_TARGET" "$transaction_id" || {
                    COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                    mark_runtime_error "Beschädigtes Recovery-Ziel konnte nicht gesichert werden: $REC_TARGET"
                    continue
                }
            else
                COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                mark_runtime_error "Beschädigtes Ziel ohne intakte Quelle oder Temp-Datei: $REC_TARGET"
                continue
            fi
        fi

        if [[ "$temp_ok" -eq 1 ]]; then
            ensure_safe_directory "${REC_TARGET%/*}" || {
                COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                continue
            }

            publish_temp_no_clobber "$REC_TEMP" "$REC_TARGET" || {
                COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                mark_runtime_error "Recovery konnte Temp-Datei nicht sicher veröffentlichen: $REC_TEMP"
                continue
            }

            file_matches_expected "$REC_TARGET" "$REC_HASH" "$REC_SIZE" || {
                COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                mark_runtime_error "Recovery-Zieldatei ist nach Veröffentlichung nicht intakt: $REC_TARGET"
                continue
            }

            apply_file_permissions "$REC_TARGET"

            if [[ "$REC_MODE" == "move" && "$source_ok" -eq 1 ]]; then
                if [[ "$REC_LEGACY" -eq 1 ]]; then
                    log_warn "Alter v1-Recovery-Marker: Quelle bleibt vorsichtshalber erhalten: $REC_SOURCE"
                else
                    rm -f -- "$REC_SOURCE" || {
                        COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                        continue
                    }
                    safe_sync_dir "${REC_SOURCE%/*}" || {
                        COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                        continue
                    }
                fi
            fi

            remove_transaction_marker "$marker" || {
                COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                continue
            }

            COUNT_RECOVERY_COMPLETED=$((COUNT_RECOVERY_COMPLETED + 1))
            COUNT_RECOVERY_REPAIRED=$((COUNT_RECOVERY_REPAIRED + 1))
            continue
        fi

        if [[ "$source_ok" -eq 1 ]]; then
            if durable_transfer \
                "$REC_SOURCE" "$REC_TARGET" "$REC_HASH" "$REC_SIZE" "$REC_MODE" \
                "$marker" "$REC_TEMP"; then
                COUNT_RECOVERY_COMPLETED=$((COUNT_RECOVERY_COMPLETED + 1))
                COUNT_RECOVERY_REPAIRED=$((COUNT_RECOVERY_REPAIRED + 1))
            else
                COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
                mark_runtime_error "Recovery-Neuübertragung ist fehlgeschlagen: $REC_SOURCE"
            fi
            continue
        fi

        COUNT_RECOVERY_FAILED=$((COUNT_RECOVERY_FAILED + 1))
        mark_runtime_error "Recovery nicht möglich; Quelle, Temp-Datei und Ziel sind nicht intakt: $marker"
    done

    shopt -u nullglob

    [[ "$COUNT_RECOVERY_FAILED" -eq 0 ]]
}

handle_orphan_transfer_temp() {
    local temp_file="$1"
    local temp_name=""
    local transaction_id=""
    local expected_marker=""

    [[ "$DRY_RUN" == "off" ]] || return 0
    [[ -f "$temp_file" && ! -L "$temp_file" ]] || return 0

    temp_name="${temp_file##*/}"

    if [[ ! "$temp_name" =~ ^[.]medien-sortierer-([a-fA-F0-9]{64})[.]part$ ]]; then
        log_warn "Datei mit reserviertem Präfix, aber ungültigem Temp-Namen bleibt unangetastet: $temp_file"
        return 0
    fi

    transaction_id="${BASH_REMATCH[1]}"
    expected_marker="$RECOVERY_DIR/${transaction_id}.txn"

    if [[ ! -f "$expected_marker" ]]; then
        quarantine_file "$temp_file" "${transaction_id}_orphan" || {
            mark_runtime_error "Verwaiste Transferdatei konnte nicht gesichert werden: $temp_file"
            return 1
        }
    fi

    return 0
}

scan_orphan_transfer_temps() {
    local temp_file=""
    local orphan_list="$TMP_ROOT/orphan_transfer_temps"

    [[ "$DRY_RUN" == "off" && -d "$DEST" ]] || return 0

    # Feste NUL-Liste: Eine während der Prüfung verschobene .part-Datei kann nicht
    # im selben Durchgang unter ihrem neuen Pfad erneut auftauchen.
    find "$DEST" -xdev \
        -path "$RECOVERY_DIR" -prune -o \
        -type f -name '.medien-sortierer-*.part' -print0 \
        > "$orphan_list" || return 1

    while IFS= read -r -d '' temp_file
    do
        handle_orphan_transfer_temp "$temp_file" || return 1
    done < "$orphan_list"

    rm -f -- "$orphan_list"
    return 0
}

# Recovery muss vor DB-Prüfung, Zielscan und Input-Verarbeitung laufen.
if ! recover_transactions; then
    log_error "Mindestens eine unvollständige Dateioperation konnte nicht sicher repariert werden. Der normale Lauf wird nicht gestartet."
    exit 1
fi

cleanup_empty_recovery_dirs

# ---------------------------------------------
# ZIP-BERICHT
# ---------------------------------------------

record_skipped_zip() {
    local file="$1"
    local readable_path=""

    COUNT_ZIP_SKIPPED=$((COUNT_ZIP_SKIPPED + 1))

    if [[ "$PROCESS_MODE" == "copy" && -n "${ZIP_PATH_LIST:-}" ]]; then
        readable_path="${file//$'\r'/\\r}"
        readable_path="${readable_path//$'\n'/\\n}"
        printf "%s\n" "$readable_path" >> "$ZIP_PATH_LIST"
    fi
}

write_zip_report() {
    local report_temp=""

    if [[ "$DRY_RUN" != "off" ]]; then
        return 0
    fi

    if [[ -L "$ZIP_REPORT_FILE" ]]; then
        mark_runtime_error "ZIP-Bericht ist unerwartet ein symbolischer Link: $ZIP_REPORT_FILE"
        return 1
    fi

    if [[ "$PROCESS_MODE" != "copy" || "$COUNT_ZIP_SKIPPED" -eq 0 ]]; then
        if path_entry_exists "$ZIP_REPORT_FILE"; then
            [[ -f "$ZIP_REPORT_FILE" ]] || return 1
            rm -f -- "$ZIP_REPORT_FILE" || return 1
            safe_sync_dir "$DEST" || return 1
        fi
        return 0
    fi

    report_temp=$(mktemp "$DEST/.zip-report.XXXXXX") || return 1

    {
        echo "Übersprungene ZIP-Dateien im Copy-Modus"
        echo "Letzte Aktualisierung: $(date '+%d.%m.%Y %H:%M:%S')"
        echo "Anzahl: $COUNT_ZIP_SKIPPED"
        echo
        echo "Die ZIP-Dateien wurden nicht geöffnet, verändert oder gelöscht."
        echo
        cat "$ZIP_PATH_LIST"
    } > "$report_temp" || {
        rm -f -- "$report_temp"
        return 1
    }

    safe_sync_path "$report_temp" || {
        rm -f -- "$report_temp"
        return 1
    }

    if mv -f -- "$report_temp" "$ZIP_REPORT_FILE"; then
        set_single_file_permissions "$ZIP_REPORT_FILE"
        safe_sync_path "$ZIP_REPORT_FILE" && safe_sync_dir "$DEST"
    else
        rm -f -- "$report_temp"
        mark_runtime_error "ZIP-Bericht konnte nicht geschrieben werden: $ZIP_REPORT_FILE"
        return 1
    fi
}

# ---------------------------------------------
# BESTEHENDE DATEI IM DEST NEU BEHANDELN
# ---------------------------------------------

process_existing_dest_file() {
    local file="$1"
    local reason="$2"
    local ext=""
    local size=""
    local hash=""
    local old_file=""
    local is_duplicate=0
    local category=""
    local target=""
    local final_size=""

    [[ ! -f "$file" && "$DRY_RUN" == "off" ]] && return 0

    ext="${file##*.}"
    ext="${ext,,}"
    is_media_extension "$ext" || return 0

    COUNT_DEST_PROCESSED=$((COUNT_DEST_PROCESSED + 1))
    db_remove_file_memory "$file"

    set_datetime_from_exif "$file"
    [[ "$DATETIME" != "UNKNOWN" ]] || COUNT_UNKNOWN=$((COUNT_UNKNOWN + 1))

    size=$(stat_size "$file")
    calculate_file_hash "$file" || {
        mark_runtime_error "SHA256 konnte bei der Zielprüfung nicht berechnet werden: $file"
        return 1
    }
    hash="$HASH_RESULT"

    if [[ -n "${DB_HASH_FILE[$hash]+x}" ]]; then
        old_file="${DB_HASH_FILE[$hash]}"

        if [[ "$DRY_RUN" == "on" || -f "$old_file" ]]; then
            is_duplicate=1
            COUNT_DUPLICATE=$((COUNT_DUPLICATE + 1))
        fi
    fi

    if [[ "$is_duplicate" -eq 1 ]]; then
        category="DUPLICATES"
    else
        set_category_from_ext "$ext"
        category="$CATEGORY_RESULT"
    fi

    if ! get_unique_target "$category" "$YEAR" "$MONTH" "$DATETIME" "$ext" "$is_duplicate" "$file"; then
        COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
        mark_runtime_error "Sicheres Ziel konnte nicht bestimmt werden: $file"
        return 1
    fi
    target="$UNIQUE_TARGET_RESULT"

    if [[ "$file" != "$target" ]]; then
        if move_existing_file "$file" "$target" "$hash" "$size"; then
            file="$target"
        else
            COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
            mark_runtime_error "Datei aus Zielprüfung konnte nicht neu einsortiert werden ($reason): $file"
            return 1
        fi
    fi

    if [[ "$is_duplicate" -eq 1 ]]; then
        COUNT_DUPLICATE_STORED=$((COUNT_DUPLICATE_STORED + 1))
        return 0
    fi

    if [[ "$DRY_RUN" == "on" ]]; then
        final_size="$size"
    else
        final_size=$(stat_size "$file")
    fi

    db_remove_hash_memory "$hash"
    db_add_memory "$hash" "$DATETIME" "$final_size" "$file" || {
        mark_runtime_error "Zieldatei konnte nicht eindeutig in die DB aufgenommen werden: $file"
        return 1
    }
    DB_DIRTY=1
    db_mark_seen "$file"
    COUNT_ORIGINAL=$((COUNT_ORIGINAL + 1))
    return 0
}

handle_dest_integrity_mismatch() {
    local file="$1"
    local reason="$2"
    local transaction_id=""

    db_remove_file_memory "$file"
    calculate_text_hash "$file" || {
        mark_runtime_error "Quarantäne-ID konnte nicht sicher berechnet werden: $file"
        return 1
    }
    transaction_id="dest_integrity_$HASH_RESULT"

    if [[ "$DRY_RUN" == "on" ]]; then
        log_warn "DRY_RUN: Auffällige Zieldatei würde in Quarantäne verschoben ($reason): $file"
        return 0
    fi

    if quarantine_file "$file" "$transaction_id"; then
        mark_runtime_error "Auffällige Zieldatei wurde zur Sicherheit quarantänisiert ($reason): $file"
        return 0
    fi

    mark_runtime_error "Auffällige Zieldatei konnte nicht sicher quarantänisiert werden ($reason): $file"
    return 1
}

# ---------------------------------------------
# DB EINMALIG LADEN ODER NEU AUFBAUEN
# ---------------------------------------------

if [[ "$DATABASE_MODE" == "temporary" && "$DRY_RUN" == "off" ]]; then
    DB_REBUILD=1
    DB_REBUILD_REASON="Temporäre Datenbank"
    db_clear_memory
    log_info "Temporäre Datenbank wird aus dem aktuellen Zielbestand aufgebaut"
else
    db_load_once
    DB_LOAD_STATUS=$?

    case "$DB_LOAD_STATUS" in
        0)
            DB_REBUILD=0
            DB_DIRTY=0
            log_info "Datenbank vorhanden und formal gültig"
            ;;
        2)
            DB_REBUILD=1
            if [[ ! -f "$DB_FILE" ]]; then
                DB_REBUILD_REASON="DB fehlt"
                log_warn "Datenbank fehlt"
            else
                DB_REBUILD_REASON="DB leer"
                log_warn "Datenbank ist leer"
            fi
            ;;
        *)
            DB_REBUILD=1
            DB_REBUILD_REASON="DB formal fehlerhaft oder widersprüchlich"
            log_warn "Datenbank wirkt formal fehlerhaft, widersprüchlich oder hat ein altes Format"
            ;;
    esac
fi

if [[ "$DB_REBUILD" -eq 1 ]]; then
    log_info "Starte DB-Neuaufbau mit Strukturprüfung"
    log_info "Grund: $DB_REBUILD_REASON"

    # Bei einem Rebuild gibt es keinen normalen Schnellscan. Daher werden .part-
    # Dateien einmal gezielt geprüft, bevor der Zielbestand neu aufgebaut wird.
    scan_orphan_transfer_temps || {
        log_error "Verwaiste Transferdateien konnten nicht sicher behandelt werden"
        exit 1
    }

    db_clear_memory
    DB_DIRTY=1

    if [[ -d "$DEST" ]]; then
        if ! find "$DEST" -xdev \
            -path "$RECOVERY_DIR" -prune -o \
            -type f \
            ! -name '.hashes.db*' \
            ! -name '.hashes.journal*' \
            ! -name '.source_state.db*' \
            ! -name '.medien-sortierer-*.part' \
            -print0 > "$FILE_LIST"; then
            log_error "Zielbestand konnte für den DB-Neuaufbau nicht vollständig erfasst werden"
            exit 1
        fi
    else
        : > "$FILE_LIST"
    fi

    while IFS= read -r -d '' file
    do
        [[ ! -f "$file" && "$DRY_RUN" == "off" ]] && continue
        media_path_supported "$file" || continue

        EXT="${file##*.}"
        EXT="${EXT,,}"
        is_media_extension "$EXT" || continue

        NAME="${file##*/}"
        SIZE=$(stat_size "$file")
        set_datetime_from_exif "$file"
        if ! calculate_file_hash "$file"; then
            COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
            mark_runtime_error "SHA256 konnte beim DB-Neuaufbau nicht berechnet werden: $file"
            continue
        fi
        HASH="$HASH_RESULT"
        IS_DUPLICATE=0

        if [[ "$file" == "$DEST/DUPLICATES/"* ]] || [[ "$NAME" =~ _DUP[0-9]+\. ]]; then
            CATEGORY="DUPLICATES"
            IS_DUPLICATE=1
        elif [[ -n "${DB_HASH_FILE[$HASH]+x}" ]]; then
            CATEGORY="DUPLICATES"
            IS_DUPLICATE=1
            COUNT_REBUILD_DUPES=$((COUNT_REBUILD_DUPES + 1))
        else
            set_category_from_ext "$EXT"
            CATEGORY="$CATEGORY_RESULT"
        fi

        if ! get_unique_target "$CATEGORY" "$YEAR" "$MONTH" "$DATETIME" "$EXT" "$IS_DUPLICATE" "$file"; then
            COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
            mark_runtime_error "Sicheres Ziel konnte nicht bestimmt werden: $file"
            continue
        fi
        TARGET="$UNIQUE_TARGET_RESULT"

        FILE_FOR_DB="$file"

        if [[ "$file" != "$TARGET" ]]; then
            COUNT_REBUILD_FIXED=$((COUNT_REBUILD_FIXED + 1))

            if move_existing_file "$file" "$TARGET" "$HASH" "$SIZE"; then
                FILE_FOR_DB="$TARGET"
            else
                COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
                mark_runtime_error "Strukturkorrektur fehlgeschlagen: $file"
                FILE_FOR_DB="$file"
            fi
        fi

        if [[ "$IS_DUPLICATE" -eq 0 ]]; then
            if [[ "$DRY_RUN" == "on" ]]; then
                SIZE_FOR_DB="$SIZE"
            else
                SIZE_FOR_DB=$(stat_size "$FILE_FOR_DB")
            fi

            db_add_memory "$HASH" "$DATETIME" "$SIZE_FOR_DB" "$FILE_FOR_DB" || {
                mark_runtime_error "Widersprüchlicher DB-Eintrag beim Neuaufbau: $FILE_FOR_DB"
                continue
            }
            COUNT_REBUILD_ADDED=$((COUNT_REBUILD_ADDED + 1))
        fi
    done < "$FILE_LIST"

    if ! db_write_if_dirty; then
        log_error "DB-Neuaufbau konnte nicht sicher abgeschlossen werden"
        exit 1
    fi

    if path_entry_exists "$JOURNAL_FILE"; then
        journal_clear || {
            log_error "Altes Journal konnte nach dem DB-Neuaufbau nicht entfernt werden"
            exit 1
        }
    fi

    log_ok "DB-Neuaufbau abgeschlossen"
    log_info "DB-Einträge erstellt: $COUNT_REBUILD_ADDED"
    log_info "Struktur korrigiert: $COUNT_REBUILD_FIXED"
    log_info "Duplikate beim Neuaufbau erkannt: $COUNT_REBUILD_DUPES"
else
    if ! journal_recover; then
        log_error "Journal konnte nicht sicher verarbeitet werden"
        exit 1
    fi
fi

log_ok "Datenbank bereit"
log_info "DB-Einträge im Arbeitsspeicher: $DB_RECORD_COUNT"

# ---------------------------------------------
# SOURCE-STATE LADEN
# ---------------------------------------------

if ! source_state_load; then
    log_error "Source-State-Cache konnte nicht sicher geladen werden"
    exit 1
fi

if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" == "on" ]]; then
    log_ok "Source-State-Cache bereit"
    log_info "Source-State-Einträge geladen: $COUNT_SOURCE_STATE_LOADED"
    log_info "Ungültige Source-State-Einträge ignoriert: $COUNT_SOURCE_STATE_INVALID"
fi

# ---------------------------------------------
# EINMALIGE SCHNELLE ZIELPRÜFUNG
# ---------------------------------------------

if [[ "$QUICK_DEST_CHECK" == "on" && "$DB_REBUILD" -eq 0 ]]; then
    log_info "Starte schnelle Zielprüfung: Pfad/Name und Größe in einem Durchgang"

    # Feste NUL-Liste vor der ersten Korrektur: Wird eine Datei innerhalb von DEST
    # verschoben, kann ihr neuer Pfad im selben Lauf nicht erneut eingesammelt werden.
    # GNU-find liefert die Größe direkt zusammen mit dem Pfad. Das vermeidet in
    # copy und move für jede Zieldatei einen separaten externen stat-Prozess. Die
    # Prüflogik bleibt identisch: Vergleich von Pfad/Name und Größe mit der DB;
    # ein Inhalts-Hash wird nur bei den ohnehin nötigen Tiefprüfungen berechnet.
    DEST_QUICK_LIST_HAS_SIZE=0

    if find "$DEST" -prune -printf '' >/dev/null 2>&1; then

        if ! find "$DEST" -xdev \
            -path "$RECOVERY_DIR" -prune -o \
            -type f -printf '%p\0%s\0' > "$FILE_LIST"; then
            log_error "Zielbestand konnte für die Schnellprüfung nicht vollständig erfasst werden"
            exit 1
        fi

        DEST_QUICK_LIST_HAS_SIZE=1
        log_info "Zielscan-Optimierung aktiv: Pfad/Name und Größe gemeinsam eingesammelt"
    else
        if ! find "$DEST" -xdev \
            -path "$RECOVERY_DIR" -prune -o \
            -type f -print0 > "$FILE_LIST"; then
            log_error "Zielbestand konnte für die Schnellprüfung nicht vollständig erfasst werden"
            exit 1
        fi

        log_info "Zielscan-Optimierung nicht verfügbar: sichere Standardprüfung mit separatem Größenabruf"
    fi

    while IFS= read -r -d '' file
    do
        # Bei der Zielscan-Optimierung folgt auf jeden NUL-beendeten Pfad unmittelbar
        # die ebenfalls NUL-beendete Dateigröße. Sie muss vor jedem continue
        # eingelesen werden, damit die Datensätze ausgerichtet bleiben.
        CURRENT_SIZE=""
        if [[ "$DEST_QUICK_LIST_HAS_SIZE" -eq 1 ]]; then
            if ! IFS= read -r -d '' CURRENT_SIZE; then
                log_error "Optimierte Ziel-Schnellliste ist unvollständig"
                exit 1
            fi

            if ! valid_size "$CURRENT_SIZE"; then
                log_error "Optimierte Ziel-Schnellliste enthält eine ungültige Dateigröße"
                exit 1
            fi
        fi

        FILE_NAME="${file##*/}"

        if [[ "$FILE_NAME" == .medien-sortierer-*.part ]]; then
            handle_orphan_transfer_temp "$file"
            continue
        fi

        case "$FILE_NAME" in
            .hashes.db|.hashes.db.*|.hashes.journal|.hashes.journal.*|\
            .source_state.db|.source_state.db.*|RUN.txt|ERROR.txt|FINISH.txt|\
            Uebersprungene_ZIP-Dateien.txt)
                continue
                ;;
        esac

        [[ "$file" != "$DEST/DUPLICATES/"* ]] || continue
        media_path_supported "$file" || continue

        EXT="${file##*.}"
        EXT="${EXT,,}"
        is_media_extension "$EXT" || continue

        COUNT_DEST_SCANNED=$((COUNT_DEST_SCANNED + 1))

        if [[ -z "${DB_FILE_RECORD[$file]+x}" ]]; then
            COUNT_DEST_UNKNOWN=$((COUNT_DEST_UNKNOWN + 1))
            process_existing_dest_file "$file" "unbekannt"
            continue
        fi

        DB_RECORD="${DB_FILE_RECORD[$file]}"
        IFS='|' read -r DB_HASH DB_DATE DB_SIZE _ <<< "$DB_RECORD"
        db_mark_seen "$file"

        if [[ "$DEST_QUICK_LIST_HAS_SIZE" -eq 0 ]]; then
            CURRENT_SIZE=$(stat_size "$file")
        fi

        if [[ "$CURRENT_SIZE" != "$DB_SIZE" ]]; then
            COUNT_DEST_SIZE_MISMATCH=$((COUNT_DEST_SIZE_MISMATCH + 1))
            handle_dest_integrity_mismatch "$file" "Größe stimmt nicht mit der DB überein"
            continue
        fi

        if [[ "$DEST_CHECK_LEVEL" == "hash" ]]; then
            if ! calculate_file_hash "$file"; then
                COUNT_DEST_HASH_MISMATCH=$((COUNT_DEST_HASH_MISMATCH + 1))
                handle_dest_integrity_mismatch "$file" "SHA256 konnte nicht sicher berechnet werden"
                continue
            fi
            CURRENT_HASH="$HASH_RESULT"
            if [[ "$CURRENT_HASH" != "$DB_HASH" ]]; then
                COUNT_DEST_HASH_MISMATCH=$((COUNT_DEST_HASH_MISMATCH + 1))
                handle_dest_integrity_mismatch "$file" "SHA256 stimmt nicht mit der DB überein"
                continue
            fi
        fi

        # DB_DATE wurde beim Laden derselben DB bereits vollständig validiert.
        # Die Strukturprüfung verwendet dieses Ergebnis, statt pro Zieldatei
        # denselben externen date-Aufruf ein zweites Mal zu starten.
        if ! is_db_path_plausible "$file" "$DB_DATE" 1; then
            COUNT_DEST_STRUCTURE_MISMATCH=$((COUNT_DEST_STRUCTURE_MISMATCH + 1))
            process_existing_dest_file "$file" "struktur"
            continue
        fi
    done < "$FILE_LIST"

    db_remove_unseen_after_scan
    cleanup_empty_recovery_dirs

    log_ok "Schnelle Zielprüfung abgeschlossen"
    log_info "Ziel-Dateien geprüft: $COUNT_DEST_SCANNED"
    log_info "Unbekannte Ziel-Dateien: $COUNT_DEST_UNKNOWN"
    log_info "Fehlende DB-Dateien entfernt: $COUNT_DB_REMOVED"
    log_info "Größenabweichungen: $COUNT_DEST_SIZE_MISMATCH"
    log_info "Hash-Abweichungen: $COUNT_DEST_HASH_MISMATCH"
    log_info "Strukturabweichungen: $COUNT_DEST_STRUCTURE_MISMATCH"
    log_info "Einzeln neu behandelt: $COUNT_DEST_PROCESSED"
else
    # Auch ohne allgemeine Zielprüfung bleiben verwaiste Transferdateien geschützt.
    scan_orphan_transfer_temps || {
        log_error "Verwaiste Transferdateien konnten nicht sicher behandelt werden"
        exit 1
    }
    cleanup_empty_recovery_dirs
fi

# ---------------------------------------------
# INPUT VERARBEITEN
# ---------------------------------------------

log_info "Starte Verarbeitung des Input-Ordners"

# Feste Momentaufnahme des Inputs. Neu eintreffende Dateien werden dadurch nicht
# gleichzeitig im laufenden und im folgenden Watch-Durchgang verarbeitet. Der
# inotify-Controller bleibt aktiv und startet für neue Ereignisse einen Folgelauf.
if ! find "$SRC" -xdev -type f -print0 > "$INPUT_FILE_LIST"; then
    log_error "Input-Dateien konnten nicht vollständig erfasst werden"
    exit 1
fi

while IFS= read -r -d '' file
do
    [[ -f "$file" && ! -L "$file" ]] || continue
    media_path_supported "$file" || continue

    EXT="${file##*.}"
    EXT="${EXT,,}"

    if ! is_media_extension "$EXT"; then
        if [[ "$EXT" == "zip" ]]; then
            record_skipped_zip "$file"
        else
            COUNT_SKIPPED=$((COUNT_SKIPPED + 1))
        fi
        continue
    fi

    if ! read_file_snapshot "$file"; then
        COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
        mark_runtime_error "Dateizustand konnte nicht sicher gelesen werden: $file"
        continue
    fi

    SIZE="$FILE_SNAPSHOT_SIZE"
    MTIME="$FILE_SNAPSHOT_MTIME"
    CTIME="$FILE_SNAPSHOT_CTIME"
    DEVINO="$FILE_SNAPSHOT_DEVINO"
    SOURCE_STATE_KEY=""

    if source_state_should_skip "$file" "$SIZE" "$MTIME"; then
        # ctime und Datei-Identität dürfen sich bei unverändertem Pfad, Größe und
        # mtime ändern, ohne den sicheren Schnellpfad oder ein DB-Neuschreiben
        # auszulösen. Der vorhandene Datensatz bleibt deshalb exakt erhalten.
        source_state_mark_seen "$file"
        COUNT_SOURCE_STATE_WRITTEN=$((COUNT_SOURCE_STATE_WRITTEN + 1))
        COUNT_SOURCE_STATE_SKIPPED=$((COUNT_SOURCE_STATE_SKIPPED + 1))
        continue
    fi
    SOURCE_STATE_KEY="$SOURCE_STATE_CURRENT_KEY"

    set_datetime_from_exif "$file"

    if [[ "$DATETIME" == "UNKNOWN" ]]; then
        COUNT_UNKNOWN=$((COUNT_UNKNOWN + 1))
    fi

    HASH=""
    IS_DUPLICATE=0

    if [[ "$FAST_MODE" == "on" ]]; then
        if [[ -n "$SIZE" && -z "${DB_SIZE_COUNT[$SIZE]+x}" ]]; then
            IS_DUPLICATE=0
        else
            if ! calculate_file_hash "$file"; then
                COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
                mark_runtime_error "SHA256 konnte nicht sicher berechnet werden: $file"
                continue
            fi
            HASH="$HASH_RESULT"
        fi
    else
        if ! calculate_file_hash "$file"; then
            COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
            mark_runtime_error "SHA256 konnte nicht sicher berechnet werden: $file"
            continue
        fi
        HASH="$HASH_RESULT"
    fi

    if [[ -n "$HASH" ]] && db_find_hash "$HASH"; then
        if [[ "$DRY_RUN" == "on" || -f "$DB_LOOKUP_FILE" ]]; then
            IS_DUPLICATE=1
            COUNT_DUPLICATE=$((COUNT_DUPLICATE + 1))
        fi
    fi

    if [[ "$IS_DUPLICATE" -eq 1 && "$EFFECTIVE_DUPLICATE_ACTION" == "skip" ]]; then
        if [[ -n "$HASH" ]]; then
            source_state_write_current \
                "$file" "$EXT" "$SIZE" "$MTIME" "$CTIME" "$DEVINO" \
                "$HASH" "$SOURCE_STATE_KEY"
        fi

        COUNT_DUPLICATE_SKIPPED=$((COUNT_DUPLICATE_SKIPPED + 1))
        continue
    fi

    if [[ "$IS_DUPLICATE" -eq 1 ]]; then
        CATEGORY="DUPLICATES"
    else
        set_category_from_ext "$EXT"
        CATEGORY="$CATEGORY_RESULT"
    fi

    if ! get_unique_target "$CATEGORY" "$YEAR" "$MONTH" "$DATETIME" "$EXT" "$IS_DUPLICATE" ""; then
        COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
        mark_runtime_error "Sicheres Ziel konnte nicht bestimmt werden: $file"
        continue
    fi
    TARGET="$UNIQUE_TARGET_RESULT"

    if [[ -z "$HASH" ]]; then
        if ! calculate_file_hash "$file"; then
            COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
            mark_runtime_error "SHA256 konnte nicht sicher berechnet werden: $file"
            continue
        fi
        HASH="$HASH_RESULT"
    fi

    if [[ "$IS_DUPLICATE" -eq 0 ]]; then
        if ! journal_append_original "$HASH" "$DATETIME" "$SIZE" "$TARGET"; then
            COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
            mark_runtime_error "Journal konnte vor dem Transfer nicht sicher geschrieben werden: $file"
            continue
        fi
    fi

    if ! transfer_file "$file" "$TARGET" "$HASH" "$SIZE"; then
        COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
        mark_runtime_error "Datei konnte nicht sicher verarbeitet werden: $file"
        continue
    fi

    source_state_write_current \
        "$file" "$EXT" "$SIZE" "$MTIME" "$CTIME" "$DEVINO" \
        "$HASH" "$SOURCE_STATE_KEY"

    if [[ "$IS_DUPLICATE" -eq 1 ]]; then
        COUNT_DUPLICATE_STORED=$((COUNT_DUPLICATE_STORED + 1))
        continue
    fi

    FINAL_SIZE="$SIZE"

    if [[ "$DRY_RUN" == "off" ]]; then
        FINAL_SIZE=$(stat_size "$TARGET")
    fi

    db_remove_hash_memory "$HASH"
    if ! db_add_memory "$HASH" "$DATETIME" "$FINAL_SIZE" "$TARGET"; then
        COUNT_TRANSFER_FAILED=$((COUNT_TRANSFER_FAILED + 1))
        mark_runtime_error "Neues Original konnte nicht eindeutig in die DB aufgenommen werden: $TARGET"
        continue
    fi
    DB_DIRTY=1

    COUNT_ORIGINAL=$((COUNT_ORIGINAL + 1))
done < "$INPUT_FILE_LIST"

log_ok "Input-Verarbeitung abgeschlossen"

if ! write_zip_report; then
    mark_runtime_error "ZIP-Bericht konnte nicht sicher aktualisiert werden"
fi

# ---------------------------------------------
# SOURCE-STATE UND DB FINAL
# ---------------------------------------------

if ! source_state_finalize; then
    mark_runtime_error "Source-State-Cache konnte nicht sicher geschrieben werden"
fi

if [[ "$SOURCE_STATE_CACHE_EFFECTIVE" == "on" ]]; then
    log_ok "Source-State-Cache aktualisiert"
    log_info "Unveränderte Quelldateien übersprungen: $COUNT_SOURCE_STATE_SKIPPED"
    log_info "Source-State-Einträge erfasst: $COUNT_SOURCE_STATE_WRITTEN"
    log_info "Nicht mehr vorhandene Quelleinträge entfernt: $COUNT_SOURCE_STATE_REMOVED"
fi

log_info "Prüfe, ob die Datenbank neu geschrieben werden muss"
if ! db_write_if_dirty; then
    mark_runtime_error "Datenbank konnte nicht sicher final geschrieben werden"
fi

# Das Journal wird nur nach einer sicher geschriebenen DB und einem fehlerfreien
# Lauf entfernt. Ohne echten Originaltransfer wird künftig gar keines angelegt.
if [[ "$COUNT_TRANSFER_FAILED" -eq 0 && \
      "$COUNT_RECOVERY_FAILED" -eq 0 && \
      "$SCRIPT_HAD_ERROR" -eq 0 ]]; then
    if [[ "$JOURNAL_NEEDS_CLEAR" -eq 1 ]] || path_entry_exists "$JOURNAL_FILE"; then
        journal_clear || mark_runtime_error "Journal konnte nicht sicher entfernt werden"
    fi
else
    log_warn "Journal bleibt wegen fehlgeschlagener Transfers/Recovery für den nächsten Start erhalten"
fi

# ---------------------------------------------
# LEERE ORDNER UND RECOVERY AUFRÄUMEN
# ---------------------------------------------

log_info "Entferne leere Ordner"

if [[ "$PROCESS_MODE" == "move" && "$DELETE_EMPTY_SRC_DIRS" == "on" ]]; then
    if [[ "$DRY_RUN" == "off" ]]; then
        find "$SRC" -xdev -mindepth 1 -type d -empty -delete
    fi
    log_ok "Leere Input-Unterordner entfernt"
else
    log_info "Input-Unterordner bleiben unverändert"
fi

if [[ "$DELETE_EMPTY_DEST_DIRS" == "on" ]]; then
    if [[ "$DRY_RUN" == "off" ]]; then
        find "$DEST" -xdev -depth -mindepth 1 -type d -empty \
            ! -path "$RECOVERY_DIR" \
            ! -path "$RECOVERY_QUARANTINE_DIR" \
            -delete
    fi
    log_ok "Leere Output-Unterordner entfernt"
fi

cleanup_empty_recovery_dirs

# ---------------------------------------------
# RECHTE INNERHALB DES ZIELORDNERS
# ---------------------------------------------

if [[ "$SET_PERMISSIONS" == "on" ]]; then
    if [[ "$RECURSIVE_PERMISSION_FIX" == "on" ]]; then
        apply_recursive_permissions || mark_runtime_error "Rekursive Rechtekorrektur innerhalb des Zielordners war nicht vollständig erfolgreich"
    else
        log_ok "Rechte wurden auf neu erstellte/ersetzte Dateien und neue Ziel-Unterordner angewendet"
    fi
else
    log_info "Datei- und Ordnerrechte wurden nicht zusätzlich verändert"
fi

# ---------------------------------------------
# ABSCHLIESSENDER SYNC
# ---------------------------------------------
# DB, Journal, Source-State und Transfers wurden bereits an ihrer jeweiligen
# Sicherheitsgrenze synchronisiert. Hier genügt ein abschließender Dateisystem-
# Sync für DEST; dieselben Dateien werden nicht nochmals einzeln synchronisiert.

if [[ "$RUN_SYNC" == "on" && "$DRY_RUN" == "off" ]]; then
    log_info "Synchronisiere abschließende Metadatenänderungen"
    safe_sync_dir "$DEST" || mark_runtime_error "Abschließender Sync von DEST ist fehlgeschlagen"
    log_ok "Sync abgeschlossen"
fi

# ---------------------------------------------
# ENDE
# ---------------------------------------------

END_TIME=$(date +%s)
RUNTIME=$((END_TIME - START_TIME))

echo "========================================"
echo "[FERTIG] Medien-Sortierung abgeschlossen"
echo "----------------------------------------"
echo "Modus                         : $PROCESS_MODE"
echo "Duplikat-Aktion               : $EFFECTIVE_DUPLICATE_ACTION"
echo "Dry-Run                       : $DRY_RUN"
echo "Fast-Mode                     : $FAST_MODE"
echo "Struktur                      : Kategorie/Medientyp/$SORT_STRUCTURE"
echo "Datenbank-Modus               : $DATABASE_MODE"
echo "Watch-Modus                   : $WATCH_MODE_EFFECTIVE"
echo "Watch-Backend (konfiguriert) : $WATCH_BACKEND"
echo "Watch-Backend (verwendet)    : $WATCH_BACKEND_EFFECTIVE"
echo "Schnellprüfung Zielordner     : $QUICK_DEST_CHECK ($DEST_CHECK_LEVEL)"
echo "Source-State-Cache            : $SOURCE_STATE_CACHE_EFFECTIVE"
echo "Neue Originale                : $COUNT_ORIGINAL"
echo "Duplikate erkannt             : $COUNT_DUPLICATE"
echo "Duplikate gespeichert         : $COUNT_DUPLICATE_STORED"
echo "Duplikate übersprungen        : $COUNT_DUPLICATE_SKIPPED"
echo "UNKNOWN Dateien               : $COUNT_UNKNOWN"
echo "Übersprungene ZIP-Dateien     : $COUNT_ZIP_SKIPPED"
echo "Übersprungene sonstige Dateien: $COUNT_SKIPPED"
echo "Fehlgeschlagene Transfers     : $COUNT_TRANSFER_FAILED"
echo "Recovery abgeschlossen        : $COUNT_RECOVERY_COMPLETED"
echo "Recovery repariert            : $COUNT_RECOVERY_REPAIRED"
echo "Recovery-Fehler               : $COUNT_RECOVERY_FAILED"
echo "Recovery-Quarantäne           : $COUNT_RECOVERY_QUARANTINED"
echo "DB-Rebuild                    : $([[ "$DB_REBUILD" -eq 1 ]] && echo "Ja" || echo "Nein")"
echo "DB-Rebuild-Grund              : $DB_REBUILD_REASON"
echo "DB-Einträge entfernt          : $COUNT_DB_REMOVED"
echo "Journal wiederhergestellt     : $COUNT_JOURNAL_RECOVERED"
echo "Ziel-Dateien geprüft          : $COUNT_DEST_SCANNED"
echo "Unbekannte Ziel-Dateien       : $COUNT_DEST_UNKNOWN"
echo "Größenabweichungen            : $COUNT_DEST_SIZE_MISMATCH"
echo "Hash-Abweichungen             : $COUNT_DEST_HASH_MISMATCH"
echo "Strukturabweichungen          : $COUNT_DEST_STRUCTURE_MISMATCH"
echo "Nicht unterstützte Pfade      : $COUNT_UNSUPPORTED_NAMES"
echo "Einzeln neu behandelt         : $COUNT_DEST_PROCESSED"
echo "Source-State geladen          : $COUNT_SOURCE_STATE_LOADED"
echo "Source-State übersprungen     : $COUNT_SOURCE_STATE_SKIPPED"
echo "Source-State erfasst          : $COUNT_SOURCE_STATE_WRITTEN"
echo "Source-State entfernt         : $COUNT_SOURCE_STATE_REMOVED"
echo "Source-State ungültig         : $COUNT_SOURCE_STATE_INVALID"
echo "DB-Format                     : HASH|DATETIME|SIZE|FILE"
echo "Source-State-Format           : SRC_KEY|SIZE|MTIME|CTIME|DEVINO|BASENAME|EXT|HASH|SRC_FILE"
echo "Laufzeit                      : ${RUNTIME}s"
echo "========================================"

if [[ "$SCRIPT_HAD_ERROR" -ne 0 || \
      "$COUNT_TRANSFER_FAILED" -gt 0 || \
      "$COUNT_RECOVERY_FAILED" -gt 0 ]]; then
    exit 1
fi

SCRIPT_COMPLETED=1
exit 0

BilderAktuellScript_v1.0.6.sh

Edited by funflo

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.

  • 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-Modus

Im 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-Modus

Im 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 RAW

Compatible: gängige Formate wie JPG, PNG, MP4 und MOV

Other: weitere Formate wie TIFF, WEBP oder GIF

Innerhalb 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. Videoordnern

Sortierung nach Jahr

Sortierung nach Jahr und Monat

Ein 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 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.

Guest
Reply to this topic...

Account

Navigation

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.