Showing 17 changed files with 1759 additions and 42 deletions
+9 -0
.gitignore
@@ -1,3 +1,12 @@
1 1
 # Exclude samples directory from version control
2 2
 sample/
3 3
 tests/
4
+.DS_Store
5
+test_reports/
6
+test/.DS_Store
7
+test/*_before.txt
8
+test/*_after.txt
9
+test/import_log.txt
10
+test/source/sorted/
11
+test/destination/*
12
+!test/destination/.gitkeep
+2 -3
README.md
@@ -2,10 +2,10 @@
2 2
 
3 3
 This repository contains the development and testing resources for the `media-importer.sh` script, which is the main product of this project.
4 4
 
5
-
6 5
 ## Project Structure
7 6
 
8 7
 - `media-importer.sh`: The primary script under development and testing.
8
+- `RPI/`: Raspberry Pi integration prototype. This area is currently frozen because Raspberry Pi 3B+ hardware with 1 GB RAM has not provided reliable validation capacity and may also be affected by hardware defects.
9 9
 - `samples/code/autonas-media-importer.sh`: The original script that serves as inspiration for this project.
10 10
 - `samples/`: Contains code samples, example files, and other resources used for development and testing. **Note:** Scripts and functions are not imported from this directory; it is strictly for reference and testing purposes.
11 11
 
@@ -15,7 +15,7 @@ Version control is implemented for all files except the `samples/` directory. Th
15 15
 
16 16
 To exclude the `samples/` directory, the following entry should be added to the `.gitignore` file:
17 17
 
18
-```
18
+```gitignore
19 19
 samples/
20 20
 ```
21 21
 
@@ -31,7 +31,6 @@ Specify your license here.
31 31
 
32 32
 ---
33 33
 
34
-
35 34
 For details on recent changes, including the removal of fallback date handling, see [CHANGELOG.md](./CHANGELOG.md).
36 35
 
37 36
 This file is intended for GitHub Copilot and contributors to understand the project structure and guidelines.
+15 -0
RPI/CHANGELOG.md
@@ -0,0 +1,15 @@
1
+# Changelog
2
+
3
+## 0.2.0 - 2026-03-12
4
+
5
+- Switched configuration to UUID-based camera profiles for reliable attach detection.
6
+- Added udev + wrapper + systemd attach pipeline modeled after proven AutoNAS behavior.
7
+- Added disk handler to run imports on device detection events.
8
+- Reused AutoNAS media importer script unchanged for proven Varia-compatible import behavior.
9
+
10
+## 0.1.0 - 2026-03-12
11
+
12
+- Created standalone Raspberry Pi camera media importer project.
13
+- Added interactive menu-based wizard for profile management.
14
+- Added media import engine with EXIF date extraction and QuickTime UTC conversion.
15
+- Added setup script for dependency and file installation.
+46 -0
RPI/INSTALL.md
@@ -0,0 +1,46 @@
1
+# Install Guide
2
+
3
+## Status
4
+
5
+RPI section is frozen.
6
+
7
+Nu se recomanda folosirea acestui ghid ca baza pentru testare curenta pe Raspberry Pi 3B+ cu 1 GB RAM. Platforma nu a oferit suficiente resurse pentru validare fiabila si exista posibilitatea unui defect hardware.
8
+
9
+## 1. Copy project to Raspberry Pi
10
+
11
+Clone or copy this folder to the Raspberry Pi.
12
+
13
+## 2. Run installer
14
+
15
+```bash
16
+cd rpi-camera-media-importer
17
+sudo ./setup.sh
18
+```
19
+
20
+## 3. Configure camera profiles
21
+
22
+```bash
23
+rpi-camera-importer wizard
24
+```
25
+
26
+In wizard, configure each camera by `ID_FS_UUID` and destination path.
27
+
28
+## 4. Verify detection pipeline (udev + systemd)
29
+
30
+Connect camera and verify attach unit logs:
31
+
32
+```bash
33
+journalctl -u rpi-camera-importer-attach@<UUID>.service -f
34
+```
35
+
36
+## 5. Test import
37
+
38
+```bash
39
+rpi-camera-importer import --all --dry-run --verbose
40
+```
41
+
42
+## 6. Run real import
43
+
44
+```bash
45
+rpi-camera-importer import --all
46
+```
+103 -0
RPI/README.md
@@ -0,0 +1,103 @@
1
+# Raspberry Pi Camera Media Importer
2
+
3
+## Status
4
+
5
+Aceasta sectiune este frozen.
6
+
7
+Motiv: Raspberry Pi 3B+ cu 1 GB RAM nu ofera suficiente resurse pentru testare si validare fiabila, iar comportamentul observat poate indica si un defect hardware. Pana la confirmarea unei platforme mai stabile, componenta RPI ramane documentata, dar nu mai este considerata activ dezvoltata sau validata.
8
+
9
+Proiect standalone pentru Raspberry Pi care importa media de pe camere (foto/video) prin detectie automata la conectare dispozitiv.
10
+
11
+Acest proiect este independent de cluster si nu are integrare cu codul de deploy existent.
12
+
13
+## Cerinte cheie implementate
14
+
15
+- Compatibilitate cu camere Garmin Varia.
16
+- Importerul media existent, testat in AutoNAS, este reutilizat fara modificari in acest proiect.
17
+- Trigger la detectie dispozitiv, pe mecanismul testat AutoNAS:
18
+  - udev rule
19
+  - wrapper (pentru device-uri ata bridge)
20
+  - systemd attach template unit
21
+  - disk handler care ruleaza import pe UUID
22
+
23
+## Functionalitati
24
+
25
+- Wizard menu-based pentru configurare profile camera
26
+- Profile multiple bazate pe UUID (`ID_FS_UUID`)
27
+- Import automat la conectarea camerei
28
+- Import manual pentru un profil, un UUID sau toate profilele
29
+- Organizare media pe data + conversie QuickTime UTC
30
+- Curatare fisiere `.glv` (util pentru Varia/Garmin)
31
+
32
+## Configurare profil
33
+
34
+Fisier configurare:
35
+
36
+`/etc/rpi-camera-importer/cameras.conf`
37
+
38
+Format linie:
39
+
40
+`name|uuid|destination_path`
41
+
42
+Exemplu:
43
+
44
+`varia_rct715|A1B2-C3D4|/srv/media/varia`
45
+
46
+## Instalare
47
+
48
+```bash
49
+sudo ./setup.sh
50
+```
51
+
52
+## Utilizare
53
+
54
+Wizard:
55
+
56
+```bash
57
+rpi-camera-importer wizard
58
+```
59
+
60
+Listare profile:
61
+
62
+```bash
63
+rpi-camera-importer list
64
+```
65
+
66
+Detectie UUID-uri conectate:
67
+
68
+```bash
69
+rpi-camera-importer discover
70
+```
71
+
72
+Import manual dupa nume profil:
73
+
74
+```bash
75
+rpi-camera-importer import --profile varia_rct715
76
+```
77
+
78
+Import manual dupa UUID:
79
+
80
+```bash
81
+rpi-camera-importer import --uuid A1B2-C3D4
82
+```
83
+
84
+Import toate profilele active:
85
+
86
+```bash
87
+rpi-camera-importer import --all
88
+```
89
+
90
+Dry-run:
91
+
92
+```bash
93
+rpi-camera-importer import --all --dry-run --verbose
94
+```
95
+
96
+## Fisiere runtime instalate
97
+
98
+- `/usr/local/bin/rpi-camera-importer`
99
+- `/usr/local/lib/rpi-camera-importer/autonas-media-importer.sh`
100
+- `/usr/local/lib/rpi-camera-importer/rpi-camera-disk-handler.sh`
101
+- `/usr/local/lib/rpi-camera-importer/rpi-camera-udev-wrapper.sh`
102
+- `/etc/udev/rules.d/99-rpi-camera-importer.rules`
103
+- `/etc/systemd/system/rpi-camera-importer-attach@.service`
+26 -0
RPI/config/99-rpi-camera-importer.rules
@@ -0,0 +1,26 @@
1
+# Raspberry Pi Camera Importer udev rules
2
+# Attach flow mirrors the proven AutoNAS trigger model (systemd attach unit + wrapper for ata bridges).
3
+
4
+# USB block devices (only removable, to exclude Pi's own rootfs)
5
+ACTION=="add", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="disk", ENV{ID_BUS}=="usb", ATTRS{removable}=="1", RUN+="/bin/systemctl start --no-block rpi-camera-importer-attach@%E{ID_FS_UUID}.service"
6
+ACTION=="remove", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="disk", ENV{ID_BUS}=="usb", ATTRS{removable}=="1", RUN+="/usr/local/lib/rpi-camera-importer/rpi-camera-disk-handler.sh detach %E{ID_FS_UUID}"
7
+ACTION=="add", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="partition", ENV{ID_BUS}=="usb", ATTRS{removable}=="1", RUN+="/bin/systemctl start --no-block rpi-camera-importer-attach@%E{ID_FS_UUID}.service"
8
+ACTION=="remove", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="partition", ENV{ID_BUS}=="usb", ATTRS{removable}=="1", RUN+="/usr/local/lib/rpi-camera-importer/rpi-camera-disk-handler.sh detach %E{ID_FS_UUID}"
9
+
10
+# ATA removable devices (via wrapper, as in AutoNAS)
11
+ACTION=="add", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="disk", ENV{ID_BUS}=="ata", ATTRS{removable}=="1", RUN+="/usr/local/lib/rpi-camera-importer/rpi-camera-udev-wrapper.sh attach %E{ID_FS_UUID}"
12
+ACTION=="remove", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="disk", ENV{ID_BUS}=="ata", ATTRS{removable}=="1", RUN+="/usr/local/lib/rpi-camera-importer/rpi-camera-disk-handler.sh detach %E{ID_FS_UUID}"
13
+ACTION=="add", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="partition", ENV{ID_BUS}=="ata", ATTRS{removable}=="1", RUN+="/usr/local/lib/rpi-camera-importer/rpi-camera-udev-wrapper.sh attach %E{ID_FS_UUID}"
14
+ACTION=="remove", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="partition", ENV{ID_BUS}=="ata", ATTRS{removable}=="1", RUN+="/usr/local/lib/rpi-camera-importer/rpi-camera-disk-handler.sh detach %E{ID_FS_UUID}"
15
+
16
+# USB-SATA bridges reported as ATA + ID_USB_TYPE=disk
17
+ACTION=="add", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="disk", ENV{ID_BUS}=="ata", ENV{ID_USB_TYPE}=="disk", RUN+="/usr/local/lib/rpi-camera-importer/rpi-camera-udev-wrapper.sh attach %E{ID_FS_UUID}"
18
+ACTION=="remove", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="disk", ENV{ID_BUS}=="ata", ENV{ID_USB_TYPE}=="disk", RUN+="/usr/local/lib/rpi-camera-importer/rpi-camera-disk-handler.sh detach %E{ID_FS_UUID}"
19
+ACTION=="add", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="partition", ENV{ID_BUS}=="ata", ENV{ID_USB_TYPE}=="disk", RUN+="/usr/local/lib/rpi-camera-importer/rpi-camera-udev-wrapper.sh attach %E{ID_FS_UUID}"
20
+ACTION=="remove", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="partition", ENV{ID_BUS}=="ata", ENV{ID_USB_TYPE}=="disk", RUN+="/usr/local/lib/rpi-camera-importer/rpi-camera-disk-handler.sh detach %E{ID_FS_UUID}"
21
+
22
+# Other removable storage
23
+ACTION=="add", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="disk", ATTRS{removable}=="1", ENV{ID_BUS}!="usb", RUN+="/bin/systemctl start --no-block rpi-camera-importer-attach@%E{ID_FS_UUID}.service"
24
+ACTION=="remove", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="disk", ATTRS{removable}=="1", ENV{ID_BUS}!="usb", RUN+="/usr/local/lib/rpi-camera-importer/rpi-camera-disk-handler.sh detach %E{ID_FS_UUID}"
25
+ACTION=="add", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="partition", ATTRS{removable}=="1", ENV{ID_BUS}!="usb", RUN+="/bin/systemctl start --no-block rpi-camera-importer-attach@%E{ID_FS_UUID}.service"
26
+ACTION=="remove", SUBSYSTEM=="block", ENV{ID_FS_UUID}!="", ENV{DEVTYPE}=="partition", ATTRS{removable}=="1", ENV{ID_BUS}!="usb", RUN+="/usr/local/lib/rpi-camera-importer/rpi-camera-disk-handler.sh detach %E{ID_FS_UUID}"
+4 -0
RPI/config/cameras.conf
@@ -0,0 +1,4 @@
1
+# Camera profiles for rpi-camera-importer
2
+# Format: name|uuid|destination_path
3
+# Example:
4
+# varia_rct715|A1B2-C3D4|/srv/media/varia
+14 -0
RPI/config/rpi-camera-importer-attach@.service
@@ -0,0 +1,14 @@
1
+[Unit]
2
+Description=Raspberry Pi Camera Importer Attach %i
3
+After=systemd-udev-settle.service
4
+
5
+[Service]
6
+Type=oneshot
7
+ExecStart=/usr/local/lib/rpi-camera-importer/rpi-camera-disk-handler.sh attach-deferred %i
8
+RemainAfterExit=no
9
+TimeoutSec=120
10
+StandardOutput=journal
11
+StandardError=journal
12
+
13
+[Install]
14
+WantedBy=multi-user.target
+438 -0
RPI/scripts/autonas-media-importer.sh
@@ -0,0 +1,438 @@
1
+#!/bin/bash
2
+
3
+# AutoNAS Media Importer
4
+# Advanced media import engine that processes, organizes and imports media files from cameras
5
+# Usage: autonas-media-importer.sh <source_mount> <destination_path>
6
+
7
+# Global configuration
8
+LOG_TAG="autonas-import"
9
+
10
+# Function to log messages
11
+log_message() {
12
+    local message="$1"
13
+    local priority="${2:-info}"  # Default priority is info
14
+    
15
+    # Log to syslog with facility local0 and specified priority
16
+    logger -p "local0.$priority" -t "$LOG_TAG" "$message"
17
+    
18
+    # Also echo to stdout/stderr for interactive use
19
+    if [ -t 1 ]; then
20
+        echo "$(date '+%Y-%m-%d %H:%M:%S') - $message"
21
+    fi
22
+}
23
+
24
+# Usage function
25
+usage() {
26
+    echo "Usage: $0 <source_mount> <destination_path> [options]"
27
+    echo ""
28
+    echo "Arguments:"
29
+    echo "  source_mount     - Mount point of the camera (e.g., /mnt/autonas/camera)"
30
+    echo "  destination_path - Destination directory for imported files"
31
+    echo ""
32
+    echo "Options:"
33
+    echo "  --dry-run        - Show what would be done without actually doing it"
34
+    echo "  --keep-originals - Keep original files on camera after import"
35
+    echo "  --verbose        - Enable verbose output"
36
+    echo "  --limit N        - Process only N files (useful for testing)"
37
+    echo "  --help           - Show this help"
38
+    echo ""
39
+    echo "Examples:"
40
+    echo "  $0 /mnt/autonas/camera /mnt/autonas/photos/imported"
41
+    echo "  $0 /mnt/autonas/camera /mnt/autonas/photos/imported --dry-run --verbose"
42
+}
43
+
44
+# Parse command line arguments
45
+SOURCE_MOUNT=""
46
+DESTINATION=""
47
+DRY_RUN=0
48
+KEEP_ORIGINALS=0
49
+VERBOSE=0
50
+FILE_LIMIT=0
51
+
52
+while [[ $# -gt 0 ]]; do
53
+    case $1 in
54
+        --dry-run)
55
+            DRY_RUN=1
56
+            shift
57
+            ;;
58
+        --keep-originals)
59
+            KEEP_ORIGINALS=1
60
+            shift
61
+            ;;
62
+        --verbose)
63
+            VERBOSE=1
64
+            shift
65
+            ;;
66
+        --limit)
67
+            FILE_LIMIT="$2"
68
+            if ! [[ "$FILE_LIMIT" =~ ^[0-9]+$ ]]; then
69
+                echo "Error: --limit requires a number"
70
+                usage
71
+                exit 1
72
+            fi
73
+            shift 2
74
+            ;;
75
+        --help)
76
+            usage
77
+            exit 0
78
+            ;;
79
+        -*)
80
+            echo "Unknown option: $1"
81
+            usage
82
+            exit 1
83
+            ;;
84
+        *)
85
+            if [[ -z "$SOURCE_MOUNT" ]]; then
86
+                SOURCE_MOUNT="$1"
87
+            elif [[ -z "$DESTINATION" ]]; then
88
+                DESTINATION="$1"
89
+            else
90
+                echo "Too many arguments"
91
+                usage
92
+                exit 1
93
+            fi
94
+            shift
95
+            ;;
96
+    esac
97
+done
98
+
99
+# Validate arguments
100
+if [[ -z "$SOURCE_MOUNT" || -z "$DESTINATION" ]]; then
101
+    echo "Error: Both source_mount and destination_path are required"
102
+    usage
103
+    exit 1
104
+fi
105
+
106
+# Check if source exists and is mounted
107
+if [[ ! -d "$SOURCE_MOUNT" ]]; then
108
+    log_message "Error: Source mount point does not exist: $SOURCE_MOUNT" "err"
109
+    exit 1
110
+fi
111
+
112
+# Check if source is actually mounted
113
+if ! mountpoint -q "$SOURCE_MOUNT" 2>/dev/null; then
114
+    log_message "Warning: Source path is not a mount point: $SOURCE_MOUNT" "warning"
115
+fi
116
+
117
+# Create destination directory if it doesn't exist
118
+if [[ $DRY_RUN -eq 0 ]]; then
119
+    mkdir -p "$DESTINATION"
120
+    if [[ ! -d "$DESTINATION" ]]; then
121
+        log_message "Error: Cannot create destination directory: $DESTINATION" "err"
122
+        exit 1
123
+    fi
124
+fi
125
+
126
+# Check for required tools
127
+if ! command -v exiftool &> /dev/null; then
128
+    log_message "Error: exiftool is required but not installed" "err"
129
+    exit 1
130
+fi
131
+
132
+# Function to process a single file
133
+process_file() {
134
+    local file="$1"
135
+    local relative_path="${file#$SOURCE_MOUNT/}"
136
+    
137
+    # Check if source mount is still available
138
+    if ! mountpoint -q "$SOURCE_MOUNT" 2>/dev/null; then
139
+        log_message "Error: Source mount point is no longer available: $SOURCE_MOUNT" "err"
140
+        return 1
141
+    fi
142
+    
143
+    # Check if file still exists
144
+    if [[ ! -f "$file" ]]; then
145
+        log_message "Error: File no longer exists: $relative_path" "err"
146
+        log_message "Camera appears to be disconnected, stopping import" "warning"
147
+        exit 1
148
+    fi
149
+    
150
+    if [[ $VERBOSE -eq 1 ]]; then
151
+        log_message "Processing: $relative_path" "info"
152
+    fi
153
+    
154
+    # Check which group CreateDate comes from to determine correct handling
155
+    create_date_info=$(exiftool -G1 -s -CreateDate "$file" 2>/dev/null | grep CreateDate | head -1)
156
+    
157
+    # Check if exiftool failed (possible if device disconnected)
158
+    local exiftool_exit_code=$?
159
+    if [[ $exiftool_exit_code -ne 0 ]] && [[ $exiftool_exit_code -ne 1 ]]; then
160
+        log_message "Error: Cannot read file (device may be disconnected): $relative_path" "err"
161
+        log_message "Camera appears to be disconnected, stopping import" "warning"
162
+        exit 1
163
+    fi
164
+    
165
+    if [[ -z "$create_date_info" ]]; then
166
+        log_message "Warning: No CreateDate found in $relative_path, using file modification time" "warning"
167
+        # Fallback to file modification time
168
+        local file_date=$(date -r "$file" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
169
+        if [[ -n "$file_date" ]]; then
170
+            create_date_value="$file_date"
171
+            create_date_group="FileSystem"
172
+        else
173
+            log_message "Error: Cannot determine date for $relative_path" "err"
174
+            return 1
175
+        fi
176
+    else
177
+        create_date_group=$(echo "$create_date_info" | cut -d']' -f1 | cut -d'[' -f2)
178
+        create_date_value=$(echo "$create_date_info" | cut -d':' -f2- | xargs)
179
+        # Convert EXIF date format (YYYY:MM:DD HH:MM:SS) to standard format (YYYY-MM-DD HH:MM:SS)
180
+        create_date_value=$(echo "$create_date_value" | sed 's/^\([0-9]\{4\}\):\([0-9]\{2\}\):\([0-9]\{2\}\)/\1-\2-\3/')
181
+    fi
182
+    
183
+    if [[ $VERBOSE -eq 1 ]]; then
184
+        echo -n "  Date: [$create_date_value] from $create_date_group "
185
+    fi
186
+    
187
+    # Extract file extension
188
+    local filename=$(basename "$file")
189
+    local extension="${filename##*.}"
190
+    
191
+    # For QuickTime files, the CreateDate is in UTC and needs conversion to local time
192
+    if [[ "$create_date_group" == "QuickTime" ]]; then
193
+        # Convert UTC time to local time
194
+        local utc_timestamp=$(date -d "$create_date_value UTC" "+%s" 2>/dev/null)
195
+        if [[ -n "$utc_timestamp" ]]; then
196
+            create_date_value=$(date -d "@$utc_timestamp" "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
197
+            if [[ $VERBOSE -eq 1 ]]; then
198
+                echo -n "(converted from UTC) "
199
+            fi
200
+        fi
201
+    fi
202
+    
203
+    # Create output directory structure
204
+    local date_dir=$(date -d "$create_date_value" "+%Y-%m-%d" 2>/dev/null)
205
+    if [[ -z "$date_dir" ]]; then
206
+        log_message "Error: Invalid date format for $relative_path: $create_date_value" "err"
207
+        return 1
208
+    fi
209
+    
210
+    local output_dir="$DESTINATION/$date_dir"
211
+    
212
+    if [[ $DRY_RUN -eq 0 ]]; then
213
+        mkdir -p "$output_dir"
214
+    fi
215
+    
216
+    # Generate output filename with timestamp
217
+    local timestamp=$(date -d "$create_date_value" "+%Y-%m-%d_%H-%M-%S" 2>/dev/null)
218
+    local output_filename="${timestamp}.${extension,,}"  # Convert extension to lowercase
219
+    local output_path="$output_dir/$output_filename"
220
+    
221
+    # Handle filename conflicts
222
+    local counter=1
223
+    local base_output_path="$output_path"
224
+    while [[ -f "$output_path" ]] && [[ $DRY_RUN -eq 0 ]]; do
225
+        local name_without_ext="${timestamp}_${counter}"
226
+        output_path="$output_dir/${name_without_ext}.${extension,,}"
227
+        counter=$((counter + 1))
228
+    done
229
+    
230
+    if [[ $DRY_RUN -eq 1 ]]; then
231
+        if [[ $KEEP_ORIGINALS -eq 1 ]]; then
232
+            echo "Would copy: $relative_path -> ${output_path#$DESTINATION/}"
233
+        else
234
+            echo "Would move: $relative_path -> ${output_path#$DESTINATION/}"
235
+        fi
236
+    else
237
+        # Perform the actual file operation
238
+        if [[ $KEEP_ORIGINALS -eq 1 ]]; then
239
+            if cp "$file" "$output_path"; then
240
+                if [[ $VERBOSE -eq 1 ]]; then
241
+                    echo "✓ Copied"
242
+                fi
243
+                log_message "Copied: $relative_path -> ${output_path#$DESTINATION/}" "info"
244
+                return 0
245
+            else
246
+                if [[ $VERBOSE -eq 1 ]]; then
247
+                    echo "✗ Copy failed"
248
+                fi
249
+                log_message "Error: Failed to copy $relative_path" "err"
250
+                return 1
251
+            fi
252
+        else
253
+            if mv "$file" "$output_path"; then
254
+                if [[ $VERBOSE -eq 1 ]]; then
255
+                    echo "✓ Moved"
256
+                fi
257
+                log_message "Moved: $relative_path -> ${output_path#$DESTINATION/}" "info"
258
+                return 0
259
+            else
260
+                if [[ $VERBOSE -eq 1 ]]; then
261
+                    echo "✗ Move failed"
262
+                fi
263
+                log_message "Error: Failed to move $relative_path" "err"
264
+                return 1
265
+            fi
266
+        fi
267
+    fi
268
+}
269
+
270
+# Function to find camera directories
271
+find_camera_directories() {
272
+    local search_patterns=("DCIM" "PRIVATE" "MP_ROOT" "AVCHD" "Photos" "Videos")
273
+    local found_dirs=()
274
+    
275
+    # Test if the mount point is accessible with a timeout
276
+    if ! timeout 3 ls "$SOURCE_MOUNT" >/dev/null 2>&1; then
277
+        log_message "Error: Mount point is not accessible (device likely disconnected): $SOURCE_MOUNT" "err"
278
+        exit 1
279
+    fi
280
+    
281
+    for pattern in "${search_patterns[@]}"; do
282
+        while IFS= read -r -d '' dir; do
283
+            found_dirs+=("$dir")
284
+        done < <(timeout 5 find "$SOURCE_MOUNT" -maxdepth 3 -type d -iname "$pattern" -print0 2>/dev/null)
285
+    done
286
+    
287
+    # If no camera directories found, search for common media file extensions
288
+    if [[ ${#found_dirs[@]} -eq 0 ]]; then
289
+        log_message "No camera directories found, searching for media files..." "info"
290
+        local media_extensions=("*.jpg" "*.jpeg" "*.png" "*.tiff" "*.cr2" "*.nef" "*.arw" "*.mp4" "*.mov" "*.avi" "*.mts" "*.m2ts")
291
+        
292
+        for ext in "${media_extensions[@]}"; do
293
+            while IFS= read -r -d '' file; do
294
+                local dir=$(dirname "$file")
295
+                if [[ ! " ${found_dirs[@]} " =~ " ${dir} " ]]; then
296
+                    found_dirs+=("$dir")
297
+                fi
298
+            done < <(timeout 5 find "$SOURCE_MOUNT" -maxdepth 3 -type f -iname "$ext" -print0 2>/dev/null)
299
+        done
300
+    fi
301
+    
302
+    printf '%s\n' "${found_dirs[@]}" | sort -u
303
+}
304
+
305
+# Main execution
306
+log_message "Starting camera import from $SOURCE_MOUNT to $DESTINATION" "info"
307
+
308
+if [[ $DRY_RUN -eq 1 ]]; then
309
+    log_message "DRY RUN MODE - No files will be actually moved/copied" "info"
310
+fi
311
+
312
+if [[ $KEEP_ORIGINALS -eq 1 ]]; then
313
+    log_message "KEEP ORIGINALS MODE - Files will be copied instead of moved" "info"
314
+fi
315
+
316
+# Find camera directories
317
+log_message "Scanning for camera directories..." "info"
318
+camera_dirs=$(find_camera_directories)
319
+
320
+if [[ -z "$camera_dirs" ]]; then
321
+    log_message "No camera directories or media files found in $SOURCE_MOUNT" "warning"
322
+    exit 0
323
+fi
324
+
325
+echo "Found camera directories:"
326
+echo "$camera_dirs" | while IFS= read -r dir; do
327
+    echo "  $dir"
328
+done
329
+
330
+# Process files
331
+total_files=0
332
+processed_files=0
333
+error_files=0
334
+
335
+# Delete GLV files (Garmin video preview files) - they're usually not needed
336
+log_message "Cleaning up GLV preview files..." "info"
337
+glv_count=0
338
+while IFS= read -r dir; do
339
+    if [[ $DRY_RUN -eq 1 ]]; then
340
+        glv_files=$(find "$dir" -type f -iname "*.glv" 2>/dev/null | wc -l)
341
+        if [[ $glv_files -gt 0 ]]; then
342
+            echo "Would delete $glv_files GLV files from $dir"
343
+            glv_count=$((glv_count + glv_files))
344
+        fi
345
+    else
346
+        while IFS= read -r -d '' glv_file; do
347
+            if rm "$glv_file" 2>/dev/null; then
348
+                glv_count=$((glv_count + 1))
349
+            fi
350
+        done < <(find "$dir" -type f -iname "*.glv" -print0 2>/dev/null)
351
+    fi
352
+done <<< "$camera_dirs"
353
+
354
+if [[ $glv_count -gt 0 ]]; then
355
+    if [[ $DRY_RUN -eq 1 ]]; then
356
+        log_message "Would delete $glv_count GLV preview files" "info"
357
+    else
358
+        log_message "Deleted $glv_count GLV preview files" "info"
359
+    fi
360
+fi
361
+
362
+# Process media files
363
+log_message "Processing media files..." "info"
364
+media_extensions=("*.jpg" "*.jpeg" "*.png" "*.tiff" "*.tif" "*.cr2" "*.nef" "*.arw" "*.dng" "*.mp4" "*.mov" "*.avi" "*.mts" "*.m2ts" "*.mkv" "*.wmv")
365
+
366
+# In dry-run mode, limit output to avoid overwhelming logs
367
+max_files_to_show=20
368
+files_shown=0
369
+files_processed_count=0
370
+
371
+while IFS= read -r dir; do
372
+    # Check if mount point is still available before processing each directory
373
+    if ! mountpoint -q "$SOURCE_MOUNT" 2>/dev/null; then
374
+        log_message "Camera disconnected, stopping import process" "warning"
375
+        break
376
+    fi
377
+    
378
+    for ext in "${media_extensions[@]}"; do
379
+        while IFS= read -r -d '' file; do
380
+            total_files=$((total_files + 1))
381
+            files_processed_count=$((files_processed_count + 1))
382
+            
383
+            # Check if camera is still connected before processing each file
384
+            if ! mountpoint -q "$SOURCE_MOUNT" 2>/dev/null; then
385
+                log_message "Camera disconnected during processing, stopping import" "warning"
386
+                exit 1
387
+            fi
388
+            
389
+            # Check file limit
390
+            if [[ $FILE_LIMIT -gt 0 && $files_processed_count -gt $FILE_LIMIT ]]; then
391
+                echo "Reached file limit of $FILE_LIMIT files, stopping processing..."
392
+                break 3
393
+            fi
394
+            
395
+            # In dry-run mode, limit verbose output
396
+            if [[ $DRY_RUN -eq 1 && $files_shown -ge $max_files_to_show ]]; then
397
+                if [[ $files_shown -eq $max_files_to_show ]]; then
398
+                    echo "... (limiting output in dry-run mode, processing continues)"
399
+                    files_shown=$((files_shown + 1))
400
+                fi
401
+                # Still process but don't show details
402
+                processed_files=$((processed_files + 1))
403
+            else
404
+                if [[ $DRY_RUN -eq 1 ]]; then
405
+                    files_shown=$((files_shown + 1))
406
+                fi
407
+                
408
+                if process_file "$file"; then
409
+                    processed_files=$((processed_files + 1))
410
+                else
411
+                    error_files=$((error_files + 1))
412
+                fi
413
+            fi
414
+        done < <(find "$dir" -type f -iname "$ext" -print0 2>/dev/null)
415
+    done
416
+done <<< "$camera_dirs"
417
+
418
+# Summary
419
+log_message "Import completed: $processed_files/$total_files files processed successfully" "info"
420
+if [[ $error_files -gt 0 ]]; then
421
+    log_message "Import had errors: $error_files files failed to process" "warning"
422
+fi
423
+
424
+echo ""
425
+echo "=== Import Summary ==="
426
+echo "Total files found: $total_files"
427
+echo "Successfully processed: $processed_files"
428
+echo "Errors: $error_files"
429
+if [[ $glv_count -gt 0 ]]; then
430
+    echo "GLV files cleaned up: $glv_count"
431
+fi
432
+
433
+# Exit with error code if there were errors
434
+if [[ $error_files -gt 0 ]]; then
435
+    exit 1
436
+else
437
+    exit 0
438
+fi
+59 -0
RPI/scripts/rpi-camera-disk-handler.sh
@@ -0,0 +1,59 @@
1
+#!/usr/bin/env bash
2
+
3
+set -euo pipefail
4
+
5
+LOG_TAG="rpi-camera-disk-handler"
6
+APP_BIN="/usr/local/bin/rpi-camera-importer"
7
+
8
+log_message() {
9
+  local message="$1"
10
+  local priority="${2:-info}"
11
+  logger -p "local0.${priority}" -t "$LOG_TAG" "$message"
12
+}
13
+
14
+usage() {
15
+  echo "Usage: $0 {attach-deferred|detach} <UUID>"
16
+}
17
+
18
+action="${1:-}"
19
+uuid="${2:-}"
20
+attach_dry_run="${RPI_CAMERA_IMPORTER_ATTACH_DRY_RUN:-0}"
21
+
22
+if [[ -z "$action" || -z "$uuid" ]]; then
23
+  log_message "Invalid arguments: action='$action' uuid='$uuid'" "err"
24
+  usage
25
+  exit 1
26
+fi
27
+
28
+case "$action" in
29
+  attach-deferred)
30
+    log_message "Attach deferred triggered for UUID=$uuid"
31
+    if [[ ! -x "$APP_BIN" ]]; then
32
+      log_message "CLI not found: $APP_BIN" "err"
33
+      exit 1
34
+    fi
35
+
36
+    # Run import only for configured UUID; unconfigured devices exit cleanly.
37
+    import_args=(import --uuid "$uuid")
38
+    if [[ "$attach_dry_run" == "1" ]]; then
39
+      import_args+=(--dry-run --verbose)
40
+      log_message "Attach handler running in dry-run mode (RPI_CAMERA_IMPORTER_ATTACH_DRY_RUN=1)"
41
+    fi
42
+
43
+    if ! "$APP_BIN" "${import_args[@]}"; then
44
+      log_message "Import flow failed for UUID=$uuid" "err"
45
+      exit 1
46
+    fi
47
+    ;;
48
+  detach)
49
+    # No detach-side action required; importer handles mount/unmount around imports.
50
+    log_message "Detach event for UUID=$uuid (no-op)"
51
+    ;;
52
+  *)
53
+    log_message "Unsupported action: $action" "err"
54
+    usage
55
+    exit 1
56
+    ;;
57
+esac
58
+
59
+exit 0
+676 -0
RPI/scripts/rpi-camera-importer.sh
@@ -0,0 +1,676 @@
1
+#!/usr/bin/env bash
2
+
3
+set -euo pipefail
4
+
5
+VERSION="0.2.0"
6
+APP_NAME="rpi-camera-importer"
7
+PROJECT_DIR="/usr/local/lib/rpi-camera-importer"
8
+MEDIA_IMPORTER_SCRIPT="${PROJECT_DIR}/autonas-media-importer.sh"
9
+CONFIG_DIR="${RPI_CAMERA_IMPORTER_CONFIG_DIR:-/etc/rpi-camera-importer}"
10
+CONFIG_FILE="${CONFIG_DIR}/cameras.conf"
11
+MOUNT_BASE="${RPI_CAMERA_IMPORTER_MOUNT_BASE:-/mnt/rpi-camera-importer}"
12
+
13
+DRY_RUN=0
14
+VERBOSE=0
15
+
16
+log_message() {
17
+  local level="$1"
18
+  local message="$2"
19
+  local ts
20
+  ts="$(date '+%Y-%m-%d %H:%M:%S')"
21
+  echo "[$ts] [$level] $message"
22
+}
23
+
24
+usage() {
25
+  cat <<EOF
26
+${APP_NAME} v${VERSION}
27
+
28
+Usage:
29
+  ${APP_NAME} wizard
30
+  ${APP_NAME} list
31
+  ${APP_NAME} discover
32
+  ${APP_NAME} verify
33
+  ${APP_NAME} update --profile <name> --destination <path>
34
+  ${APP_NAME} import --profile <name> [--dry-run] [--verbose]
35
+  ${APP_NAME} import --uuid <uuid> [--dry-run] [--verbose]
36
+  ${APP_NAME} import --all [--dry-run] [--verbose]
37
+
38
+Config format:
39
+  name|uuid|destination_path
40
+
41
+Notes:
42
+  - Import flow is device-trigger compatible (udev + systemd)
43
+  - Media importer uses the proven AutoNAS importer script unchanged
44
+EOF
45
+}
46
+
47
+require_root() {
48
+  if [[ "${EUID}" -ne 0 ]]; then
49
+    log_message "ERROR" "Run as root"
50
+    exit 1
51
+  fi
52
+}
53
+
54
+require_dependency() {
55
+  local bin="$1"
56
+  if ! command -v "$bin" >/dev/null 2>&1; then
57
+    log_message "ERROR" "Missing dependency: $bin"
58
+    exit 1
59
+  fi
60
+}
61
+
62
+suggest_package_for_bin() {
63
+  local bin="$1"
64
+  case "$bin" in
65
+    exiftool) echo "libimage-exiftool-perl" ;;
66
+    blkid|mount|umount|mountpoint) echo "util-linux" ;;
67
+    find) echo "findutils" ;;
68
+    awk) echo "mawk" ;;
69
+    sed|grep) echo "sed grep" ;;
70
+    logger) echo "bsdutils" ;;
71
+    *) echo "" ;;
72
+  esac
73
+}
74
+
75
+ensure_runtime_dependencies() {
76
+  local missing_bins=()
77
+  local packages=()
78
+  local dep pkg
79
+
80
+  for dep in "$@"; do
81
+    if ! command -v "$dep" >/dev/null 2>&1; then
82
+      missing_bins+=("$dep")
83
+      pkg="$(suggest_package_for_bin "$dep")"
84
+      if [[ -n "$pkg" ]]; then
85
+        for p in $pkg; do
86
+          packages+=("$p")
87
+        done
88
+      fi
89
+    fi
90
+  done
91
+
92
+  if [[ ${#missing_bins[@]} -eq 0 ]]; then
93
+    return 0
94
+  fi
95
+
96
+  log_message "ERROR" "Missing runtime dependencies: ${missing_bins[*]}"
97
+
98
+  if [[ ${#packages[@]} -gt 0 ]]; then
99
+    local unique_packages
100
+    unique_packages="$(printf '%s\n' "${packages[@]}" | awk '!seen[$0]++' | xargs)"
101
+    log_message "ERROR" "Suggested install command: apt update && apt install -y ${unique_packages}"
102
+
103
+    if [[ -t 0 && -t 1 ]] && command -v apt >/dev/null 2>&1; then
104
+      local answer
105
+      read -r -p "Install missing dependencies now? [y/N]: " answer
106
+      if [[ "${answer,,}" == "y" || "${answer,,}" == "yes" ]]; then
107
+        apt update
108
+        apt install -y ${unique_packages}
109
+      fi
110
+    fi
111
+  fi
112
+
113
+  for dep in "$@"; do
114
+    if ! command -v "$dep" >/dev/null 2>&1; then
115
+      log_message "ERROR" "Dependency still missing after check: $dep"
116
+      return 1
117
+    fi
118
+  done
119
+
120
+  return 0
121
+}
122
+
123
+ensure_config_file() {
124
+  mkdir -p "$CONFIG_DIR"
125
+  if [[ ! -f "$CONFIG_FILE" ]]; then
126
+    cat > "$CONFIG_FILE" <<'EOF'
127
+# Camera profiles
128
+# Format: name|uuid|destination_path
129
+# Example: varia_rct715|A1B2-C3D4|/srv/media/varia
130
+EOF
131
+  fi
132
+
133
+  # Normalize legacy entries to the current 3-field profile schema.
134
+  : > "${CONFIG_FILE}.tmp"
135
+  local line
136
+  while IFS= read -r line; do
137
+    if [[ -z "$line" || "$line" == \#* ]]; then
138
+      echo "$line" >> "${CONFIG_FILE}.tmp"
139
+      continue
140
+    fi
141
+
142
+    IFS='|' read -r name uuid destination _ <<< "$line"
143
+    if [[ -n "$name" && -n "$uuid" && -n "$destination" ]]; then
144
+      echo "${name}|${uuid}|${destination}" >> "${CONFIG_FILE}.tmp"
145
+    fi
146
+  done < "$CONFIG_FILE"
147
+  mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE"
148
+}
149
+
150
+sanitize_profile_name() {
151
+  local v="$1"
152
+  echo "$v" | tr -cd '[:alnum:]_-' | xargs
153
+}
154
+
155
+profile_exists_by_name() {
156
+  local profile="$1"
157
+  grep -E "^${profile//./\\.}\|" "$CONFIG_FILE" >/dev/null 2>&1
158
+}
159
+
160
+profile_exists_by_uuid() {
161
+  local uuid="$1"
162
+  grep -E "^[^|]+\|${uuid//./\\.}\|" "$CONFIG_FILE" >/dev/null 2>&1
163
+}
164
+
165
+find_profile_line() {
166
+  local mode="$1"
167
+  local target="$2"
168
+  local line
169
+
170
+  while IFS= read -r line; do
171
+    [[ -z "$line" || "$line" == \#* ]] && continue
172
+    IFS='|' read -r name uuid destination _ <<< "$line"
173
+
174
+    case "$mode" in
175
+      name)
176
+        if [[ "$name" == "$target" ]]; then
177
+          echo "$line"
178
+          return 0
179
+        fi
180
+        ;;
181
+      uuid)
182
+        if [[ "$uuid" == "$target" ]]; then
183
+          echo "$line"
184
+          return 0
185
+        fi
186
+        ;;
187
+      *)
188
+        return 1
189
+        ;;
190
+    esac
191
+  done < "$CONFIG_FILE"
192
+
193
+  return 1
194
+}
195
+
196
+list_profiles() {
197
+  ensure_config_file
198
+  echo ""
199
+  echo "Configured camera profiles"
200
+  echo "--------------------------------------------------------------------------"
201
+  printf "%-20s %-16s %-30s\n" "Name" "UUID" "Destination"
202
+  echo "--------------------------------------------------------------------------"
203
+
204
+  local line
205
+  while IFS= read -r line; do
206
+    [[ -z "$line" || "$line" == \#* ]] && continue
207
+    IFS='|' read -r name uuid destination _ <<< "$line"
208
+    printf "%-20s %-16s %-30s\n" "$name" "$uuid" "$destination"
209
+  done < "$CONFIG_FILE"
210
+
211
+  echo "--------------------------------------------------------------------------"
212
+}
213
+
214
+discover_devices() {
215
+  ensure_runtime_dependencies "blkid" || return 1
216
+  echo ""
217
+  echo "Detected removable/storage block devices"
218
+  echo "---------------------------------------------------------------"
219
+  blkid -o export 2>/dev/null | awk '
220
+    /^DEVNAME=/ {dev=$0; sub(/^DEVNAME=/, "", dev)}
221
+    /^UUID=/ {uuid=$0; sub(/^UUID=/, "", uuid)}
222
+    /^TYPE=/ {type=$0; sub(/^TYPE=/, "", type)}
223
+    /^$/ {
224
+      if (dev != "" && uuid != "") {
225
+        printf "%s | UUID=%s | FS=%s\n", dev, uuid, type
226
+      }
227
+      dev=""; uuid=""; type=""
228
+    }
229
+    END {
230
+      if (dev != "" && uuid != "") {
231
+        printf "%s | UUID=%s | FS=%s\n", dev, uuid, type
232
+      }
233
+    }
234
+  '
235
+  echo "---------------------------------------------------------------"
236
+}
237
+
238
+add_profile_interactive() {
239
+  ensure_config_file
240
+
241
+  echo ""
242
+  echo "Tip: conecteaza camera si ruleaza optional 'discover' intr-un alt terminal."
243
+
244
+  local name uuid destination
245
+  read -r -p "Profile name (ex: varia_rct715): " name
246
+  name="$(sanitize_profile_name "$name")"
247
+  if [[ -z "$name" ]]; then
248
+    echo "Invalid profile name"
249
+    return
250
+  fi
251
+
252
+  if profile_exists_by_name "$name"; then
253
+    echo "Profile already exists"
254
+    return
255
+  fi
256
+
257
+  read -r -p "Camera UUID (ID_FS_UUID): " uuid
258
+  if [[ -z "$uuid" ]]; then
259
+    echo "UUID is required"
260
+    return
261
+  fi
262
+
263
+  if profile_exists_by_uuid "$uuid"; then
264
+    echo "UUID already configured"
265
+    return
266
+  fi
267
+
268
+  read -r -p "Destination path (ex: /srv/media/varia): " destination
269
+  if [[ -z "$destination" ]]; then
270
+    echo "Destination is required"
271
+    return
272
+  fi
273
+
274
+  echo "${name}|${uuid}|${destination}" >> "$CONFIG_FILE"
275
+  echo "Profile '$name' added"
276
+}
277
+
278
+remove_profile_interactive() {
279
+  ensure_config_file
280
+  local name
281
+  read -r -p "Profile name to remove: " name
282
+  name="$(sanitize_profile_name "$name")"
283
+
284
+  if ! profile_exists_by_name "$name"; then
285
+    echo "Profile not found"
286
+    return
287
+  fi
288
+
289
+  grep -v -E "^${name//./\\.}\|" "$CONFIG_FILE" > "${CONFIG_FILE}.tmp"
290
+  mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE"
291
+  echo "Profile '$name' removed"
292
+}
293
+
294
+update_profile_values() {
295
+  local target_name="$1"
296
+  local new_destination="$2"
297
+
298
+  if ! profile_exists_by_name "$target_name"; then
299
+    log_message "ERROR" "Profile '$target_name' not found"
300
+    return 1
301
+  fi
302
+
303
+  : > "${CONFIG_FILE}.tmp"
304
+  local line
305
+  local changed=0
306
+  while IFS= read -r line; do
307
+    if [[ -z "$line" || "$line" == \#* ]]; then
308
+      echo "$line" >> "${CONFIG_FILE}.tmp"
309
+      continue
310
+    fi
311
+
312
+    IFS='|' read -r name uuid destination _ <<< "$line"
313
+    if [[ "$name" == "$target_name" ]]; then
314
+      [[ -n "$new_destination" ]] && destination="$new_destination"
315
+      changed=1
316
+    fi
317
+
318
+    echo "${name}|${uuid}|${destination}" >> "${CONFIG_FILE}.tmp"
319
+  done < "$CONFIG_FILE"
320
+
321
+  mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE"
322
+
323
+  if [[ $changed -eq 1 ]]; then
324
+    log_message "INFO" "Profile '$target_name' updated"
325
+  fi
326
+}
327
+
328
+update_profile_interactive() {
329
+  ensure_config_file
330
+
331
+  local name
332
+  read -r -p "Profile name to update: " name
333
+  name="$(sanitize_profile_name "$name")"
334
+
335
+  local line
336
+  if ! line="$(find_profile_line "name" "$name")"; then
337
+    echo "Profile not found"
338
+    return 1
339
+  fi
340
+
341
+  local current_name current_uuid current_destination
342
+  IFS='|' read -r current_name current_uuid current_destination _ <<< "$line"
343
+
344
+  echo "Current UUID: $current_uuid"
345
+  echo "Current destination: $current_destination"
346
+
347
+  local new_destination
348
+  read -r -p "New destination path (leave blank to keep current): " new_destination
349
+
350
+  update_profile_values "$name" "$new_destination"
351
+}
352
+
353
+run_import_for_entry() {
354
+  local name="$1"
355
+  local uuid="$2"
356
+  local destination="$3"
357
+
358
+  local mount_point="${MOUNT_BASE}/${name}"
359
+  local device_path="/dev/disk/by-uuid/${uuid}"
360
+
361
+  if [[ ! -x "$MEDIA_IMPORTER_SCRIPT" ]]; then
362
+    log_message "ERROR" "Media importer missing: $MEDIA_IMPORTER_SCRIPT"
363
+    return 1
364
+  fi
365
+
366
+  if [[ ! -e "$device_path" ]]; then
367
+    log_message "WARN" "Device for UUID $uuid not present, skipping profile '$name'"
368
+    return 0
369
+  fi
370
+
371
+  mkdir -p "$mount_point" "$destination"
372
+
373
+  local mounted_here=0
374
+  if ! mountpoint -q "$mount_point" 2>/dev/null; then
375
+    if [[ $DRY_RUN -eq 1 ]]; then
376
+      log_message "INFO" "[dry-run] would mount $device_path at $mount_point"
377
+    else
378
+      mount "$device_path" "$mount_point"
379
+      mounted_here=1
380
+    fi
381
+  fi
382
+
383
+  local args=("$mount_point" "$destination")
384
+  if [[ $DRY_RUN -eq 1 ]]; then
385
+    args+=("--dry-run")
386
+  fi
387
+  if [[ $VERBOSE -eq 1 ]]; then
388
+    args+=("--verbose")
389
+  fi
390
+
391
+  log_message "INFO" "Import start for '$name' (uuid=$uuid)"
392
+  if [[ $DRY_RUN -eq 1 ]]; then
393
+    "$MEDIA_IMPORTER_SCRIPT" "${args[@]}" || true
394
+  else
395
+    "$MEDIA_IMPORTER_SCRIPT" "${args[@]}"
396
+  fi
397
+  local importer_rc=$?
398
+
399
+  if [[ $mounted_here -eq 1 ]]; then
400
+    sync || true
401
+    umount "$mount_point" || log_message "WARN" "Could not unmount $mount_point"
402
+  fi
403
+
404
+  if [[ $importer_rc -ne 0 ]]; then
405
+    log_message "ERROR" "Import failed for '$name'"
406
+    return 1
407
+  fi
408
+
409
+  log_message "INFO" "Import finished for '$name'"
410
+  return 0
411
+}
412
+
413
+import_profile_by_name() {
414
+  ensure_config_file
415
+  local target="$1"
416
+  local line
417
+  if ! line="$(find_profile_line "name" "$target")"; then
418
+    log_message "ERROR" "Profile '$target' not found"
419
+    return 1
420
+  fi
421
+
422
+  local name uuid destination
423
+  IFS='|' read -r name uuid destination _ <<< "$line"
424
+  run_import_for_entry "$name" "$uuid" "$destination"
425
+}
426
+
427
+import_profile_by_uuid() {
428
+  ensure_config_file
429
+  local target_uuid="$1"
430
+
431
+  local line
432
+  if ! line="$(find_profile_line "uuid" "$target_uuid")"; then
433
+    log_message "INFO" "UUID '$target_uuid' is not configured"
434
+    return 0
435
+  fi
436
+
437
+  local name uuid destination
438
+  IFS='|' read -r name uuid destination _ <<< "$line"
439
+  run_import_for_entry "$name" "$uuid" "$destination"
440
+}
441
+
442
+verify_pipeline() {
443
+  ensure_config_file
444
+
445
+  local rc=0
446
+  local deps=(mount umount mountpoint exiftool blkid find awk sed grep logger)
447
+  local dep
448
+
449
+  log_message "INFO" "Verifying dependencies"
450
+  for dep in "${deps[@]}"; do
451
+    if command -v "$dep" >/dev/null 2>&1; then
452
+      log_message "INFO" "OK dependency: $dep"
453
+    else
454
+      log_message "ERROR" "Missing dependency: $dep"
455
+      rc=1
456
+    fi
457
+  done
458
+
459
+  local paths=(
460
+    "$MEDIA_IMPORTER_SCRIPT"
461
+    "/usr/local/lib/rpi-camera-importer/rpi-camera-disk-handler.sh"
462
+    "/usr/local/lib/rpi-camera-importer/rpi-camera-udev-wrapper.sh"
463
+    "/etc/udev/rules.d/99-rpi-camera-importer.rules"
464
+    "/etc/systemd/system/rpi-camera-importer-attach@.service"
465
+  )
466
+  local p
467
+
468
+  log_message "INFO" "Verifying runtime files"
469
+  for p in "${paths[@]}"; do
470
+    if [[ -e "$p" ]]; then
471
+      log_message "INFO" "OK file: $p"
472
+    else
473
+      log_message "ERROR" "Missing file: $p"
474
+      rc=1
475
+    fi
476
+  done
477
+
478
+  if command -v systemctl >/dev/null 2>&1; then
479
+    if systemctl daemon-reload >/dev/null 2>&1; then
480
+      log_message "INFO" "OK systemd daemon-reload"
481
+    else
482
+      log_message "WARN" "systemd daemon-reload returned non-zero"
483
+    fi
484
+  fi
485
+
486
+  log_message "INFO" "Configured profiles overview"
487
+  list_profiles
488
+
489
+  log_message "INFO" "Connected block devices overview"
490
+  discover_devices
491
+
492
+  if [[ $rc -eq 0 ]]; then
493
+    log_message "INFO" "Mechanism verification completed successfully"
494
+  else
495
+    log_message "ERROR" "Mechanism verification failed"
496
+  fi
497
+
498
+  return $rc
499
+}
500
+
501
+import_all_profiles() {
502
+  ensure_config_file
503
+  local line any=0
504
+
505
+  while IFS= read -r line; do
506
+    [[ -z "$line" || "$line" == \#* ]] && continue
507
+    IFS='|' read -r name uuid destination _ <<< "$line"
508
+
509
+    any=1
510
+    run_import_for_entry "$name" "$uuid" "$destination"
511
+  done < "$CONFIG_FILE"
512
+
513
+  if [[ $any -eq 0 ]]; then
514
+    log_message "WARN" "No profiles found"
515
+  fi
516
+}
517
+
518
+wizard() {
519
+  ensure_config_file
520
+
521
+  while true; do
522
+    echo ""
523
+    echo "=== Raspberry Pi Camera Importer Wizard ==="
524
+    echo "1) List profiles"
525
+    echo "2) Discover connected devices (UUID)"
526
+    echo "3) Add profile"
527
+    echo "4) Remove profile"
528
+    echo "5) Update profile destination"
529
+    echo "6) Run import for one profile"
530
+    echo "7) Run import for all profiles"
531
+    echo "0) Exit"
532
+    echo ""
533
+
534
+    local choice
535
+    read -r -p "Choose option: " choice
536
+
537
+    case "$choice" in
538
+      1) list_profiles ;;
539
+      2) discover_devices ;;
540
+      3) add_profile_interactive ;;
541
+      4) remove_profile_interactive ;;
542
+      5) update_profile_interactive ;;
543
+      6)
544
+        local p
545
+        read -r -p "Profile name: " p
546
+        import_profile_by_name "$(sanitize_profile_name "$p")"
547
+        ;;
548
+      7) import_all_profiles ;;
549
+      0) break ;;
550
+      *) echo "Invalid option" ;;
551
+    esac
552
+  done
553
+}
554
+
555
+main() {
556
+  local command="${1:-}"
557
+  if [[ -z "$command" ]]; then
558
+    usage
559
+    exit 1
560
+  fi
561
+  shift || true
562
+
563
+  case "$command" in
564
+    --help|-h)
565
+      usage
566
+      ;;
567
+    list)
568
+      require_root
569
+      list_profiles
570
+      ;;
571
+    discover)
572
+      require_root
573
+      ensure_runtime_dependencies "blkid" || exit 1
574
+      discover_devices
575
+      ;;
576
+    verify)
577
+      require_root
578
+      verify_pipeline
579
+      ;;
580
+    update)
581
+      require_root
582
+      ensure_config_file
583
+
584
+      local profile=""
585
+      local destination=""
586
+
587
+      while [[ $# -gt 0 ]]; do
588
+        case "$1" in
589
+          --profile)
590
+            profile="$2"
591
+            shift 2
592
+            ;;
593
+          --destination)
594
+            destination="$2"
595
+            shift 2
596
+            ;;
597
+          *)
598
+            log_message "ERROR" "Unknown option: $1"
599
+            exit 1
600
+            ;;
601
+        esac
602
+      done
603
+
604
+      if [[ -z "$profile" ]]; then
605
+        log_message "ERROR" "Use --profile <name>"
606
+        exit 1
607
+      fi
608
+
609
+      if [[ -z "$destination" ]]; then
610
+        log_message "ERROR" "Use --destination <path>"
611
+        exit 1
612
+      fi
613
+
614
+      update_profile_values "$profile" "$destination"
615
+      ;;
616
+    wizard)
617
+      require_root
618
+      ensure_runtime_dependencies "mount" "umount" "mountpoint" "exiftool" "blkid" || exit 1
619
+      wizard
620
+      ;;
621
+    import)
622
+      require_root
623
+      ensure_runtime_dependencies "mount" "umount" "mountpoint" "exiftool" || exit 1
624
+      local profile=""
625
+      local uuid=""
626
+      local import_all=0
627
+
628
+      while [[ $# -gt 0 ]]; do
629
+        case "$1" in
630
+          --profile)
631
+            profile="$2"
632
+            shift 2
633
+            ;;
634
+          --uuid)
635
+            uuid="$2"
636
+            shift 2
637
+            ;;
638
+          --all)
639
+            import_all=1
640
+            shift
641
+            ;;
642
+          --dry-run)
643
+            DRY_RUN=1
644
+            shift
645
+            ;;
646
+          --verbose)
647
+            VERBOSE=1
648
+            shift
649
+            ;;
650
+          *)
651
+            log_message "ERROR" "Unknown option: $1"
652
+            exit 1
653
+            ;;
654
+        esac
655
+      done
656
+
657
+      if [[ $import_all -eq 1 ]]; then
658
+        import_all_profiles
659
+      elif [[ -n "$profile" ]]; then
660
+        import_profile_by_name "$profile"
661
+      elif [[ -n "$uuid" ]]; then
662
+        import_profile_by_uuid "$uuid"
663
+      else
664
+        log_message "ERROR" "Use --profile <name>, --uuid <uuid> or --all"
665
+        exit 1
666
+      fi
667
+      ;;
668
+    *)
669
+      log_message "ERROR" "Unknown command: $command"
670
+      usage
671
+      exit 1
672
+      ;;
673
+  esac
674
+}
675
+
676
+main "$@"
+39 -0
RPI/scripts/rpi-camera-udev-wrapper.sh
@@ -0,0 +1,39 @@
1
+#!/usr/bin/env bash
2
+
3
+set -euo pipefail
4
+
5
+LOG_TAG="rpi-camera-wrapper"
6
+export PATH="/usr/local/sbin:/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin"
7
+export HOME="/root"
8
+export USER="root"
9
+export LOGNAME="root"
10
+export SHELL="/bin/bash"
11
+umask 022
12
+
13
+action="${1:-}"
14
+uuid="${2:-}"
15
+
16
+if [[ -z "$action" || -z "$uuid" ]]; then
17
+  logger -p local0.err -t "$LOG_TAG" "Invalid arguments: action='$action' uuid='$uuid'"
18
+  exit 1
19
+fi
20
+
21
+logger -p local0.info -t "$LOG_TAG" "Called with action=$action uuid=$uuid"
22
+
23
+(
24
+  sleep 3
25
+  if [[ "$(id -u)" != "0" ]]; then
26
+    logger -p local0.err -t "$LOG_TAG" "Not running as root"
27
+    exit 1
28
+  fi
29
+
30
+  if [[ "$action" == "attach" ]]; then
31
+    systemctl start "rpi-camera-importer-attach@${uuid}.service"
32
+    logger -p local0.info -t "$LOG_TAG" "Started attach service for UUID=$uuid"
33
+  else
34
+    logger -p local0.warning -t "$LOG_TAG" "Unsupported wrapper action: $action"
35
+  fi
36
+) &
37
+
38
+logger -p local0.info -t "$LOG_TAG" "Background job started for $action $uuid"
39
+exit 0
+63 -0
RPI/setup.sh
@@ -0,0 +1,63 @@
1
+#!/usr/bin/env bash
2
+
3
+set -euo pipefail
4
+
5
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+APP_NAME="rpi-camera-importer"
7
+BIN_PATH="/usr/local/bin/${APP_NAME}"
8
+RUNTIME_DIR="/usr/local/lib/${APP_NAME}"
9
+CONFIG_DIR="/etc/rpi-camera-importer"
10
+CONFIG_FILE="${CONFIG_DIR}/cameras.conf"
11
+UDEV_RULE="/etc/udev/rules.d/99-rpi-camera-importer.rules"
12
+ATTACH_UNIT="/etc/systemd/system/rpi-camera-importer-attach@.service"
13
+
14
+require_root() {
15
+  if [[ "${EUID}" -ne 0 ]]; then
16
+    echo "This script must run as root"
17
+    exit 1
18
+  fi
19
+}
20
+
21
+install_dependencies() {
22
+  echo "Dependency installation at deploy is disabled."
23
+  echo "Dependencies are checked at runtime by rpi-camera-importer."
24
+}
25
+
26
+install_files() {
27
+  install -d /usr/local/bin
28
+  install -d "$RUNTIME_DIR"
29
+  install -d "$CONFIG_DIR"
30
+
31
+  install -m 0755 "${SCRIPT_DIR}/scripts/rpi-camera-importer.sh" "$BIN_PATH"
32
+  install -m 0755 "${SCRIPT_DIR}/scripts/rpi-camera-disk-handler.sh" "${RUNTIME_DIR}/rpi-camera-disk-handler.sh"
33
+  install -m 0755 "${SCRIPT_DIR}/scripts/rpi-camera-udev-wrapper.sh" "${RUNTIME_DIR}/rpi-camera-udev-wrapper.sh"
34
+  install -m 0755 "${SCRIPT_DIR}/scripts/autonas-media-importer.sh" "${RUNTIME_DIR}/autonas-media-importer.sh"
35
+
36
+  if [[ ! -f "$CONFIG_FILE" ]]; then
37
+    install -m 0644 "${SCRIPT_DIR}/config/cameras.conf" "$CONFIG_FILE"
38
+  else
39
+    echo "Config already exists, preserving: $CONFIG_FILE"
40
+  fi
41
+
42
+  install -m 0644 "${SCRIPT_DIR}/config/99-rpi-camera-importer.rules" "$UDEV_RULE"
43
+  install -m 0644 "${SCRIPT_DIR}/config/rpi-camera-importer-attach@.service" "$ATTACH_UNIT"
44
+}
45
+
46
+activate() {
47
+  systemctl daemon-reload
48
+  udevadm control --reload-rules
49
+  udevadm trigger --subsystem-match=block --action=add
50
+}
51
+
52
+main() {
53
+  require_root
54
+  install_dependencies
55
+  install_files
56
+  activate
57
+
58
+  echo "Installed ${APP_NAME}"
59
+  echo "Wizard: ${APP_NAME} wizard"
60
+  echo "Logs: journalctl -u rpi-camera-importer-attach@<UUID>.service -f"
61
+}
62
+
63
+main "$@"
+124 -34
media-importer.sh
@@ -16,6 +16,7 @@ COLLECT_UNSORTABLE=0
16 16
 SOURCE_PATTERNS=()
17 17
 DESTINATION=""
18 18
 KEEP_ORIGINALS=0
19
+VERIFY_MODE="size"  # options: size, strict, none
19 20
 DRY_RUN=0
20 21
 VERBOSE=0
21 22
 CLEANUP_EMPTY_DIRS=1
@@ -96,6 +97,7 @@ Options:
96 97
     -s, --source PATH            File or directory to process (repeatable). Default: cwd
97 98
     -d, --destination PATH       Destination folder. Required when multiple -s are given.
98 99
     -k, --keep-originals         Copy files instead of moving
100
+    --verify-mode MODE           size|strict|none (default: size)
99 101
     --collect-unsortable         Put files without dates into DEST/unsortable
100 102
     --keep-empty-dirs            Keep empty directories after processing
101 103
     --dry-run                    Show actions without changing files
@@ -240,6 +242,91 @@ safe_cp() {
240 242
     return $?
241 243
 }
242 244
 
245
+verify_copied_file() {
246
+    local src="$1"
247
+    local dst="$2"
248
+    local expected_date="$3"
249
+
250
+    if [[ ! -f "$dst" ]]; then
251
+        log_message "Verified copy missing at destination: $dst" "ERROR"
252
+        return 1
253
+    fi
254
+
255
+    local src_size dst_size
256
+    src_size=$(get_file_size "$src")
257
+    dst_size=$(get_file_size "$dst")
258
+    if [[ "$src_size" != "$dst_size" ]]; then
259
+        log_message "Size mismatch after copy: $src ($src_size) != $dst ($dst_size)" "ERROR"
260
+        return 1
261
+    fi
262
+
263
+    if [[ "$VERIFY_MODE" == "strict" ]]; then
264
+        if ! cmp -s "$src" "$dst"; then
265
+            log_message "Content mismatch after copy: $src -> $dst" "ERROR"
266
+            return 1
267
+        fi
268
+    elif [[ "$VERIFY_MODE" == "none" ]]; then
269
+        return 0
270
+    fi
271
+
272
+    if [[ -n "$expected_date" ]]; then
273
+        local destination_date_info
274
+        destination_date_info=$(extract_file_date "$dst")
275
+        local extract_status=$?
276
+        if [[ $extract_status -ne 0 || -z "$destination_date_info" ]]; then
277
+            log_message "Destination metadata validation failed: $dst" "ERROR"
278
+            return 1
279
+        fi
280
+
281
+        local destination_date="${destination_date_info%|*}"
282
+        if [[ "$destination_date" != "$expected_date" ]]; then
283
+            log_message "Destination metadata mismatch: expected $expected_date, got $destination_date for $dst" "ERROR"
284
+            return 1
285
+        fi
286
+    fi
287
+
288
+    return 0
289
+}
290
+
291
+remove_source_file() {
292
+    local src="$1"
293
+    rm -f "$src"
294
+}
295
+
296
+copy_with_verification() {
297
+    local src="$1"
298
+    local dst="$2"
299
+    local expected_date="$3"
300
+
301
+    if ! safe_cp "$src" "$dst"; then
302
+        return 1
303
+    fi
304
+
305
+    if ! verify_copied_file "$src" "$dst" "$expected_date"; then
306
+        rm -f "$dst"
307
+        return 1
308
+    fi
309
+
310
+    return 0
311
+}
312
+
313
+verified_move_file() {
314
+    local src="$1"
315
+    local dst="$2"
316
+    local expected_date="$3"
317
+
318
+    if ! copy_with_verification "$src" "$dst" "$expected_date"; then
319
+        return 1
320
+    fi
321
+
322
+    if ! remove_source_file "$src"; then
323
+        log_message "Copied and verified destination, but failed to remove source: $src" "ERROR"
324
+        return 1
325
+    fi
326
+
327
+    return 0
328
+}
329
+
243 330
 # Function to format file size
244 331
 format_size() {
245 332
     local size=$1
@@ -533,10 +620,10 @@ process_file() {
533 620
                 if [[ $DRY_RUN -eq 1 ]]; then
534 621
                     print_color "$BLUE" "Would move unsortable: $file -> $unsortable_path"
535 622
                 else
536
-                    if mv "$file" "$unsortable_path"; then
623
+                    if verified_move_file "$file" "$unsortable_path" ""; then
537 624
                         log_message "Unsortable: $file -> $unsortable_path" "SUCCESS"
538 625
                     else
539
-                        log_message "Failed to move unsortable file: $file" "ERROR"
626
+                        log_message "Failed to move unsortable file after verification: $file" "ERROR"
540 627
                     fi
541 628
                 fi
542 629
                 SKIPPED_FILES=$((SKIPPED_FILES + 1))
@@ -590,24 +677,24 @@ process_file() {
590 677
     
591 678
     # Copy or move file using safe helpers (filter benign stderr). Let external tools handle renaming conflicts.
592 679
     if [[ $KEEP_ORIGINALS -eq 1 ]]; then
593
-        if safe_cp "$file" "$dest_path"; then
680
+        if copy_with_verification "$file" "$dest_path" "$date_str"; then
594 681
             log_message "Copied: $file -> $dest_path" "SUCCESS"
595 682
             PROCESSED_FILES=$((PROCESSED_FILES + 1))
596 683
             PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
597 684
             return 0
598 685
         else
599
-            log_message "Failed to copy: $file -> $dest_path" "ERROR"
686
+            log_message "Failed to copy or verify destination: $file -> $dest_path" "ERROR"
600 687
             ERROR_FILES=$((ERROR_FILES + 1))
601 688
             return 1
602 689
         fi
603 690
     else
604
-        if safe_mv "$file" "$dest_path"; then
691
+        if verified_move_file "$file" "$dest_path" "$date_str"; then
605 692
             log_message "Moved: $file -> $dest_path" "SUCCESS"
606 693
             PROCESSED_FILES=$((PROCESSED_FILES + 1))
607 694
             PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
608 695
             return 0
609 696
         else
610
-            log_message "Failed to move: $file -> $dest_path" "ERROR"
697
+            log_message "Failed to move after copy verification: $file -> $dest_path" "ERROR"
611 698
             ERROR_FILES=$((ERROR_FILES + 1))
612 699
             return 1
613 700
         fi
@@ -705,6 +792,14 @@ while [[ $# -gt 0 ]]; do
705 792
             KEEP_ORIGINALS=1
706 793
             shift
707 794
             ;;
795
+        --verify-mode)
796
+            VERIFY_MODE="$2"
797
+            if [[ ! "$VERIFY_MODE" =~ ^(size|strict|none)$ ]]; then
798
+                print_color "$RED" "Error: Invalid verify mode. Must be one of: size, strict, none"
799
+                exit 1
800
+            fi
801
+            shift 2
802
+            ;;
708 803
         --dry-run)
709 804
             DRY_RUN=1
710 805
             shift
@@ -731,7 +826,24 @@ done
731 826
 
732 827
 # If no organization is provided, leave ORGANIZATION empty and filename mode will decide naming
733 828
 
734
-# Set default destination: if user didn't provide -d and a source was given, use first source's directory + /sorted
829
+# If no source specified, default to current directory. Refuse to run when cwd is unsafe.
830
+if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
831
+    cwd=$(pwd)
832
+    # Resolve home and root paths
833
+    home_dir="$HOME"
834
+    case "$cwd" in
835
+        "$home_dir"|"/"|"/etc"|"/var"|"/bin"|"/sbin"|"/dev"|"/proc"|"/sys"|"/run"|"/usr"|"/lib"|"/lib64"|"/boot"|"/snap")
836
+            print_color "$RED" "Refusing to run with default source in unsafe directory: $cwd"
837
+            print_color "$RED" "Please specify $cwd as source explicitly with -s or run from a safer directory."
838
+            exit 1
839
+            ;;
840
+        *)
841
+            SOURCE_PATTERNS+=("$cwd")
842
+            ;;
843
+    esac
844
+fi
845
+
846
+# Set default destination: if user didn't provide -d and a source was given, use first source + /sorted
735 847
 if [[ -z "$DESTINATION" ]]; then
736 848
     if [[ ${#SOURCE_PATTERNS[@]} -gt 1 ]]; then
737 849
         print_color "$RED" "Error: Multiple sources specified - destination (-d|--destination) is required when using multiple sources."
@@ -740,18 +852,12 @@ if [[ -z "$DESTINATION" ]]; then
740 852
     fi
741 853
 
742 854
     if [[ ${#SOURCE_PATTERNS[@]} -gt 0 ]]; then
743
-        # If exactly one source provided, place 'sorted' in the source's mount root
744 855
         first_source="${SOURCE_PATTERNS[0]}"
745
-        if [[ -e "$first_source" ]]; then
746
-            mount_root=$(get_mountpoint "$first_source")
747
-            if [[ -n "$mount_root" ]]; then
748
-                DESTINATION="$mount_root/sorted"
749
-            else
750
-                # Fallback to dirname of source
751
-                DESTINATION="$(dirname "$first_source")/sorted"
752
-            fi
856
+        if [[ -d "$first_source" ]]; then
857
+            DESTINATION="$first_source/sorted"
858
+        elif [[ -f "$first_source" ]]; then
859
+            DESTINATION="$(dirname "$first_source")/sorted"
753 860
         else
754
-            # Source doesn't exist; fallback to ./sorted but warn
755 861
             print_color "$YELLOW" "Warning: first source does not exist; defaulting destination to ./sorted"
756 862
             DESTINATION="./sorted"
757 863
         fi
@@ -760,23 +866,6 @@ if [[ -z "$DESTINATION" ]]; then
760 866
     fi
761 867
 fi
762 868
 
763
-# If no source specified, default to current directory. Refuse to run when cwd is unsafe.
764
-if [[ ${#SOURCE_PATTERNS[@]} -eq 0 ]]; then
765
-    cwd=$(pwd)
766
-    # Resolve home and root paths
767
-    home_dir="$HOME"
768
-    case "$cwd" in
769
-        "$home_dir"|"/"|"/etc"|"/var"|"/bin"|"/sbin"|"/dev"|"/proc"|"/sys"|"/run"|"/usr"|"/lib"|"/lib64"|"/boot"|"/snap")
770
-            print_color "$RED" "Refusing to run with default source in unsafe directory: $cwd"
771
-            print_color "$RED" "Please specify $cwd as source explicitly with -s or run from a safer directory."
772
-            exit 1
773
-            ;;
774
-        *)
775
-            SOURCE_PATTERNS+=("$cwd")
776
-            ;;
777
-    esac
778
-fi
779
-
780 869
 # Convert destination to absolute path
781 870
 DESTINATION=$(cd "$(dirname "$DESTINATION")" 2>/dev/null && pwd)/$(basename "$DESTINATION") || DESTINATION=$(realpath "$DESTINATION" 2>/dev/null) || DESTINATION="$DESTINATION"
782 871
 
@@ -787,6 +876,7 @@ echo "Configuration:"
787 876
 echo "  Organization pattern: $ORGANIZATION"
788 877
 echo "  Destination:         $DESTINATION"
789 878
 echo "  Keep originals:      $([ $KEEP_ORIGINALS -eq 1 ] && echo "Yes" || echo "No")"
879
+echo "  Verify mode:         $VERIFY_MODE"
790 880
 echo "  Dry run:             $([ $DRY_RUN -eq 1 ] && echo "Yes" || echo "No")"
791 881
 echo "  Keep empty dirs:     $([ $CLEANUP_EMPTY_DIRS -eq 0 ] && echo "Yes" || echo "No")"
792 882
 echo "  Verbose:             $([ $VERBOSE -eq 1 ] && echo "Yes" || echo "No")"
+0 -0
test/destination/.gitkeep
No changes.
+0 -0
test/source/.gitkeep
No changes.
+141 -5
test_runner.sh
@@ -496,6 +496,124 @@ run_source_only_test() {
496 496
         "$result"
497 497
 }
498 498
 
499
+# Test Case 8: Destination Inside Source Test
500
+run_destination_inside_source_test() {
501
+    print_color "$GREEN" "=== Running Test 8: Destination Inside Source Test ==="
502
+
503
+    clean_test_dir
504
+    create_test_dirs
505
+
506
+    # Setup test files
507
+    copy_sample_files "media/sortable" "$SOURCE_DIR"
508
+
509
+    # Capture pre-test state
510
+    capture_state "$SOURCE_DIR" "$TEST_DIR/source_before.txt" "source"
511
+    capture_state "$SOURCE_DIR/sorted" "$TEST_DIR/dest_before.txt" "destination-inside-source"
512
+
513
+    # Run test with destination explicitly inside source
514
+    local command="\"$MEDIA_IMPORTER\" -s \"$SOURCE_DIR\" -d \"$SOURCE_DIR/sorted\" -v"
515
+    local result=0
516
+    run_test_command "$command" "$TEST_DIR/import_log.txt" || result=$?
517
+
518
+    # Guard 1: no files should remain outside sorted for sortable-only dataset
519
+    local outside_count
520
+    outside_count=$(find "$SOURCE_DIR" -type f ! -path "$SOURCE_DIR/sorted/*" | wc -l | tr -d ' ')
521
+    if [[ "$outside_count" != "0" ]]; then
522
+        echo "Destination exclusion check failed: found $outside_count file(s) outside sorted" >> "$TEST_DIR/import_log.txt"
523
+        result=1
524
+    fi
525
+
526
+    # Guard 2: destination exclusion should prevent sorted/sorted recursion
527
+    local recursive_sorted_count
528
+    recursive_sorted_count=$(find "$SOURCE_DIR/sorted" -type d -path "*/sorted/sorted*" 2>/dev/null | wc -l | tr -d ' ')
529
+    if [[ "$recursive_sorted_count" != "0" ]]; then
530
+        echo "Destination recursion check failed: found nested sorted/sorted directories" >> "$TEST_DIR/import_log.txt"
531
+        result=1
532
+    fi
533
+
534
+    # Capture post-test state
535
+    capture_state "$SOURCE_DIR" "$TEST_DIR/source_after.txt" "source"
536
+    capture_state "$SOURCE_DIR/sorted" "$TEST_DIR/dest_after.txt" "destination-inside-source"
537
+
538
+    # Generate report
539
+    generate_test_report \
540
+        "Destination_Inside_Source" \
541
+        "Testing explicit destination inside source directory" \
542
+        "Verify destination exclusion and no sorted/sorted recursion" \
543
+        "Sortable sample media with destination set to source/sorted" \
544
+        "$command" \
545
+        "$result"
546
+}
547
+
548
+# Test Case 9: Verify Mode Test
549
+run_verify_mode_test() {
550
+    print_color "$GREEN" "=== Running Test 9: Verify Mode Test ==="
551
+
552
+    clean_test_dir
553
+    create_test_dirs
554
+
555
+    # Setup test files
556
+    copy_sample_files "media/sortable" "$SOURCE_DIR"
557
+
558
+    # Capture pre-test state
559
+    capture_state "$SOURCE_DIR" "$TEST_DIR/source_before.txt" "source"
560
+    capture_state "$SOURCE_DIR/sorted" "$TEST_DIR/dest_before.txt" "auto-destination"
561
+
562
+    local log_file="$TEST_DIR/import_log.txt"
563
+    local result=0
564
+    : > "$log_file"
565
+
566
+    local command_default="cd \"$SOURCE_DIR\" && \"$MEDIA_IMPORTER\" --dry-run"
567
+    local command_strict="cd \"$SOURCE_DIR\" && \"$MEDIA_IMPORTER\" --dry-run --verify-mode strict"
568
+    local command="$command_default && $command_strict"
569
+
570
+    echo "$command_default" >> "$log_file"
571
+    echo "" >> "$log_file"
572
+    echo "=== DEFAULT VERIFY MODE OUTPUT ===" >> "$log_file"
573
+    if eval "$command_default" >> "$log_file" 2>&1; then
574
+        if ! grep -q "Verify mode:[[:space:]]*size" "$log_file"; then
575
+            echo "Expected default verify mode 'size' not found in output" >> "$log_file"
576
+            result=1
577
+        fi
578
+    else
579
+        result=1
580
+    fi
581
+
582
+    echo "" >> "$log_file"
583
+    echo "$command_strict" >> "$log_file"
584
+    echo "" >> "$log_file"
585
+    echo "=== STRICT VERIFY MODE OUTPUT ===" >> "$log_file"
586
+    if eval "$command_strict" >> "$log_file" 2>&1; then
587
+        if ! grep -q "Verify mode:[[:space:]]*strict" "$log_file"; then
588
+            echo "Expected strict verify mode not found in output" >> "$log_file"
589
+            result=1
590
+        fi
591
+    else
592
+        result=1
593
+    fi
594
+
595
+    if [[ $result -eq 0 ]]; then
596
+        echo "" >> "$log_file"
597
+        echo "=== COMMAND COMPLETED SUCCESSFULLY ===" >> "$log_file"
598
+    else
599
+        echo "" >> "$log_file"
600
+        echo "=== COMMAND FAILED ===" >> "$log_file"
601
+    fi
602
+
603
+    # Capture post-test state
604
+    capture_state "$SOURCE_DIR" "$TEST_DIR/source_after.txt" "source"
605
+    capture_state "$SOURCE_DIR/sorted" "$TEST_DIR/dest_after.txt" "auto-destination"
606
+
607
+    # Generate report
608
+    generate_test_report \
609
+        "Verify_Mode" \
610
+        "Testing verify-mode defaults and strict override" \
611
+        "Verify default mode is size and strict mode is accepted" \
612
+        "Sample sortable media in source-only dry-run mode" \
613
+        "$command" \
614
+        "$result"
615
+}
616
+
499 617
 # Function to show menu
500 618
 show_menu() {
501 619
     echo ""
@@ -511,10 +629,12 @@ show_menu() {
511 629
     echo "5. Subdirectory Processing Test"
512 630
     echo "6. Keep Empty Directories Test"
513 631
     echo "7. Source Only Test"
514
-    echo "8. Run All Tests"
632
+    echo "8. Destination Inside Source Test"
633
+    echo "9. Verify Mode Test"
634
+    echo "10. Run All Tests"
515 635
     echo "q. Quit"
516 636
     echo ""
517
-    echo -n "Select test to run (0-8, q to quit): "
637
+    echo -n "Select test to run (0-10, q to quit): "
518 638
 }
519 639
 
520 640
 # Function to run all tests
@@ -536,6 +656,10 @@ run_all_tests() {
536 656
     run_keep_empty_dirs_test
537 657
     echo ""
538 658
     run_source_only_test
659
+    echo ""
660
+    run_destination_inside_source_test
661
+    echo ""
662
+    run_verify_mode_test
539 663
 
540 664
     print_color "$GREEN" "All tests completed!"
541 665
 }
@@ -572,6 +696,12 @@ main() {
572 696
                 run_source_only_test
573 697
                 ;;
574 698
             8)
699
+                run_destination_inside_source_test
700
+                ;;
701
+            9)
702
+                run_verify_mode_test
703
+                ;;
704
+            10)
575 705
                 run_all_tests
576 706
                 ;;
577 707
             q|Q)
@@ -579,7 +709,7 @@ main() {
579 709
                 exit 0
580 710
                 ;;
581 711
             *)
582
-                print_color "$RED" "Invalid choice. Please select 0-8 or q to quit."
712
+                print_color "$RED" "Invalid choice. Please select 0-10 or q to quit."
583 713
                 ;;
584 714
         esac
585 715
 
@@ -630,7 +760,13 @@ else
630 760
         "source-only"|"7")
631 761
             run_source_only_test
632 762
             ;;
633
-        "all"|"8")
763
+        "dest-in-source"|"8")
764
+            run_destination_inside_source_test
765
+            ;;
766
+        "verify-mode"|"9")
767
+            run_verify_mode_test
768
+            ;;
769
+        "all"|"10")
634 770
             run_all_tests
635 771
             ;;
636 772
         "clean")
@@ -641,7 +777,7 @@ else
641 777
             ;;
642 778
         *)
643 779
             print_color "$RED" "Unknown test: $1"
644
-            echo "Usage: $0 [basic|unimportable|mixed|safety|utc|subdir|keep-empty|source-only|all|clean|setup] or [0-8]"
780
+            echo "Usage: $0 [basic|unimportable|mixed|safety|utc|subdir|keep-empty|source-only|dest-in-source|verify-mode|all|clean|setup] or [0-10]"
645 781
             exit 1
646 782
             ;;
647 783
     esac