HTML documentation: The GitHub Pages version is available at Photo Offloader Documentation. The hosted page is maintained in
index.htmlonmain.
A macOS-focused camera media offloader that safely copies photos and video from camera cards to local storage and, when available, an external backup drive.
The importer follows:
Discover β Lock β Snapshot β Scan β Capacity Check β Copy β Verify β Report β Eject
Files are never deleted from the camera card and existing destination files are never overwritten.
Configured camera volume patterns currently include:
NIKON*SONY*EOS*
The automatic LaunchAgent watches /Volumes, but the trigger only launches when one of those camera volumes contains a DCIM directory. A volume such as /Volumes/Photos is ignored as a source.
The importer also accepts a volume or DCIM path through --source.
The automatic camera trigger opens the importer in iTerm2 by default. To use another terminal application, set PHOTO_OFFLOADER_TERMINAL_APP in the LaunchAgent environment before installing/loading the trigger.
For example, to use the macOS Terminal app instead:
PHOTO_OFFLOADER_TERMINAL_APP=Terminal
Volume names are passed as real command arguments rather than being reconstructed through unsafe nested shell quoting. Names such as:
/Volumes/NIKON Z 8
are supported.
macOS can generate multiple /Volumes events while removable media is mounting. The trigger therefore uses several layers of protection:
- LaunchAgent
ThrottleIntervalreduces event bursts. - An atomic trigger lock prevents simultaneous trigger evaluations.
- Trigger state prevents the same mounted camera from launching repeatedly.
- The importer has its own process lock as the final concurrency safeguard.
The state is cleared when no recognized camera volume is mounted, allowing the same card to be processed again after it is unmounted and remounted.
Local storage defaults to:
~/Pictures/CameraImports/
When /Volumes/Photos is mounted when an import begins, a second copy is written to:
/Volumes/Photos/CameraImports/
The USB destination is snapshotted at the beginning of the session. If the expected USB volume disappears or is replaced during the import, the session fails and the camera card is not automatically ejected.
Use --no-usb when a second backup is intentionally not required.
Existing files are never blindly overwritten.
For an existing same-name file:
- Size is compared.
- Contents are SHA-256 compared.
- If it matches, the file is skipped.
- If it differs, a conflict name such as
DSC_1234__conflict-1.NEFis used.
Every copied file is verified after the copy completes.
By default, verification compares file sizes. For stronger verification of every new copy:
python3 camera_offloader_v2.py --sha256Existing same-name files are always content-checked before being skipped.
The importer fingerprints each source file before copying and checks it again afterward. If the source changes or disappears during the copy, that destination is treated as failed.
Before copying, the importer calculates the total source bytes and checks every required destination for enough free space plus a safety buffer.
The default safety buffer is 100 MiB and can be changed with MIN_FREE_SPACE_BUFFER_BYTES in the Python configuration.
Files are organized into YYYY-MM folders while preserving native camera subfolders:
CameraImports/
βββ YYYY-MM/
βββ 100NIKON/
β βββ DSC_0001.NEF
β βββ DSC_0002.JPG
βββ 101NIKON/
βββ DSC_0003.JPG
EXIF capture date is preferred for the month folder, followed by filesystem birth time and modification time.
For video files such as MOV and MP4, the importer can read embedded camera Make/Model metadata using the optional exiftool command. When available, this allows videos to be grouped with photos from the same camera.
If exiftool is not installed or a video does not contain usable camera metadata, the importer falls back to Unknown-Camera.
Because macOS LaunchAgents may start with a restricted PATH, the importer also checks common executable locations including /opt/homebrew/bin/exiftool, /usr/local/bin/exiftool, and /opt/local/bin/exiftool.
exiftool is not installed automatically by Photo Offloader. On a Homebrew-based Mac, install it with:
brew install exiftoolYou can verify that it is available with:
exiftool -verThe importer reports:
- files discovered
- source bytes
- destination copies
- destination skips
- verified copies
- failures
- destination paths
- eject status
Persistent logs are written to:
~/.camera_transfer.log
Trigger activity is written to:
~/.camera_transfer_trigger.log
The importer uses native osascript notifications for successful, incomplete, canceled, and eject-failure sessions. Notification failure does not itself make an import fail.
The card is automatically ejected only when:
- all required destinations succeeded;
- the expected USB backup volume is still present and matches its recorded identity; and
--no-ejectwas not specified.
If an import fails, the card remains mounted for investigation or retry.
- macOS
- Python 3.9+
diskutilandosascript(provided by macOS)alive-progressPillow- Optional:
exiftoolfor camera Make/Model metadata in video files
Development/testing also uses Ruff.
From the repository directory:
chmod +x install.sh uninstall.sh
./install.shThe installer:
- installs the importer and trigger under
~/Library/Application Support/PhotoOffloader; - creates the runtime virtual environment;
- installs runtime dependencies;
- creates and validates
com.user.photooffloader.plist; - loads the LaunchAgent with
launchctl bootstrap; and - verifies that the LaunchAgent is active.
Install development dependencies:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-dev.txtRun the complete test suite:
python -m unittest discover -vRun Ruff:
python -m ruff check camera_offloader_v2.py test_camera_offloader.py test_hardening_regressions.py test_camera_offloader_trigger.pyThe test suite includes filesystem-level copy tests, hardening regressions, lock behavior, USB identity checks, video camera metadata detection, and trigger tests covering volume names with spaces and repeated /Volumes events.
Import a specific camera volume:
python3 camera_offloader_v2.py --source "/Volumes/NIKON Z 8"Import without the USB destination:
python3 camera_offloader_v2.py --source "/Volumes/NIKON Z 8" --no-usbDo not eject after a successful import:
python3 camera_offloader_v2.py --source "/Volumes/NIKON Z 8" --no-ejectEnable SHA-256 verification:
python3 camera_offloader_v2.py --source "/Volumes/NIKON Z 8" --sha256The main configuration constants are at the top of camera_offloader_v2.py:
CARD_VOLUME_GLOBSLOCAL_DEST_ROOTUSB_DEST_VOLUMEUSB_DEST_ROOTVERIFY_WITH_SHA256PRESERVE_CAMERA_SUBFOLDERSUSE_FILE_BIRTH_TIMEMIN_FREE_SPACE_BUFFER_BYTESEJECT_CARD_AFTER_SUCCESS
The installed copy lives at:
~/Library/Application Support/PhotoOffloader/camera_offloader_v2.py
The LaunchAgent is:
~/Library/LaunchAgents/com.user.photooffloader.plist
It watches /Volumes. Do not remove the trigger-level and importer-level locks; both are intentional safety layers.
./uninstall.shThis removes the application files, runtime environment, trigger state, LaunchAgent, and logs. Imported media under ~/Pictures/CameraImports is intentionally left untouched.
See CHANGELOG.md for the project history. The current hardening release is tracked as 2.3.0.
This application is designed to protect against accidental overwrites and incomplete backups, but it should still be treated as backup infrastructure rather than the only copy of irreplaceable media.
For a first run after installation or an upgrade, verify the resulting files on both destinations before formatting or reusing the camera card.