Showing 5 changed files with 1157 additions and 78 deletions
+106 -7
DEVELOPMENT.md
@@ -2,11 +2,10 @@
2 2
 
3 3
 ## 1. Objectives
4 4
 
5
-The Media Importer project aims to provide a robust, efficient solution for organizing media files by date with proper timezone handling and conflict - **Naming Convention**: `{s/f}_{YYYYMMDD_HHMMSS}_{TestName}.md` format where:
6
-  - `s` = success, `f` = failure
7
-  - Followed by timestamp and test name (spaces converted to underscores)esolution.
5
+The Media Importer project aims to provide a robust, efficient solution for organizing media files by date with proper timezone handling and conflict resolution.
8 6
 
9 7
 Key objectives:
8
+
10 9
 - Reliable EXIF/metadata extraction and date parsing
11 10
 - Proper UTC time conversion for QuickTime/Apple media files
12 11
 - Flexible organization patterns (year/month/day/hour)
@@ -23,14 +22,15 @@ When making changes to the project, follow this structured approach:
23 22
 
24 23
 All changelog entries should follow this format:
25 24
 
26
-```
25
+```text
27 26
 - Date time
28 27
 - Bug/Feature description  
29 28
 - Changes made
30 29
 ```
31 30
 
32 31
 Example:
33
-```
32
+
33
+```text
34 34
 - 2025-09-07 10:30
35 35
 - Fixed data loss issue when processing already-sorted folders
36 36
 - Added exclusion patterns for sorted/organized/processed folders
@@ -40,10 +40,19 @@ Example:
40 40
 ### File Move Confirmation
41 41
 
42 42
 Every file move operation should be confirmed:
43
+
43 44
 - After moving a file, the script must check that the file exists at the destination.
44 45
 - If the file is not present at the destination after the move operation, the script should immediately stop and report an error.
45 46
 - This ensures data integrity and prevents silent data loss.
46 47
 
48
+Implementation note (2026-05):
49
+
50
+- Move flow now uses `copy -> verify -> delete source`.
51
+- Verification is controlled by `--verify-mode` with modes `size` (default), `strict`, and `none`.
52
+- `size` validates destination existence, size match, and metadata/date consistency.
53
+- `strict` adds byte-to-byte validation (`cmp`) on top of `size` checks.
54
+- Source deletion is allowed only after successful verification.
55
+
47 56
 ### Destination Inside Source Handling
48 57
 
49 58
 - Given the script's purpose, the destination folder may be inside the source folder.
@@ -56,18 +65,21 @@ Every file move operation should be confirmed:
56 65
 Testing is essential to ensure the script's reliability and data safety. The following methodology should be used:
57 66
 
58 67
 #### Test Environment Setup
68
+
59 69
 - The `samples` directory contains a variety of media files for testing.
60 70
 - Create a dedicated working directory named `test` for each test run.
61 71
 - Copy selected files from `samples` into the `test` directory to simulate real-world scenarios.
62 72
 - Perform import operations using the script, targeting the `test` directory as the source and a subdirectory (e.g., `test/sorted`) as the destination.
63 73
 
64 74
 #### Test Execution and Documentation
75
+
65 76
 - Before and after each import operation, run `find` on both the source and destination directories to capture the file structure:
66 77
   - Example: `find ./test > test/source_before.txt`
67 78
   - Example: `find ./test/sorted > test/dest_before.txt`
68 79
 - Log all results, including script output and directory listings, into a dedicated log file for each test.
69 80
 
70 81
 #### Test Report Format
82
+
71 83
 Each test must generate a comprehensive Markdown report in `test/test_report.md` with the following structure:
72 84
 
73 85
 ```markdown
@@ -161,6 +173,7 @@ Each test must generate a comprehensive Markdown report in `test/test_report.md`
161 173
 ```
162 174
 
163 175
 #### Automated Test Runner
176
+
164 177
 A comprehensive test runner script (`test_runner.sh`) is available to automate the testing process:
165 178
 
166 179
 ```bash
@@ -168,6 +181,7 @@ A comprehensive test runner script (`test_runner.sh`) is available to automate t
168 181
 ```
169 182
 
170 183
 The script provides:
184
+
171 185
 - **Pre-configured test scenarios** for common use cases
172 186
 - **Automatic report generation** in Markdown format
173 187
 - **State capture** before and after test execution
@@ -175,6 +189,7 @@ The script provides:
175 189
 - **Custom test support** for specific scenarios
176 190
 
177 191
 #### Test Categories
192
+
178 193
 The test runner provides the following pre-configured test scenarios:
179 194
 
180 195
 1. **Basic Functionality Test**: Tests processing of files with valid EXIF data to verify correct sorting and organization
@@ -183,13 +198,20 @@ The test runner provides the following pre-configured test scenarios:
183 198
 4. **Safety Protections Test**: Tests destination exclusion and move confirmation mechanisms to prevent data loss
184 199
 5. **UTC Conversion Test**: Tests UTC timestamp conversion for QuickTime/Apple EXIF data
185 200
 6. **Subdirectory Processing Test**: Tests processing of files in nested subdirectories to ensure recursive file discovery
186
-7. **Custom Test**: Allows user-defined test scenarios with custom file sets and commands
201
+7. **Source Only Test**: Tests automatic `source/sorted` destination behavior
202
+8. **Destination Inside Source Test**: Verifies destination exclusion and prevents `sorted/sorted` recursion
203
+9. **Verify Mode Test**: Verifies default `size` verification and explicit `strict` mode
204
+10. **Timestamp Collision No-Overwrite Test**: Reproduces GoPro-style chapters with identical `CreateDate` values and verifies unique destination filenames with no overwrite collapse
205
+11. **GoPro Sidecar Metadata Sync Test**: Verifies GoPro MP4 imports use THM start times and automatically write the corrected timestamp into destination metadata
187 206
 
188 207
 #### Test Result Persistence
208
+
189 209
 The test runner includes automatic result persistence:
190 210
 
191 211
 - **Archival Location**: Test results are saved as individual Markdown files in `test_reports/` directory
192
-- **Naming Convention**: `{TestName}_{YYYYMMDD_HHMMSS}.md` format for easy identification
212
+- **Naming Convention**: `{s/f}_{YYYYMMDD_HHMMSS}_{TestName}.md` format where:
213
+  - `s` = success, `f` = failure
214
+  - Followed by timestamp and test name (spaces converted to underscores)
193 215
 - **Contents Preserved**: Single self-contained Markdown file with:
194 216
   - Complete test information and directory structures
195 217
   - Full script execution output embedded inline (ANSI codes stripped for readability)
@@ -198,13 +220,82 @@ The test runner includes automatic result persistence:
198 220
 - **Historical Tracking**: Maintains complete test history for debugging and regression testing
199 221
 
200 222
 #### Cleanup
223
+
201 224
 - Review the test report and verify all aspects are documented
202 225
 - Clean up the `test` directory after each test run to ensure a fresh environment for subsequent tests
203 226
 - Archive important test reports in a `test_reports/` directory for future reference
204 227
 
205 228
 ## 3. Changelog
206 229
 
230
+### 2026-05-16 00:10 - GoPro Chapter Overwrite Postmortem
231
+
232
+- Documented the 2026-05-15 GoPro import data-loss incident in `INCIDENTS.md`.
233
+- Root cause: multiple chaptered GoPro MP4 files shared the same timestamp-derived destination filename; the script warned but continued, allowing each copy to overwrite the previous destination before deleting the current source after verification.
234
+- Clarified that QuickTime UTC-to-local conversion likely explains the visible `19:20` vs `22:20` timestamp difference, but was not the data-loss mechanism.
235
+- Recorded surviving evidence: only the final full-resolution chapter remained in destination; `.THM` and `.LRV` sidecars remained on the card and preserved the chapter timeline.
236
+- Added operational guidance for future GoPro imports: start with `--date-source filesystem --sync-metadata --dry-run -v` and verify that GoPro MP4 dates come from matching `.THM` sidecars.
237
+- Added regression rule: future changes to date extraction, naming, conflict handling, copy/move, or GoPro/Garmin/Varia behavior must run `./test_runner.sh timestamp-collision`.
238
+
239
+---
240
+
241
+### 2026-05-17 08:35 - Automatic GoPro Metadata Timestamp Correction
242
+
243
+- Changed GoPro import behavior so `GX/GH/GP*.MP4` files with matching `.THM` sidecars prefer the THM filesystem timestamp even in default `auto` date mode.
244
+- GoPro imports that use THM/filesystem dates now automatically sync destination metadata to the corrected clip start time; `--sync-metadata` is no longer required for this GoPro path.
245
+- Changed move flow for metadata-corrected imports to copy and verify first, write destination metadata, verify the metadata timestamp, and only then delete the source file.
246
+- Added `GoPro_Sidecar_Metadata_Sync` regression coverage in `test_runner.sh`.
247
+- Verified the real `/Volumes/GOPRO` dry-run maps the current files to `19:03:20`, `19:15:08`, `19:26:56`, and `19:38:44`, all from matching THM sidecars.
248
+
249
+---
250
+
251
+### 2026-05-17 09:20 - Explicit Destination Conflict Policy
252
+
253
+- Added `--unattended`; unattended/non-interactive runs never prompt and resolve existing destinations with numeric suffixes (`_1`, `_2`, ...).
254
+- Interactive runs now ask on destination conflicts and offer suffix once/all, skip once/all, or abort, so long imports do not require repeated decisions.
255
+- Dry-run reserves planned destinations, so collisions within the same run are visible before writing.
256
+- Updated `Timestamp_Collision_No_Overwrite` to assert numeric suffixes and reject the legacy `__GX...` suffix form.
257
+
258
+---
259
+
260
+### 2026-05-17 12:05 - Long Import UX Feedback
261
+
262
+- Added per-file progress lines (`Processing [n/total]`) before long copy/move work starts.
263
+- Replaced integer `files/sec` reporting with decimal throughput that shows `files/min` for large slow imports and keeps `MB/sec` precision.
264
+- Added average time per processed file to the final report.
265
+
266
+---
267
+
268
+### 2026-05-17 12:20 - GoPro Filesystem Date Fallback
269
+
270
+- GoPro auto date extraction now stays on filesystem timestamps even when THM is missing.
271
+- Fallback order is matching `THM`, matching `LRV`, then the MP4 file's own filesystem mtime.
272
+- Automatic metadata sync now applies to all GoPro filesystem date sources, including the MP4 fallback.
273
+- Extended `GoPro_Sidecar_Metadata_Sync` to cover THM, LRV-only, and no-sidecar GoPro imports.
274
+- Added `GoPro_No_Sidecar_Reimport` to verify no-sidecar MP4 fallback plus safe numeric suffixing when the same GoPro file is imported again.
275
+- Excluded macOS AppleDouble `._*` files from source discovery so NAS metadata forks are not treated as importable media.
276
+
277
+---
278
+
279
+### 2026-05-13 21:10 - Critical Data Loss Incident + Hardening
280
+
281
+- Observed incident: running with explicit source on a problematic/near-full card led to writes on source media and source content removal without strong post-write confirmation.
282
+- Impact: major data loss risk on unstable storage media (including read-only/IO edge cases).
283
+- Clarified behavior kept as valid: destination may be inside source (`source/sorted`) and must be excluded from scanning.
284
+- Reworked move safety in `media-importer.sh`: direct `mv` path replaced by verified flow `copy -> verify -> delete source`.
285
+- Added verification modes via `--verify-mode`:
286
+  - `size` (default): destination exists + size match + metadata/date validation
287
+  - `strict`: adds byte-to-byte content validation (`cmp`)
288
+  - `none`: disables verification (explicit opt-out)
289
+- Applied the same verified move logic for unsortable collection path.
290
+- Added regression coverage in `test_runner.sh`:
291
+  - Destination-inside-source test (`-s source -d source/sorted`) to verify destination exclusion and no `sorted/sorted` recursion.
292
+  - Verify-mode test to confirm default `size` and explicit `strict` behavior.
293
+- Updated ignore/staging hygiene to avoid committing generated test artifacts and `.DS_Store`.
294
+
295
+---
296
+
207 297
 ### 2025-09-07 21:15 - Test 2 and 3 Enhancements
298
+
208 299
 - Updated Test 2 (Unimportable Files Test) to include files in both root and subfolder
209 300
 - Removed --collect-unimportable flag from Test 2 to test default behavior
210 301
 - Updated Test 3 (Mixed Content Test) to use separate folders for sortable vs unimportable files
@@ -215,6 +306,7 @@ The test runner includes automatic result persistence:
215 306
 ---
216 307
 
217 308
 ### 2025-09-07 21:25 - Documentation Enhancement
309
+
218 310
 - Added comprehensive documentation for --collect-unimportable flag in README.md
219 311
 - Added Example 4 showing how to use --collect-unimportable flag
220 312
 - Updated Features section to mention unimportable files handling
@@ -224,6 +316,7 @@ The test runner includes automatic result persistence:
224 316
 ---
225 317
 
226 318
 ### 2025-09-07 21:30 - Git Ignore Enhancement
319
+
227 320
 - Added test_reports/ to .gitignore to exclude generated test reports from version control
228 321
 - Test reports are generated files that don't need to be tracked in Git
229 322
 - Prevents large numbers of timestamped report files from cluttering the repository
@@ -232,6 +325,7 @@ The test runner includes automatic result persistence:
232 325
 ---
233 326
 
234 327
 ### 2025-09-07 20:40 - Source Only Test Addition
328
+
235 329
 - Added Test 8: Source Only Test to test runner
236 330
 - Tests processing with only source parameter (creates sorted subdirectory automatically)
237 331
 - Verifies that when no destination is specified, files are sorted into source/sorted/
@@ -240,6 +334,7 @@ The test runner includes automatic result persistence:
240 334
 ---
241 335
 
242 336
 ### 2025-09-07 20:45 - Test 7 Refinement
337
+
243 338
 - Updated Test 7 to test --keep-empty-dirs functionality instead of cleanup
244 339
 - Since cleanup is now default behavior, Test 7 now verifies empty directory preservation
245 340
 - Renamed from "Cleanup Empty Directories Test" to "Keep Empty Directories Test"
@@ -249,6 +344,7 @@ The test runner includes automatic result persistence:
249 344
 ---
250 345
 
251 346
 ### 2025-09-07 19:30 - Test Runner Directory Separation
347
+
252 348
 - Adapted test runner to use separate source and destination directories
253 349
 - Changed from test/ as source to test/source/ and test/destination/
254 350
 - Updated all test functions to use proper directory separation
@@ -257,6 +353,7 @@ The test runner includes automatic result persistence:
257 353
 ---
258 354
 
259 355
 ### 2025-09-07 19:00 - Default Cleanup Behavior
356
+
260 357
 - Made --cleanup-empty-dirs the default behavior (implicit option)
261 358
 - Added --keep-empty-dirs flag to disable cleanup if needed
262 359
 - Updated help text and configuration display to reflect new default
@@ -265,6 +362,7 @@ The test runner includes automatic result persistence:
265 362
 ---
266 363
 
267 364
 ### 2025-09-07 18:56 - Cleanup Empty Directories Feature
365
+
268 366
 - Added --cleanup-empty-dirs option to remove empty directories from source after processing
269 367
 - Added cleanup_empty_directories() function with safe empty directory detection
270 368
 - Updated final report to show cleanup status
@@ -274,6 +372,7 @@ The test runner includes automatic result persistence:
274 372
 ## 4. Todo
275 373
 
276 374
 Key areas for future development:
375
+
277 376
 - GPS metadata integration for timezone detection
278 377
 - Enhanced duplicate detection
279 378
 - Performance optimizations for large file sets
+206 -0
INCIDENTS.md
@@ -0,0 +1,206 @@
1
+# Incident Reports
2
+
3
+## 2026-05-15 - GoPro Chapter Import Overwrite
4
+
5
+### Summary
6
+
7
+An import from `/Volumes/NO NAME` into `~/Autofs/xdev/autonas/ext01/@Camera/GoPro`
8
+processed 9 GoPro MP4 files and reported success for all of them, but the import
9
+collapsed multiple chapter files onto one destination filename. Because the script
10
+continued after detecting the collision, each later copy overwrote the previous
11
+destination file and the source file was then deleted after verification.
12
+
13
+The surviving destination file appears to be the last chapter (`GX091621.MP4`),
14
+stored as:
15
+
16
+```text
17
+/Users/bogdan/Autofs/xdev/autonas/ext01/@Camera/GoPro/2026-05-15/2026-05-15_22-20-09.mp4
18
+```
19
+
20
+### User Command
21
+
22
+```bash
23
+media-importer.sh -s "/Volumes/NO NAME" -d ~/Autofs/xdev/autonas/ext01/@Camera/GoPro
24
+```
25
+
26
+Effective settings from the log:
27
+
28
+```text
29
+Organization pattern: ymd
30
+Destination:         /Users/bogdan/Autofs/xdev/autonas/ext01/@Camera/GoPro
31
+Keep originals:      No
32
+Verify mode:         size
33
+Dry run:             No
34
+Keep empty dirs:     No
35
+Source patterns:
36
+  - /Volumes/NO NAME
37
+```
38
+
39
+### Impact
40
+
41
+- Total files found: 9
42
+- Total size found: 30GB
43
+- Script reported: 9 successfully processed, 0 errors
44
+- Actual observed result: only one full-resolution MP4 remained in destination
45
+- Source MP4 files were removed from the card
46
+- Remaining card artifacts included `.LRV` proxy files and `.THM` thumbnails
47
+
48
+The original full-resolution data for the first 8 chapters was not recoverable
49
+from the mounted filesystem at the time of inspection. No recovery tool was run.
50
+
51
+### Evidence
52
+
53
+The import log repeatedly mapped each source chapter to the same destination:
54
+
55
+```text
56
+GX011621.MP4 -> 2026-05-15_22-20-09.mp4
57
+GX021621.MP4 -> 2026-05-15_22-20-09.mp4
58
+GX031621.MP4 -> 2026-05-15_22-20-09.mp4
59
+GX041621.MP4 -> 2026-05-15_22-20-09.mp4
60
+GX051621.MP4 -> 2026-05-15_22-20-09.mp4
61
+GX061621.MP4 -> 2026-05-15_22-20-09.mp4
62
+GX071621.MP4 -> 2026-05-15_22-20-09.mp4
63
+GX081621.MP4 -> 2026-05-15_22-20-09.mp4
64
+GX091621.MP4 -> 2026-05-15_22-20-09.mp4
65
+```
66
+
67
+The script emitted warnings like:
68
+
69
+```text
70
+Destination already exists: .../2026-05-15_22-20-09.mp4 - proceeding to move/copy and letting external tools handle conflicts
71
+```
72
+
73
+That warning was the bug signal. The script should have chosen a unique destination
74
+or failed closed. Instead it proceeded.
75
+
76
+Post-incident inspection found:
77
+
78
+```text
79
+/Users/bogdan/Autofs/xdev/autonas/ext01/@Camera/GoPro/2026-05-15/2026-05-15_22-20-09.mp4
80
+size: 586M
81
+duration: 0:01:49
82
+QuickTime CreateDate: 2026:05:15 19:20:09
83
+```
84
+
85
+The duration matched the surviving low-resolution proxy:
86
+
87
+```text
88
+GL091621.LRV duration: 108.58s
89
+```
90
+
91
+This strongly indicates that the surviving full-resolution file was the last
92
+chapter (`GX091621.MP4`).
93
+
94
+### Timeline Recovered From Card Artifacts
95
+
96
+The full-resolution MP4 files were gone from `/Volumes/NO NAME/DCIM/100GOPRO`,
97
+but GoPro sidecar/proxy files remained. The `.THM` timestamps preserved a useful
98
+chapter timeline:
99
+
100
+```text
101
+GX011621.THM  2026-05-15 19:20:10
102
+GX021621.THM  2026-05-15 19:31:58
103
+GX031621.THM  2026-05-15 19:43:44
104
+GX041621.THM  2026-05-15 19:55:32
105
+GX051621.THM  2026-05-15 20:07:20
106
+GX061621.THM  2026-05-15 20:19:08
107
+GX071621.THM  2026-05-15 20:30:54
108
+GX081621.THM  2026-05-15 20:42:42
109
+GX091621.THM  2026-05-15 20:54:30
110
+```
111
+
112
+The `.LRV` proxy files also remained and can preserve low-resolution visual
113
+content when full-resolution MP4 recovery is not possible.
114
+
115
+### Root Cause
116
+
117
+There were two separate issues:
118
+
119
+1. Filename collision handling was unsafe.
120
+   - Multiple GoPro chapters had the same metadata timestamp.
121
+   - `generate_destination_path` generated the same destination filename for all
122
+     files.
123
+   - `process_file` detected the existing destination but continued.
124
+   - The copy operation overwrote the destination.
125
+   - Verification then validated the newly overwritten destination.
126
+   - The source file was deleted.
127
+
128
+2. Timestamp interpretation was easy to misunderstand.
129
+   - The script converts QuickTime `CreateDate` values from UTC to local time.
130
+   - The accident log used `22:20:09`, while surviving `.THM/.LRV` filesystem
131
+     timestamps showed `19:20`.
132
+   - This looks like QuickTime UTC conversion rather than a GoPro firmware
133
+     regression.
134
+   - This was not the data-loss mechanism, but it affected the visible names.
135
+
136
+### Corrective Actions
137
+
138
+Implemented in `media-importer.sh`:
139
+
140
+- Added explicit no-overwrite checks in `safe_cp` and `safe_mv`.
141
+- Changed copy/move flow to copy into a temporary file, verify the temporary
142
+  file, then move it into place only if the final destination does not exist.
143
+- Added explicit destination conflict handling. Unattended runs append numeric
144
+  suffixes; interactive runs ask the user and can apply the decision to all
145
+  similar conflicts during long imports.
146
+
147
+```text
148
+2026-05-15_19-20-09.mp4
149
+2026-05-15_19-20-09_1.mp4
150
+2026-05-15_19-20-09_2.mp4
151
+```
152
+
153
+- Sorted discovered files before processing to make chapter order stable.
154
+- Added `--date-source filesystem` for imports where filesystem timestamps are
155
+  the source of truth.
156
+- GoPro `GX/GH/GP*.MP4` files now prefer filesystem timestamps in default
157
+  `auto` mode, using matching `THM`, matching `LRV`, then MP4 mtime as fallback.
158
+- GoPro imports that use filesystem dates automatically write the corrected clip
159
+  start time into destination metadata before deleting the source.
160
+- Added `--sync-metadata` to force the same metadata write for non-GoPro cases.
161
+- Prevented accidental execution when the script is sourced rather than run.
162
+
163
+Implemented in `test_runner.sh`:
164
+
165
+- Added `timestamp-collision` regression test.
166
+- The test creates multiple MP4 files with identical `CreateDate` values.
167
+- It runs move mode and verifies that all files arrive at unique destination
168
+  paths, with no overwrite collapse.
169
+
170
+Verification command:
171
+
172
+```bash
173
+./test_runner.sh timestamp-collision
174
+```
175
+
176
+### Operational Guidance
177
+
178
+For the next GoPro import, start with dry-run:
179
+
180
+```bash
181
+./media-importer.sh -s "/Volumes/GOPRO" -d "$HOME/Autofs/xdev/autonas/ext01/@Camera/GoPro" --dry-run -v
182
+```
183
+
184
+Review the dry-run output before removing `--dry-run`. Expected healthy output:
185
+
186
+- each `GX*.MP4` maps to a distinct destination path;
187
+- date source says `Filesystem:GX*.THM`, `Filesystem:GX*.LRV`, or the MP4
188
+  basename depending on the best available GoPro filesystem source;
189
+- each GoPro line says metadata date would be synced;
190
+- no line says that an existing destination will be overwritten;
191
+- any unavoidable conflict is either a deliberate numeric suffix or an explicit
192
+  user choice.
193
+
194
+After dry-run review, run the same command without `--dry-run`.
195
+
196
+### Regression Rule
197
+
198
+Any future change to date extraction, filename generation, verification, copy,
199
+move, conflict handling, or GoPro/Garmin/Varia media behavior must run:
200
+
201
+```bash
202
+./test_runner.sh timestamp-collision
203
+```
204
+
205
+The test must fail if two source media files can collapse into one destination
206
+file while the script still reports success.
+2 -0
README.md
@@ -5,6 +5,7 @@ This repository contains the development and testing resources for the `media-im
5 5
 ## Project Structure
6 6
 
7 7
 - `media-importer.sh`: The primary script under development and testing.
8
+- `INCIDENTS.md`: Postmortems for data-loss or near-data-loss incidents and the regression rules derived from them.
8 9
 - `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 10
 - `samples/code/autonas-media-importer.sh`: The original script that serves as inspiration for this project.
10 11
 - `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.
@@ -24,6 +25,7 @@ samples/
24 25
 - All development should focus on `media-importer.sh`.
25 26
 - Do not import or source scripts/functions from the `samples/` directory.
26 27
 - Use resources in `samples/` only for testing and development reference.
28
+- Destination conflicts must never be delegated to copy/move tools. In unattended runs the importer appends numeric suffixes (`_1`, `_2`, ...); in interactive runs it asks the user and supports applying the choice to all similar conflicts.
27 29
 
28 30
 ## License
29 31
 
+539 -66
media-importer.sh
@@ -17,9 +17,15 @@ SOURCE_PATTERNS=()
17 17
 DESTINATION=""
18 18
 KEEP_ORIGINALS=0
19 19
 VERIFY_MODE="size"  # options: size, strict, none
20
+DATE_SOURCE="auto"  # options: auto, exif, filesystem
21
+SYNC_METADATA=0     # when 1, write reconstructed date into destination metadata
22
+UNATTENDED=0        # when 1, never prompt; destination conflicts get numeric suffixes
20 23
 DRY_RUN=0
21 24
 VERBOSE=0
22 25
 CLEANUP_EMPTY_DIRS=1
26
+CONFLICT_APPLY_ALL=""  # suffix|skip after an interactive "all similar" choice
27
+RESOLVED_DESTINATION_PATH=""
28
+RESERVED_DESTINATION_PATHS=()
23 29
 
24 30
 # Counters and statistics
25 31
 TOTAL_FILES=0
@@ -29,6 +35,7 @@ ERROR_FILES=0
29 35
 TOTAL_SIZE=0
30 36
 PROCESSED_SIZE=0
31 37
 START_TIME=$(date +%s)
38
+CURRENT_FILE_INDEX=0
32 39
 
33 40
 # Colors for output
34 41
 RED='\033[0;31m'
@@ -98,6 +105,9 @@ Options:
98 105
     -d, --destination PATH       Destination folder. Required when multiple -s are given.
99 106
     -k, --keep-originals         Copy files instead of moving
100 107
     --verify-mode MODE           size|strict|none (default: size)
108
+    --date-source SOURCE         auto|exif|filesystem (default: auto)
109
+    --sync-metadata              Write chosen date into destination metadata (automatic for GoPro filesystem dates)
110
+    --unattended                 Never prompt; resolve destination conflicts with numeric suffixes
101 111
     --collect-unsortable         Put files without dates into DEST/unsortable
102 112
     --keep-empty-dirs            Keep empty directories after processing
103 113
     --dry-run                    Show actions without changing files
@@ -165,7 +175,6 @@ check_dependencies() {
165 175
 
166 176
 # Determine filesystem/device ID for a path (portable between Linux and macOS)
167 177
 get_dev() {
168
-    # Return a device identifier for the supplied path (portable between GNU stat and BSD stat)
169 178
     local path="$1"
170 179
     if [[ -z "$path" ]]; then
171 180
         path="."
@@ -180,30 +189,6 @@ get_dev() {
180 189
     fi
181 190
 }
182 191
 
183
-# Determine the mount root for a path by walking up until device changes
184
-get_mountpoint() {
185
-    local path="$1"
186
-    if [[ -z "$path" ]]; then path="."; fi
187
-    # Resolve to absolute
188
-    local abs
189
-    abs=$(cd "$path" 2>/dev/null && pwd) || abs="$path"
190
-    # Walk up until device differs or we reach /
191
-    local parent="$abs"
192
-    local root_dev
193
-    root_dev=$(get_dev "$parent")
194
-    while [[ "$parent" != "/" ]]; do
195
-        local next_parent
196
-        next_parent=$(dirname "$parent")
197
-        local next_dev
198
-        next_dev=$(get_dev "$next_parent")
199
-        if [[ "$next_dev" != "$root_dev" ]]; then
200
-            break
201
-        fi
202
-        parent="$next_parent"
203
-    done
204
-    echo "$parent"
205
-}
206
-
207 192
 # Function to get file size in bytes (portable between Linux and macOS)
208 193
 get_file_size() {
209 194
     local file="$1"
@@ -234,12 +219,291 @@ safe_mv() {
234 219
     return $?
235 220
 }
236 221
 
237
-# Use simple safe_mv/safe_cp for moving/copying files. Removed atomic installer to let exiftool or filesystem handle renames.
222
+destination_path_reserved() {
223
+    local candidate="$1"
224
+    local reserved_path
225
+    for reserved_path in "${RESERVED_DESTINATION_PATHS[@]}"; do
226
+        if [[ "$reserved_path" == "$candidate" ]]; then
227
+            return 0
228
+        fi
229
+    done
230
+    return 1
231
+}
238 232
 
239
-safe_cp() {
240
-    local src="$1" dst="$2"
241
-    cp "$src" "$dst" 2> >(grep -v "set flags (was:" >&2)
242
-    return $?
233
+destination_path_unavailable() {
234
+    local candidate="$1"
235
+    [[ -e "$candidate" ]] || destination_path_reserved "$candidate"
236
+}
237
+
238
+reserve_destination_path() {
239
+    local candidate="$1"
240
+    if [[ -n "$candidate" ]] && ! destination_path_reserved "$candidate"; then
241
+        RESERVED_DESTINATION_PATHS+=("$candidate")
242
+    fi
243
+}
244
+
245
+prompt_destination_conflict_choice() {
246
+    local source_file="$1"
247
+    local desired_path="$2"
248
+    local choice
249
+
250
+    if [[ ! -r /dev/tty || ! -w /dev/tty ]]; then
251
+        return 1
252
+    fi
253
+
254
+    {
255
+        print_color "$YELLOW" "Destination already exists:"
256
+        echo "  Source:      $source_file"
257
+        echo "  Destination: $desired_path"
258
+        echo ""
259
+        echo "Choose conflict action:"
260
+        echo "  [s] suffix once"
261
+        echo "  [S] suffix for all similar conflicts"
262
+        echo "  [k] skip once"
263
+        echo "  [K] skip all similar conflicts"
264
+        echo "  [a] abort import"
265
+    } > /dev/tty
266
+
267
+    while true; do
268
+        printf "Action [s/S/k/K/a]: " > /dev/tty
269
+        IFS= read -r choice < /dev/tty || return 1
270
+        case "$choice" in
271
+            s|"")
272
+                echo "suffix"
273
+                return 0
274
+                ;;
275
+            S)
276
+                echo "suffix_all"
277
+                return 0
278
+                ;;
279
+            k)
280
+                echo "skip"
281
+                return 0
282
+                ;;
283
+            K)
284
+                echo "skip_all"
285
+                return 0
286
+                ;;
287
+            a|A)
288
+                echo "abort"
289
+                return 0
290
+                ;;
291
+            *)
292
+                print_color "$YELLOW" "Please choose s, S, k, K, or a." > /dev/tty
293
+                ;;
294
+        esac
295
+    done
296
+}
297
+
298
+resolve_destination_conflict() {
299
+    local desired_path="$1"
300
+    local source_file="$2"
301
+    local resolved_path choice
302
+    RESOLVED_DESTINATION_PATH=""
303
+
304
+    if [[ -z "$desired_path" ]]; then
305
+        return 1
306
+    fi
307
+
308
+    if ! destination_path_unavailable "$desired_path"; then
309
+        RESOLVED_DESTINATION_PATH="$desired_path"
310
+        reserve_destination_path "$RESOLVED_DESTINATION_PATH"
311
+        return 0
312
+    fi
313
+
314
+    if [[ "$CONFLICT_APPLY_ALL" == "skip" ]]; then
315
+        return 3
316
+    fi
317
+
318
+    if [[ "$CONFLICT_APPLY_ALL" == "suffix" || $UNATTENDED -eq 1 ]]; then
319
+        resolved_path=$(ensure_unique_destination_path "$desired_path")
320
+        if [[ -z "$resolved_path" ]]; then
321
+            return 1
322
+        fi
323
+        RESOLVED_DESTINATION_PATH="$resolved_path"
324
+        reserve_destination_path "$RESOLVED_DESTINATION_PATH"
325
+        return 0
326
+    fi
327
+
328
+    choice=$(prompt_destination_conflict_choice "$source_file" "$desired_path")
329
+    if [[ $? -ne 0 || -z "$choice" ]]; then
330
+        log_message "Cannot prompt for destination conflict; using unattended numeric suffix mode" "WARNING"
331
+        resolved_path=$(ensure_unique_destination_path "$desired_path")
332
+        if [[ -z "$resolved_path" ]]; then
333
+            return 1
334
+        fi
335
+        RESOLVED_DESTINATION_PATH="$resolved_path"
336
+        reserve_destination_path "$RESOLVED_DESTINATION_PATH"
337
+        return 0
338
+    fi
339
+
340
+    case "$choice" in
341
+        suffix)
342
+            resolved_path=$(ensure_unique_destination_path "$desired_path")
343
+            ;;
344
+        suffix_all)
345
+            CONFLICT_APPLY_ALL="suffix"
346
+            resolved_path=$(ensure_unique_destination_path "$desired_path")
347
+            ;;
348
+        skip)
349
+            return 3
350
+            ;;
351
+        skip_all)
352
+            CONFLICT_APPLY_ALL="skip"
353
+            return 3
354
+            ;;
355
+        abort)
356
+            return 4
357
+            ;;
358
+        *)
359
+            return 1
360
+            ;;
361
+    esac
362
+
363
+    if [[ -z "$resolved_path" ]]; then
364
+        return 1
365
+    fi
366
+
367
+    RESOLVED_DESTINATION_PATH="$resolved_path"
368
+    reserve_destination_path "$RESOLVED_DESTINATION_PATH"
369
+    return 0
370
+}
371
+
372
+extract_filesystem_date() {
373
+    # Returns yyyy-mm-dd hh:mm:ss based on filesystem mtime.
374
+    # We intentionally use mtime (not birthtime) because birthtime isn't preserved by copies
375
+    # across filesystems, while mtime can be preserved via `cp -p`.
376
+    local file="$1"
377
+    if [[ ! -e "$file" ]]; then
378
+        return 2
379
+    fi
380
+
381
+    local epoch=""
382
+
383
+    if [[ "$OSTYPE" == "darwin"* ]]; then
384
+        epoch=$(stat -f %m "$file" 2>/dev/null || echo "")
385
+        [[ -n "$epoch" ]] || return 2
386
+        date -j -r "$epoch" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || return 2
387
+        return 0
388
+    else
389
+        epoch=$(stat -c %Y "$file" 2>/dev/null || echo "")
390
+        [[ -n "$epoch" ]] || return 2
391
+        date -d "@$epoch" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || return 2
392
+        return 0
393
+    fi
394
+}
395
+
396
+filesystem_date_reference() {
397
+    local file="$1"
398
+    local dir base stem ext sidecar_ext sidecar
399
+    dir=$(dirname "$file")
400
+    base=$(basename "$file")
401
+    stem="${base%.*}"
402
+    ext="${base##*.}"
403
+
404
+    if [[ "$ext" =~ ^([Mm][Pp]4)$ ]]; then
405
+        for sidecar_ext in THM thm LRV lrv; do
406
+            sidecar="$dir/$stem.$sidecar_ext"
407
+            if [[ -f "$sidecar" ]]; then
408
+                echo "$sidecar"
409
+                return 0
410
+            fi
411
+        done
412
+    fi
413
+
414
+    echo "$file"
415
+}
416
+
417
+is_gopro_media_file() {
418
+    local filename
419
+    filename=$(basename "$1")
420
+    [[ "$filename" =~ ^G[HPX][0-9]{6}\.[Mm][Pp]4$ ]]
421
+}
422
+
423
+should_prefer_gopro_filesystem_date() {
424
+    local file="$1"
425
+
426
+    is_gopro_media_file "$file"
427
+}
428
+
429
+filesystem_date_source_label() {
430
+    local file="$1"
431
+    local reference="$2"
432
+
433
+    if is_gopro_media_file "$file"; then
434
+        echo "Filesystem:$(basename "$reference")"
435
+    elif [[ "$reference" != "$file" ]]; then
436
+        echo "Filesystem:$(basename "$reference")"
437
+    else
438
+        echo "Filesystem"
439
+    fi
440
+}
441
+
442
+date_to_exiftool_format() {
443
+    # yyyy-mm-dd hh:mm:ss -> yyyy:mm:dd hh:mm:ss
444
+    local s="$1"
445
+    if [[ "$s" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})[[:space:]]+([0-9]{2}):([0-9]{2}):([0-9]{2})$ ]]; then
446
+        echo "${BASH_REMATCH[1]}:${BASH_REMATCH[2]}:${BASH_REMATCH[3]} ${BASH_REMATCH[4]}:${BASH_REMATCH[5]}:${BASH_REMATCH[6]}"
447
+        return 0
448
+    fi
449
+    return 1
450
+}
451
+
452
+sync_destination_metadata_to_date() {
453
+    local file="$1"
454
+    local date_str="$2" # yyyy-mm-dd hh:mm:ss
455
+
456
+    local exif_dt
457
+    exif_dt=$(date_to_exiftool_format "$date_str") || return 1
458
+
459
+    exiftool -overwrite_original \
460
+        "-CreateDate=$exif_dt" \
461
+        "-DateTimeOriginal=$exif_dt" \
462
+        "-DateTime=$exif_dt" \
463
+        "-ModifyDate=$exif_dt" \
464
+        "-MediaCreateDate=$exif_dt" \
465
+        "-TrackCreateDate=$exif_dt" \
466
+        "-QuickTime:CreateDate=$exif_dt" \
467
+        "-QuickTime:ModifyDate=$exif_dt" \
468
+        "$file" >/dev/null 2>&1
469
+    return 0
470
+}
471
+
472
+verify_synced_metadata_date() {
473
+    local file="$1"
474
+    local expected_date="$2"
475
+
476
+    local metadata_date
477
+    metadata_date=$(exiftool -api QuickTimeUTC=0 -s -s -s -QuickTime:CreateDate "$file" 2>/dev/null | head -1)
478
+    if [[ -z "$metadata_date" ]]; then
479
+        metadata_date=$(exiftool -api QuickTimeUTC=0 -s -s -s -CreateDate "$file" 2>/dev/null | head -1)
480
+    fi
481
+
482
+    if [[ "$metadata_date" =~ ^([0-9]{4}):([0-9]{2}):([0-9]{2})[[:space:]]+([0-9]{2}):([0-9]{2}):([0-9]{2}) ]]; then
483
+        metadata_date="${BASH_REMATCH[1]}-${BASH_REMATCH[2]}-${BASH_REMATCH[3]} ${BASH_REMATCH[4]}:${BASH_REMATCH[5]}:${BASH_REMATCH[6]}"
484
+    fi
485
+
486
+    if [[ "$metadata_date" != "$expected_date" ]]; then
487
+        log_message "Destination metadata sync mismatch: expected $expected_date, got ${metadata_date:-none} for $file" "ERROR"
488
+        return 1
489
+    fi
490
+
491
+    return 0
492
+}
493
+
494
+should_sync_imported_metadata() {
495
+    local original_filename="$1"
496
+    local date_source="$2"
497
+
498
+    if [[ $SYNC_METADATA -eq 1 ]]; then
499
+        return 0
500
+    fi
501
+
502
+    if [[ "$date_source" == Filesystem* ]] && is_gopro_media_file "$original_filename"; then
503
+        return 0
504
+    fi
505
+
506
+    return 1
243 507
 }
244 508
 
245 509
 verify_copied_file() {
@@ -298,12 +562,39 @@ copy_with_verification() {
298 562
     local dst="$2"
299 563
     local expected_date="$3"
300 564
 
301
-    if ! safe_cp "$src" "$dst"; then
565
+    if [[ -e "$dst" ]]; then
566
+        log_message "Refusing to overwrite existing destination: $dst" "ERROR"
302 567
         return 1
303 568
     fi
304 569
 
305
-    if ! verify_copied_file "$src" "$dst" "$expected_date"; then
306
-        rm -f "$dst"
570
+    local dst_dir tmp
571
+    dst_dir=$(dirname "$dst")
572
+    tmp=$(mktemp "$dst_dir/.media-importer.$(basename "$dst").tmp.XXXXXX") || return 1
573
+    rm -f "$tmp"
574
+
575
+    if ! safe_cp "$src" "$tmp"; then
576
+        rm -f "$tmp"
577
+        return 1
578
+    fi
579
+
580
+    if ! verify_copied_file "$src" "$tmp" "$expected_date"; then
581
+        rm -f "$tmp"
582
+        return 1
583
+    fi
584
+
585
+    if [[ -e "$dst" ]]; then
586
+        log_message "Destination appeared during copy, refusing to overwrite: $dst" "ERROR"
587
+        rm -f "$tmp"
588
+        return 1
589
+    fi
590
+
591
+    if ! safe_mv "$tmp" "$dst"; then
592
+        rm -f "$tmp"
593
+        return 1
594
+    fi
595
+
596
+    if [[ ! -f "$dst" ]]; then
597
+        log_message "Copied file missing after final move: $dst" "ERROR"
307 598
         return 1
308 599
     fi
309 600
 
@@ -341,12 +632,62 @@ format_size() {
341 632
     fi
342 633
 }
343 634
 
635
+format_duration() {
636
+    local total_seconds=$1
637
+    local hours=$((total_seconds / 3600))
638
+    local minutes=$(((total_seconds % 3600) / 60))
639
+    local seconds=$((total_seconds % 60))
640
+
641
+    if (( hours > 0 )); then
642
+        printf "%dh %02dm %02ds" "$hours" "$minutes" "$seconds"
643
+    elif (( minutes > 0 )); then
644
+        printf "%dm %02ds" "$minutes" "$seconds"
645
+    else
646
+        printf "%ds" "$seconds"
647
+    fi
648
+}
649
+
650
+format_processing_rate() {
651
+    local files_count="$1"
652
+    local bytes_count="$2"
653
+    local elapsed_seconds="$3"
654
+
655
+    awk -v files="$files_count" -v bytes="$bytes_count" -v seconds="$elapsed_seconds" '
656
+        BEGIN {
657
+            if (seconds <= 0 || files <= 0) {
658
+                exit
659
+            }
660
+
661
+            files_per_second = files / seconds
662
+            files_per_minute = files * 60 / seconds
663
+            mb_per_second = bytes / seconds / 1048576
664
+
665
+            if (files_per_second >= 1) {
666
+                printf "%.2f files/sec, %.2f MB/sec", files_per_second, mb_per_second
667
+            } else {
668
+                printf "%.2f files/min, %.2f MB/sec", files_per_minute, mb_per_second
669
+            }
670
+        }
671
+    '
672
+}
673
+
344 674
 # Function to extract date from file
345 675
 extract_file_date() {
346 676
     local file="$1"
347 677
     local create_date=""
348 678
     local date_source=""
349 679
     local exif_found=0
680
+
681
+    # Filesystem authoritative mode, and GoPro media in auto mode.
682
+    # GoPro fallback order is THM, LRV, then the MP4 filesystem timestamp.
683
+    if [[ "$DATE_SOURCE" == "filesystem" ]] || { [[ "$DATE_SOURCE" == "auto" ]] && should_prefer_gopro_filesystem_date "$file"; }; then
684
+        local filesystem_reference
685
+        filesystem_reference=$(filesystem_date_reference "$file")
686
+        create_date=$(extract_filesystem_date "$filesystem_reference") || return 2
687
+        echo "$create_date|$(filesystem_date_source_label "$file" "$filesystem_reference")"
688
+        return 0
689
+    fi
690
+
350 691
     # Try to get creation date from EXIF data
351 692
     local exif_output=$(exiftool -G1 -s -CreateDate -DateTimeOriginal -DateTime "$file" 2>/dev/null)
352 693
     if [[ -n "$exif_output" ]]; then
@@ -383,6 +724,15 @@ extract_file_date() {
383 724
             date_source="MediaInfo:Recorded_Date"
384 725
         fi
385 726
     fi
727
+
728
+    # In auto mode, if metadata is missing/unreliable, fall back to filesystem timestamps
729
+    if [[ -z "$create_date" && "$DATE_SOURCE" == "auto" ]]; then
730
+        local filesystem_reference
731
+        filesystem_reference=$(filesystem_date_reference "$file")
732
+        create_date=$(extract_filesystem_date "$filesystem_reference") || return 2
733
+        date_source=$(filesystem_date_source_label "$file" "$filesystem_reference")
734
+    fi
735
+
386 736
     # If no EXIF or mediainfo date found, return failure
387 737
     if [[ -z "$create_date" ]]; then
388 738
         return 2  # No date metadata found
@@ -570,11 +920,14 @@ find_source_files() {
570 920
         fi
571 921
         # Add expression
572 922
         # shellcheck disable=SC2068
573
-        "${find_cmd[@]}" \( $ext_expr \) 2>/dev/null || true
923
+        "${find_cmd[@]}" ! -name '._*' \( $ext_expr \) 2>/dev/null || true
574 924
     else
575 925
         # Scan each provided source
576 926
         for src in "${SOURCE_PATTERNS[@]}"; do
577 927
             if [[ -f "$src" ]]; then
928
+                if [[ "$(basename "$src")" == ._* ]]; then
929
+                    continue
930
+                fi
578 931
                 # single file - skip if it's inside dest
579 932
                 local abs_file
580 933
                 abs_file=$(cd "$(dirname "$src")" 2>/dev/null && pwd)/$(basename "$src")
@@ -587,9 +940,9 @@ find_source_files() {
587 940
                 abs_src=$(cd "$src" 2>/dev/null && pwd)
588 941
                 if [[ -n "$abs_src" ]]; then
589 942
                     if [[ -n "$abs_dest" && "$abs_dest" == "$abs_src"* ]]; then
590
-                        find -L "$abs_src" -type f \( $ext_expr \) ! -path "$abs_dest/*" ! -path "$abs_dest" 2>/dev/null || true
943
+                        find -L "$abs_src" -type f ! -name '._*' \( $ext_expr \) ! -path "$abs_dest/*" ! -path "$abs_dest" 2>/dev/null || true
591 944
                     else
592
-                        find -L "$abs_src" -type f \( $ext_expr \) 2>/dev/null || true
945
+                        find -L "$abs_src" -type f ! -name '._*' \( $ext_expr \) 2>/dev/null || true
593 946
                     fi
594 947
                 else
595 948
                     print_color "$YELLOW" "Warning: Could not resolve source directory: $src"
@@ -605,8 +958,15 @@ find_source_files() {
605 958
 process_file() {
606 959
     local file="$1"
607 960
     local file_size=$(get_file_size "$file")
961
+    local file_label
962
+    file_label="$(basename "$file")"
608 963
     TOTAL_SIZE=$((TOTAL_SIZE + file_size))
609 964
     
965
+    if [[ $TOTAL_FILES -gt 0 && $CURRENT_FILE_INDEX -gt 0 ]]; then
966
+        print_color "$BLUE" "Processing [$CURRENT_FILE_INDEX/$TOTAL_FILES]: $file_label ($(format_size "$file_size"))"
967
+    else
968
+        print_color "$BLUE" "Processing: $file_label ($(format_size "$file_size"))"
969
+    fi
610 970
     log_message "Processing: $file" "INFO"
611 971
     
612 972
     # Extract date information
@@ -617,6 +977,29 @@ process_file() {
617 977
                 local unsortable_dir="$DESTINATION/unsortable"
618 978
                 mkdir -p "$unsortable_dir"
619 979
                 local unsortable_path="$unsortable_dir/$(basename "$file")"
980
+                local desired_unsortable_path="$unsortable_path"
981
+                local unsortable_conflict_status
982
+                resolve_destination_conflict "$unsortable_path" "$file"
983
+                unsortable_conflict_status=$?
984
+                if [[ $unsortable_conflict_status -eq 0 ]]; then
985
+                    unsortable_path="$RESOLVED_DESTINATION_PATH"
986
+                    if [[ "$unsortable_path" != "$desired_unsortable_path" ]]; then
987
+                        log_message "Destination already exists or is already planned: $desired_unsortable_path - using: $unsortable_path" "WARNING"
988
+                    fi
989
+                elif [[ $unsortable_conflict_status -eq 3 ]]; then
990
+                    log_message "Destination conflict skipped: $desired_unsortable_path" "WARNING"
991
+                    SKIPPED_FILES=$((SKIPPED_FILES + 1))
992
+                    return 1
993
+                elif [[ $unsortable_conflict_status -eq 4 ]]; then
994
+                    log_message "Import aborted by user at destination conflict: $desired_unsortable_path" "ERROR"
995
+                    ERROR_FILES=$((ERROR_FILES + 1))
996
+                    FATAL_ERROR=1
997
+                    return 2
998
+                else
999
+                    log_message "Could not resolve a unique destination path for $file (wanted: $desired_unsortable_path)" "ERROR"
1000
+                    ERROR_FILES=$((ERROR_FILES + 1))
1001
+                    return 1
1002
+                fi
620 1003
                 if [[ $DRY_RUN -eq 1 ]]; then
621 1004
                     print_color "$BLUE" "Would move unsortable: $file -> $unsortable_path"
622 1005
                 else
@@ -641,21 +1024,43 @@ process_file() {
641 1024
         local date_source="${date_info#*|}"
642 1025
         log_message "Date: $date_str (from $date_source)" "INFO"
643 1026
         # Generate destination path
644
-        local dest_path=$(generate_destination_path "$date_str" "$(basename "$file")" "$DESTINATION")
1027
+        local original_basename
1028
+        original_basename="$(basename "$file")"
1029
+        local dest_path
1030
+        dest_path=$(generate_destination_path "$date_str" "$original_basename" "$DESTINATION")
645 1031
         if [[ $? -ne 0 ]] || [[ -z "$dest_path" ]]; then
646 1032
             log_message "Could not generate destination path for $file" "ERROR"
647 1033
             ERROR_FILES=$((ERROR_FILES + 1))
648 1034
             FATAL_ERROR=1
649 1035
             return 2
650 1036
     fi
651
-    
652
-    # If destination exists, do not attempt complex conflict resolution here.
653
-    # Let external tools (exiftool) or filesystem semantics handle renames/overwrites.
654
-    if [[ -f "$dest_path" ]]; then
655
-        log_message "Destination already exists: $dest_path - proceeding to move/copy and letting external tools handle conflicts" "WARNING"
1037
+
1038
+    local desired_dest_path="$dest_path"
1039
+    local conflict_status
1040
+    resolve_destination_conflict "$dest_path" "$file"
1041
+    conflict_status=$?
1042
+    if [[ $conflict_status -eq 0 ]]; then
1043
+        dest_path="$RESOLVED_DESTINATION_PATH"
1044
+    elif [[ $conflict_status -eq 3 ]]; then
1045
+        log_message "Destination conflict skipped: $desired_dest_path" "WARNING"
1046
+        SKIPPED_FILES=$((SKIPPED_FILES + 1))
1047
+        return 1
1048
+    elif [[ $conflict_status -eq 4 ]]; then
1049
+        log_message "Import aborted by user at destination conflict: $desired_dest_path" "ERROR"
1050
+        ERROR_FILES=$((ERROR_FILES + 1))
1051
+        FATAL_ERROR=1
1052
+        return 2
1053
+    else
1054
+        log_message "Could not resolve a unique destination path for $file (wanted: $desired_dest_path)" "ERROR"
1055
+        ERROR_FILES=$((ERROR_FILES + 1))
1056
+        return 1
656 1057
     fi
657
-    
658
-    local dest_dir=$(dirname "$dest_path")
1058
+    if [[ "$dest_path" != "$desired_dest_path" ]]; then
1059
+        log_message "Destination already exists or is already planned: $desired_dest_path - using: $dest_path" "WARNING"
1060
+    fi
1061
+
1062
+    local dest_dir
1063
+    dest_dir=$(dirname "$dest_path")
659 1064
     
660 1065
     if [[ $DRY_RUN -eq 1 ]]; then
661 1066
         if [[ $KEEP_ORIGINALS -eq 1 ]]; then
@@ -663,6 +1068,9 @@ process_file() {
663 1068
         else
664 1069
             print_color "$BLUE" "Would move: $file -> $dest_path"
665 1070
         fi
1071
+        if should_sync_imported_metadata "$original_basename" "$date_source"; then
1072
+            print_color "$BLUE" "Would sync metadata date: $dest_path -> $date_str"
1073
+        fi
666 1074
         PROCESSED_FILES=$((PROCESSED_FILES + 1))
667 1075
         PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
668 1076
         return 0
@@ -675,9 +1083,26 @@ process_file() {
675 1083
         return 1
676 1084
     fi
677 1085
     
678
-    # Copy or move file using safe helpers (filter benign stderr). Let external tools handle renaming conflicts.
1086
+    local sync_metadata_after_copy=0
1087
+    local verification_date="$date_str"
1088
+    if should_sync_imported_metadata "$original_basename" "$date_source"; then
1089
+        sync_metadata_after_copy=1
1090
+        verification_date=""
1091
+    fi
1092
+
1093
+    # Copy or move file using safe helpers after destination conflicts are resolved.
679 1094
     if [[ $KEEP_ORIGINALS -eq 1 ]]; then
680
-        if copy_with_verification "$file" "$dest_path" "$date_str"; then
1095
+        if copy_with_verification "$file" "$dest_path" "$verification_date"; then
1096
+            if [[ $sync_metadata_after_copy -eq 1 ]]; then
1097
+                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1098
+                    log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR"
1099
+                    ERROR_FILES=$((ERROR_FILES + 1))
1100
+                    return 1
1101
+                elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1102
+                    ERROR_FILES=$((ERROR_FILES + 1))
1103
+                    return 1
1104
+                fi
1105
+            fi
681 1106
             log_message "Copied: $file -> $dest_path" "SUCCESS"
682 1107
             PROCESSED_FILES=$((PROCESSED_FILES + 1))
683 1108
             PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
@@ -688,8 +1113,40 @@ process_file() {
688 1113
             return 1
689 1114
         fi
690 1115
     else
691
-        if verified_move_file "$file" "$dest_path" "$date_str"; then
1116
+        if [[ $sync_metadata_after_copy -eq 1 ]]; then
1117
+            if copy_with_verification "$file" "$dest_path" "$verification_date"; then
1118
+                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1119
+                    log_message "Failed to sync destination metadata timestamps: $dest_path" "ERROR"
1120
+                    ERROR_FILES=$((ERROR_FILES + 1))
1121
+                    return 1
1122
+                fi
1123
+                if ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1124
+                    ERROR_FILES=$((ERROR_FILES + 1))
1125
+                    return 1
1126
+                fi
1127
+                if ! remove_source_file "$file"; then
1128
+                    log_message "Copied, verified, and synced destination, but failed to remove source: $file" "ERROR"
1129
+                    ERROR_FILES=$((ERROR_FILES + 1))
1130
+                    return 1
1131
+                fi
1132
+                log_message "Moved: $file -> $dest_path" "SUCCESS"
1133
+                PROCESSED_FILES=$((PROCESSED_FILES + 1))
1134
+                PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
1135
+                return 0
1136
+            else
1137
+                log_message "Failed to move after copy verification: $file -> $dest_path" "ERROR"
1138
+                ERROR_FILES=$((ERROR_FILES + 1))
1139
+                return 1
1140
+            fi
1141
+        elif verified_move_file "$file" "$dest_path" "$date_str"; then
692 1142
             log_message "Moved: $file -> $dest_path" "SUCCESS"
1143
+            if [[ $sync_metadata_after_copy -eq 1 ]]; then
1144
+                if ! sync_destination_metadata_to_date "$dest_path" "$date_str"; then
1145
+                    log_message "Failed to sync destination metadata timestamps: $dest_path" "WARNING"
1146
+                elif ! verify_synced_metadata_date "$dest_path" "$date_str"; then
1147
+                    log_message "Failed to verify synced destination metadata timestamps: $dest_path" "WARNING"
1148
+                fi
1149
+            fi
693 1150
             PROCESSED_FILES=$((PROCESSED_FILES + 1))
694 1151
             PROCESSED_SIZE=$((PROCESSED_SIZE + file_size))
695 1152
             return 0
@@ -715,26 +1172,9 @@ show_report() {
715 1172
     print_color "$GREEN" "=========================================="
716 1173
     echo ""
717 1174
     
718
-    echo "Files Summary:"
719
-    echo "  Total files found:     $TOTAL_FILES"
720
-    echo "  Successfully processed: $PROCESSED_FILES"
721
-    echo "  Skipped (no date):     $SKIPPED_FILES"
722
-    echo "  Errors:                $ERROR_FILES"
723
-    echo ""
724
-    
725 1175
     echo "Size Summary:"
726
-    echo "  Total size found:      $(format_size $TOTAL_SIZE)"
727
-    echo "  Successfully processed: $(format_size $PROCESSED_SIZE)"
728
-    echo ""
729
-    
730
-    echo "Time Summary:"
731
-    printf "  Time elapsed:          %02d:%02d:%02d\n" $hours $minutes $seconds
732
-    
733
-    if [[ $elapsed_time -gt 0 && $PROCESSED_FILES -gt 0 ]]; then
734
-        local files_per_second=$((PROCESSED_FILES / elapsed_time))
735
-        local mb_per_second=$((PROCESSED_SIZE / elapsed_time / 1048576))
736
-        echo "  Speed:                 $files_per_second files/sec, ${mb_per_second}MB/sec"
737
-    fi
1176
+    printf "  %-22s %s\n" "Total size found:" "$(format_size $TOTAL_SIZE)"
1177
+    printf "  %-22s %s\n" "Successfully processed:" "$(format_size $PROCESSED_SIZE)"
738 1178
     
739 1179
     echo ""
740 1180
     
@@ -750,6 +1190,10 @@ show_report() {
750 1190
     print_color "$GREEN" "=========================================="
751 1191
 }
752 1192
 
1193
+if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
1194
+    return 0
1195
+fi
1196
+
753 1197
 # Parse command line arguments
754 1198
 while [[ $# -gt 0 ]]; do
755 1199
     case $1 in
@@ -800,6 +1244,22 @@ while [[ $# -gt 0 ]]; do
800 1244
             fi
801 1245
             shift 2
802 1246
             ;;
1247
+        --date-source)
1248
+            DATE_SOURCE="$2"
1249
+            if [[ ! "$DATE_SOURCE" =~ ^(auto|exif|filesystem)$ ]]; then
1250
+                print_color "$RED" "Error: Invalid date source. Must be one of: auto, exif, filesystem"
1251
+                exit 1
1252
+            fi
1253
+            shift 2
1254
+            ;;
1255
+        --sync-metadata)
1256
+            SYNC_METADATA=1
1257
+            shift
1258
+            ;;
1259
+        --unattended)
1260
+            UNATTENDED=1
1261
+            shift
1262
+            ;;
803 1263
         --dry-run)
804 1264
             DRY_RUN=1
805 1265
             shift
@@ -824,6 +1284,11 @@ while [[ $# -gt 0 ]]; do
824 1284
     esac
825 1285
 done
826 1286
 
1287
+# Non-interactive execution cannot safely ask conflict questions.
1288
+if [[ $UNATTENDED -eq 0 && ( ! -t 0 || ! -t 1 ) ]]; then
1289
+    UNATTENDED=1
1290
+fi
1291
+
827 1292
 # If no organization is provided, leave ORGANIZATION empty and filename mode will decide naming
828 1293
 
829 1294
 # If no source specified, default to current directory. Refuse to run when cwd is unsafe.
@@ -877,6 +1342,9 @@ echo "  Organization pattern: $ORGANIZATION"
877 1342
 echo "  Destination:         $DESTINATION"
878 1343
 echo "  Keep originals:      $([ $KEEP_ORIGINALS -eq 1 ] && echo "Yes" || echo "No")"
879 1344
 echo "  Verify mode:         $VERIFY_MODE"
1345
+echo "  Date source:         $DATE_SOURCE"
1346
+echo "  Sync metadata:       $([ $SYNC_METADATA -eq 1 ] && echo "Yes" || echo "No")"
1347
+echo "  Unattended:          $([ $UNATTENDED -eq 1 ] && echo "Yes" || echo "No")"
880 1348
 echo "  Dry run:             $([ $DRY_RUN -eq 1 ] && echo "Yes" || echo "No")"
881 1349
 echo "  Keep empty dirs:     $([ $CLEANUP_EMPTY_DIRS -eq 0 ] && echo "Yes" || echo "No")"
882 1350
 echo "  Verbose:             $([ $VERBOSE -eq 1 ] && echo "Yes" || echo "No")"
@@ -910,6 +1378,10 @@ files=()
910 1378
 while IFS= read -r file; do
911 1379
     files+=("$file")
912 1380
 done < <(find_source_files)
1381
+if [[ ${#files[@]} -gt 0 ]]; then
1382
+    IFS=$'\n' files=($(printf '%s\n' "${files[@]}" | sort))
1383
+    unset IFS
1384
+fi
913 1385
 TOTAL_FILES=${#files[@]}
914 1386
 
915 1387
 if [[ $TOTAL_FILES -eq 0 ]]; then
@@ -925,6 +1397,7 @@ echo ""
925 1397
 FATAL_ERROR=0
926 1398
 for file in "${files[@]}"; do
927 1399
     if [[ -f "$file" ]]; then
1400
+        CURRENT_FILE_INDEX=$((CURRENT_FILE_INDEX + 1))
928 1401
         process_file "$file"
929 1402
         if [[ $FATAL_ERROR -eq 1 ]]; then
930 1403
             print_color "$RED" "Fatal error encountered. Stopping further processing."
+304 -5
test_runner.sh
@@ -614,6 +614,278 @@ run_verify_mode_test() {
614 614
         "$result"
615 615
 }
616 616
 
617
+# Test Case 10: Timestamp Collision No-Overwrite Test
618
+run_timestamp_collision_no_overwrite_test() {
619
+    print_color "$GREEN" "=== Running Test 10: Timestamp Collision No-Overwrite Test ==="
620
+
621
+    clean_test_dir
622
+    create_test_dirs
623
+
624
+    local log_file="$TEST_DIR/import_log.txt"
625
+    local result=0
626
+    : > "$log_file"
627
+
628
+    if ! command -v ffmpeg >/dev/null 2>&1; then
629
+        echo "ffmpeg is required for this regression test" >> "$log_file"
630
+        result=1
631
+    elif ! command -v exiftool >/dev/null 2>&1; then
632
+        echo "exiftool is required for this regression test" >> "$log_file"
633
+        result=1
634
+    else
635
+        ffmpeg -hide_banner -loglevel error -f lavfi -i color=c=blue:s=160x120:d=1 -c:v libx264 -pix_fmt yuv420p "$SOURCE_DIR/GX011621.MP4" >> "$log_file" 2>&1 || result=1
636
+        ffmpeg -hide_banner -loglevel error -f lavfi -i color=c=red:s=160x120:d=1 -c:v libx264 -pix_fmt yuv420p "$SOURCE_DIR/GX021621.MP4" >> "$log_file" 2>&1 || result=1
637
+        ffmpeg -hide_banner -loglevel error -f lavfi -i color=c=green:s=160x120:d=1 -c:v libx264 -pix_fmt yuv420p "$SOURCE_DIR/GX031621.MP4" >> "$log_file" 2>&1 || result=1
638
+        exiftool -overwrite_original -CreateDate="2026:05:15 19:20:09" "$SOURCE_DIR/GX011621.MP4" "$SOURCE_DIR/GX021621.MP4" "$SOURCE_DIR/GX031621.MP4" >> "$log_file" 2>&1 || result=1
639
+        touch -t 202605151920.09 "$SOURCE_DIR/GX011621.MP4" "$SOURCE_DIR/GX021621.MP4" "$SOURCE_DIR/GX031621.MP4" || result=1
640
+    fi
641
+
642
+    # Capture pre-test state
643
+    capture_state "$SOURCE_DIR" "$TEST_DIR/source_before.txt" "source"
644
+    capture_state "$DEST_DIR" "$TEST_DIR/dest_before.txt" "destination"
645
+
646
+    local command="\"$MEDIA_IMPORTER\" -s \"$SOURCE_DIR\" -d \"$DEST_DIR\" --verify-mode size -v"
647
+    if [[ $result -eq 0 ]]; then
648
+        run_test_command "$command" "$log_file" || result=$?
649
+    fi
650
+
651
+    local dest_count
652
+    dest_count=$(find "$DEST_DIR" -type f -iname "*.mp4" | wc -l | tr -d ' ')
653
+    if [[ "$dest_count" != "3" ]]; then
654
+        echo "Expected 3 destination MP4 files after timestamp collision import, found $dest_count" >> "$log_file"
655
+        result=1
656
+    fi
657
+
658
+    local source_count
659
+    source_count=$(find "$SOURCE_DIR" -type f -iname "*.mp4" | wc -l | tr -d ' ')
660
+    if [[ "$source_count" != "0" ]]; then
661
+        echo "Expected source MP4 files to be moved, found $source_count remaining" >> "$log_file"
662
+        result=1
663
+    fi
664
+
665
+    local unique_names
666
+    unique_names=$(find "$DEST_DIR" -type f -iname "*.mp4" -exec basename {} \; | sort -u | wc -l | tr -d ' ')
667
+    if [[ "$unique_names" != "3" ]]; then
668
+        echo "Expected 3 unique destination filenames, found $unique_names" >> "$log_file"
669
+        result=1
670
+    fi
671
+
672
+    local expected_base="$DEST_DIR/2026-05-15/2026-05-15_19-20-09.mp4"
673
+    local expected_suffix_1="$DEST_DIR/2026-05-15/2026-05-15_19-20-09_1.mp4"
674
+    local expected_suffix_2="$DEST_DIR/2026-05-15/2026-05-15_19-20-09_2.mp4"
675
+    if [[ ! -f "$expected_base" || ! -f "$expected_suffix_1" || ! -f "$expected_suffix_2" ]]; then
676
+        echo "Expected unattended numeric conflict suffixes _1 and _2 were not created" >> "$log_file"
677
+        result=1
678
+    fi
679
+
680
+    local legacy_suffix_count
681
+    legacy_suffix_count=$(find "$DEST_DIR" -type f -name "*__GX*.mp4" | wc -l | tr -d ' ')
682
+    if [[ "$legacy_suffix_count" != "0" ]]; then
683
+        echo "Expected no legacy original-name conflict suffixes, found $legacy_suffix_count" >> "$log_file"
684
+        result=1
685
+    fi
686
+
687
+    # Capture post-test state
688
+    capture_state "$SOURCE_DIR" "$TEST_DIR/source_after.txt" "source"
689
+    capture_state "$DEST_DIR" "$TEST_DIR/dest_after.txt" "destination"
690
+
691
+    # Generate report
692
+    generate_test_report \
693
+        "Timestamp_Collision_No_Overwrite" \
694
+        "Processing multiple GoPro-style chapters with identical CreateDate values" \
695
+        "Verify timestamp filename collisions do not overwrite prior imports or delete sources" \
696
+        "Synthetic MP4 files with identical QuickTime CreateDate metadata" \
697
+        "$command" \
698
+        "$result"
699
+}
700
+
701
+# Test Case 11: GoPro Sidecar Metadata Sync Test
702
+run_gopro_sidecar_metadata_sync_test() {
703
+    print_color "$GREEN" "=== Running Test 11: GoPro Sidecar Metadata Sync Test ==="
704
+
705
+    clean_test_dir
706
+    create_test_dirs
707
+
708
+    local log_file="$TEST_DIR/import_log.txt"
709
+    local result=0
710
+    : > "$log_file"
711
+
712
+    if ! command -v ffmpeg >/dev/null 2>&1; then
713
+        echo "ffmpeg is required for this regression test" >> "$log_file"
714
+        result=1
715
+    elif ! command -v exiftool >/dev/null 2>&1; then
716
+        echo "exiftool is required for this regression test" >> "$log_file"
717
+        result=1
718
+    else
719
+        ffmpeg -hide_banner -loglevel error -f lavfi -i color=c=blue:s=160x120:d=1 -c:v libx264 -pix_fmt yuv420p "$SOURCE_DIR/GX011622.MP4" >> "$log_file" 2>&1 || result=1
720
+        ffmpeg -hide_banner -loglevel error -f lavfi -i color=c=red:s=160x120:d=1 -c:v libx264 -pix_fmt yuv420p "$SOURCE_DIR/GX021622.MP4" >> "$log_file" 2>&1 || result=1
721
+        ffmpeg -hide_banner -loglevel error -f lavfi -i color=c=green:s=160x120:d=1 -c:v libx264 -pix_fmt yuv420p "$SOURCE_DIR/GX031622.MP4" >> "$log_file" 2>&1 || result=1
722
+        ffmpeg -hide_banner -loglevel error -f lavfi -i color=c=yellow:s=160x120:d=1 -c:v libx264 -pix_fmt yuv420p "$SOURCE_DIR/GX041622.MP4" >> "$log_file" 2>&1 || result=1
723
+        exiftool -overwrite_original -QuickTime:CreateDate="2026:05:16 19:03:19" "$SOURCE_DIR/GX011622.MP4" "$SOURCE_DIR/GX021622.MP4" "$SOURCE_DIR/GX031622.MP4" "$SOURCE_DIR/GX041622.MP4" >> "$log_file" 2>&1 || result=1
724
+        touch -t 202605161903.20 "$SOURCE_DIR/GX011622.THM" || result=1
725
+        touch -t 202605161915.08 "$SOURCE_DIR/GX021622.THM" || result=1
726
+        touch -t 202605161926.56 "$SOURCE_DIR/GX031622.LRV" || result=1
727
+        touch -t 202605161938.44 "$SOURCE_DIR/GX041622.MP4" || result=1
728
+    fi
729
+
730
+    capture_state "$SOURCE_DIR" "$TEST_DIR/source_before.txt" "source"
731
+    capture_state "$DEST_DIR" "$TEST_DIR/dest_before.txt" "destination"
732
+
733
+    local command="\"$MEDIA_IMPORTER\" -s \"$SOURCE_DIR\" -d \"$DEST_DIR\" --verify-mode size -v"
734
+    if [[ $result -eq 0 ]]; then
735
+        run_test_command "$command" "$log_file" || result=$?
736
+    fi
737
+
738
+    local first_dest="$DEST_DIR/2026-05-16/2026-05-16_19-03-20.mp4"
739
+    local second_dest="$DEST_DIR/2026-05-16/2026-05-16_19-15-08.mp4"
740
+    local third_dest="$DEST_DIR/2026-05-16/2026-05-16_19-26-56.mp4"
741
+    local fourth_dest="$DEST_DIR/2026-05-16/2026-05-16_19-38-44.mp4"
742
+    if [[ ! -f "$first_dest" || ! -f "$second_dest" || ! -f "$third_dest" || ! -f "$fourth_dest" ]]; then
743
+        echo "Expected GoPro filesystem-based destination filenames were not created" >> "$log_file"
744
+        result=1
745
+    fi
746
+
747
+    local first_metadata second_metadata third_metadata fourth_metadata
748
+    if [[ -f "$first_dest" ]]; then
749
+        first_metadata=$(exiftool -api QuickTimeUTC=0 -s -s -s -QuickTime:CreateDate "$first_dest" 2>/dev/null | head -1)
750
+        if [[ "$first_metadata" != "2026:05:16 19:03:20" ]]; then
751
+            echo "Expected first imported metadata CreateDate 2026:05:16 19:03:20, got ${first_metadata:-none}" >> "$log_file"
752
+            result=1
753
+        fi
754
+    fi
755
+    if [[ -f "$third_dest" ]]; then
756
+        third_metadata=$(exiftool -api QuickTimeUTC=0 -s -s -s -QuickTime:CreateDate "$third_dest" 2>/dev/null | head -1)
757
+        if [[ "$third_metadata" != "2026:05:16 19:26:56" ]]; then
758
+            echo "Expected third imported metadata CreateDate 2026:05:16 19:26:56, got ${third_metadata:-none}" >> "$log_file"
759
+            result=1
760
+        fi
761
+    fi
762
+    if [[ -f "$fourth_dest" ]]; then
763
+        fourth_metadata=$(exiftool -api QuickTimeUTC=0 -s -s -s -QuickTime:CreateDate "$fourth_dest" 2>/dev/null | head -1)
764
+        if [[ "$fourth_metadata" != "2026:05:16 19:38:44" ]]; then
765
+            echo "Expected fourth imported metadata CreateDate 2026:05:16 19:38:44, got ${fourth_metadata:-none}" >> "$log_file"
766
+            result=1
767
+        fi
768
+    fi
769
+    if [[ -f "$second_dest" ]]; then
770
+        second_metadata=$(exiftool -api QuickTimeUTC=0 -s -s -s -QuickTime:CreateDate "$second_dest" 2>/dev/null | head -1)
771
+        if [[ "$second_metadata" != "2026:05:16 19:15:08" ]]; then
772
+            echo "Expected second imported metadata CreateDate 2026:05:16 19:15:08, got ${second_metadata:-none}" >> "$log_file"
773
+            result=1
774
+        fi
775
+    fi
776
+
777
+    local source_count
778
+    source_count=$(find "$SOURCE_DIR" -type f -iname "*.mp4" | wc -l | tr -d ' ')
779
+    if [[ "$source_count" != "0" ]]; then
780
+        echo "Expected GoPro source MP4 files to be moved, found $source_count remaining" >> "$log_file"
781
+        result=1
782
+    fi
783
+
784
+    capture_state "$SOURCE_DIR" "$TEST_DIR/source_after.txt" "source"
785
+    capture_state "$DEST_DIR" "$TEST_DIR/dest_after.txt" "destination"
786
+
787
+    generate_test_report \
788
+        "GoPro_Sidecar_Metadata_Sync" \
789
+        "Processing GoPro-style MP4 chapters with THM, LRV, and MP4 filesystem timestamps" \
790
+        "Verify GoPro imports use filesystem fallback dates and sync destination metadata automatically" \
791
+        "Synthetic GoPro MP4 files with shared QuickTime CreateDate plus THM/LRV/no-sidecar mtime variants" \
792
+        "$command" \
793
+        "$result"
794
+}
795
+
796
+# Test Case 12: GoPro No-Sidecar Reimport Test
797
+run_gopro_no_sidecar_reimport_test() {
798
+    print_color "$GREEN" "=== Running Test 12: GoPro No-Sidecar Reimport Test ==="
799
+
800
+    clean_test_dir
801
+    create_test_dirs
802
+
803
+    local log_file="$TEST_DIR/import_log.txt"
804
+    local result=0
805
+    : > "$log_file"
806
+
807
+    if ! command -v ffmpeg >/dev/null 2>&1; then
808
+        echo "ffmpeg is required for this regression test" >> "$log_file"
809
+        result=1
810
+    elif ! command -v exiftool >/dev/null 2>&1; then
811
+        echo "exiftool is required for this regression test" >> "$log_file"
812
+        result=1
813
+    else
814
+        ffmpeg -hide_banner -loglevel error -f lavfi -i color=c=purple:s=160x120:d=1 -c:v libx264 -pix_fmt yuv420p "$SOURCE_DIR/GX051622.MP4" >> "$log_file" 2>&1 || result=1
815
+        exiftool -overwrite_original -QuickTime:CreateDate="2026:05:16 19:03:19" "$SOURCE_DIR/GX051622.MP4" >> "$log_file" 2>&1 || result=1
816
+        rm -f "$SOURCE_DIR/GX051622.THM" "$SOURCE_DIR/GX051622.thm" "$SOURCE_DIR/GX051622.LRV" "$SOURCE_DIR/GX051622.lrv"
817
+        touch -t 202605161950.32 "$SOURCE_DIR/GX051622.MP4" || result=1
818
+    fi
819
+
820
+    capture_state "$SOURCE_DIR" "$TEST_DIR/source_before.txt" "source"
821
+    capture_state "$DEST_DIR" "$TEST_DIR/dest_before.txt" "destination"
822
+
823
+    local command="\"$MEDIA_IMPORTER\" -s \"$SOURCE_DIR\" -d \"$DEST_DIR\" --verify-mode size -k -v && \"$MEDIA_IMPORTER\" -s \"$SOURCE_DIR\" -d \"$DEST_DIR\" --verify-mode size -k -v"
824
+    if [[ $result -eq 0 ]]; then
825
+        run_test_command "$command" "$log_file" || result=$?
826
+    fi
827
+
828
+    local first_dest="$DEST_DIR/2026-05-16/2026-05-16_19-50-32.mp4"
829
+    local reimport_dest="$DEST_DIR/2026-05-16/2026-05-16_19-50-32_1.mp4"
830
+    if [[ ! -f "$first_dest" || ! -f "$reimport_dest" ]]; then
831
+        echo "Expected no-sidecar GoPro reimport destinations were not created" >> "$log_file"
832
+        result=1
833
+    fi
834
+
835
+    local dest_count
836
+    dest_count=$(find "$DEST_DIR" -type f -iname "*.mp4" | wc -l | tr -d ' ')
837
+    if [[ "$dest_count" != "2" ]]; then
838
+        echo "Expected 2 destination MP4 files after reimport, found $dest_count" >> "$log_file"
839
+        result=1
840
+    fi
841
+
842
+    local source_count
843
+    source_count=$(find "$SOURCE_DIR" -type f -iname "*.mp4" | wc -l | tr -d ' ')
844
+    if [[ "$source_count" != "1" ]]; then
845
+        echo "Expected source MP4 to remain in keep-originals reimport test, found $source_count" >> "$log_file"
846
+        result=1
847
+    fi
848
+
849
+    local sidecar_count
850
+    sidecar_count=$(find "$SOURCE_DIR" -type f \( -iname "*.thm" -o -iname "*.lrv" \) | wc -l | tr -d ' ')
851
+    if [[ "$sidecar_count" != "0" ]]; then
852
+        echo "Expected no THM/LRV sidecars in no-sidecar test, found $sidecar_count" >> "$log_file"
853
+        result=1
854
+    fi
855
+
856
+    if ! grep -q "Filesystem:GX051622.MP4" "$log_file"; then
857
+        echo "Expected date source fallback to Filesystem:GX051622.MP4" >> "$log_file"
858
+        result=1
859
+    fi
860
+
861
+    local first_metadata reimport_metadata
862
+    if [[ -f "$first_dest" ]]; then
863
+        first_metadata=$(exiftool -api QuickTimeUTC=0 -s -s -s -QuickTime:CreateDate "$first_dest" 2>/dev/null | head -1)
864
+        if [[ "$first_metadata" != "2026:05:16 19:50:32" ]]; then
865
+            echo "Expected first import metadata CreateDate 2026:05:16 19:50:32, got ${first_metadata:-none}" >> "$log_file"
866
+            result=1
867
+        fi
868
+    fi
869
+    if [[ -f "$reimport_dest" ]]; then
870
+        reimport_metadata=$(exiftool -api QuickTimeUTC=0 -s -s -s -QuickTime:CreateDate "$reimport_dest" 2>/dev/null | head -1)
871
+        if [[ "$reimport_metadata" != "2026:05:16 19:50:32" ]]; then
872
+            echo "Expected reimport metadata CreateDate 2026:05:16 19:50:32, got ${reimport_metadata:-none}" >> "$log_file"
873
+            result=1
874
+        fi
875
+    fi
876
+
877
+    capture_state "$SOURCE_DIR" "$TEST_DIR/source_after.txt" "source"
878
+    capture_state "$DEST_DIR" "$TEST_DIR/dest_after.txt" "destination"
879
+
880
+    generate_test_report \
881
+        "GoPro_No_Sidecar_Reimport" \
882
+        "Importing and reimporting a GoPro MP4 with no THM/LRV sidecars" \
883
+        "Verify MP4 filesystem mtime fallback, metadata sync, and numeric suffix on reimport" \
884
+        "Synthetic GoPro MP4 with no sidecars and wrong QuickTime metadata" \
885
+        "$command" \
886
+        "$result"
887
+}
888
+
617 889
 # Function to show menu
618 890
 show_menu() {
619 891
     echo ""
@@ -631,10 +903,13 @@ show_menu() {
631 903
     echo "7. Source Only Test"
632 904
     echo "8. Destination Inside Source Test"
633 905
     echo "9. Verify Mode Test"
634
-    echo "10. Run All Tests"
906
+    echo "10. Timestamp Collision No-Overwrite Test"
907
+    echo "11. GoPro Sidecar Metadata Sync Test"
908
+    echo "12. GoPro No-Sidecar Reimport Test"
909
+    echo "13. Run All Tests"
635 910
     echo "q. Quit"
636 911
     echo ""
637
-    echo -n "Select test to run (0-10, q to quit): "
912
+    echo -n "Select test to run (0-13, q to quit): "
638 913
 }
639 914
 
640 915
 # Function to run all tests
@@ -660,6 +935,12 @@ run_all_tests() {
660 935
     run_destination_inside_source_test
661 936
     echo ""
662 937
     run_verify_mode_test
938
+    echo ""
939
+    run_timestamp_collision_no_overwrite_test
940
+    echo ""
941
+    run_gopro_sidecar_metadata_sync_test
942
+    echo ""
943
+    run_gopro_no_sidecar_reimport_test
663 944
 
664 945
     print_color "$GREEN" "All tests completed!"
665 946
 }
@@ -702,6 +983,15 @@ main() {
702 983
                 run_verify_mode_test
703 984
                 ;;
704 985
             10)
986
+                run_timestamp_collision_no_overwrite_test
987
+                ;;
988
+            11)
989
+                run_gopro_sidecar_metadata_sync_test
990
+                ;;
991
+            12)
992
+                run_gopro_no_sidecar_reimport_test
993
+                ;;
994
+            13)
705 995
                 run_all_tests
706 996
                 ;;
707 997
             q|Q)
@@ -709,7 +999,7 @@ main() {
709 999
                 exit 0
710 1000
                 ;;
711 1001
             *)
712
-                print_color "$RED" "Invalid choice. Please select 0-10 or q to quit."
1002
+                print_color "$RED" "Invalid choice. Please select 0-13 or q to quit."
713 1003
                 ;;
714 1004
         esac
715 1005
 
@@ -766,7 +1056,16 @@ else
766 1056
         "verify-mode"|"9")
767 1057
             run_verify_mode_test
768 1058
             ;;
769
-        "all"|"10")
1059
+        "timestamp-collision"|"collision"|"10")
1060
+            run_timestamp_collision_no_overwrite_test
1061
+            ;;
1062
+        "gopro-sidecar-sync"|"gopro-sync"|"11")
1063
+            run_gopro_sidecar_metadata_sync_test
1064
+            ;;
1065
+        "gopro-no-sidecar-reimport"|"gopro-reimport"|"12")
1066
+            run_gopro_no_sidecar_reimport_test
1067
+            ;;
1068
+        "all"|"13")
770 1069
             run_all_tests
771 1070
             ;;
772 1071
         "clean")
@@ -777,7 +1076,7 @@ else
777 1076
             ;;
778 1077
         *)
779 1078
             print_color "$RED" "Unknown test: $1"
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]"
1079
+            echo "Usage: $0 [basic|unimportable|mixed|safety|utc|subdir|keep-empty|source-only|dest-in-source|verify-mode|timestamp-collision|gopro-sidecar-sync|gopro-no-sidecar-reimport|all|clean|setup] or [0-13]"
781 1080
             exit 1
782 1081
             ;;
783 1082
     esac