fotofolder is a Python CLI for copying photo and video archives into a deterministic, date-based folder layout. It keeps a SQLite manifest, extracts metadata with ExifTool, hashes source files, writes review reports, and leaves the source tree untouched.
This is intended for cautious archive organization: run it against a source directory, inspect the reports, then repeat as more source folders are added.
- Copies files instead of moving or modifying originals.
- Writes each destination through a temporary file before atomically renaming it.
- Never overwrites an existing destination file.
- Uses SHA-256 to detect exact duplicates across the full manifest.
- Uses metadata dates first, filename dates next, and filesystem timestamps only as a low-confidence fallback.
- Keeps matched sidecars with their primary media file.
- Sends unmatched sidecars and ambiguous files to review reports.
- Supports reruns without redoing unchanged metadata, hash, copy, and verify work.
- Python 3.11 or newer.
- ExifTool on the host when running the Python CLI directly.
- Docker, only if using the container workflow.
On macOS, ExifTool can be installed with Homebrew:
brew install exiftoolCreate a local virtual environment and install the package in editable mode:
python3 -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -e .Run the tests:
make testUse run for normal operation. Each invocation creates a run record, processes one source directory, and writes timestamped reports with both per-run deltas and full-manifest totals.
mkdir -p work
.venv/bin/python -m fotofolder run \
--source /path/to/source-photos \
--dest /path/to/organized-photos \
--db work/manifest.sqlite \
--reports work/reportsUseful options:
--dry-run Stop after scan, metadata, hash, plan, and report.
--metadata-workers 2 Metadata extraction worker count.
--hash-workers 2 Hash worker count.
--copy-workers 2 Copy worker count.
--verify-workers 2 Verify worker count.
--quiet Suppress progress logs on stderr.
For a no-copy preview:
.venv/bin/python -m fotofolder --quiet run \
--source /path/to/source-photos \
--dest /path/to/organized-photos \
--db work/manifest.sqlite \
--reports work/reports \
--dry-runPrimary media is planned into date folders. The destination filename includes a short content hash suffix so same-name files with different contents can coexist.
organized-photos/
2024/
2024-07/
2024-07-12/
IMG_1234__a1b2c3d4.heic
Files needing manual review are planned under _needs_review/ instead of being guessed into the main archive layout.
Reports are written to a timestamped run directory:
work/reports/
20260429T212524Z_run-2/
summary.txt
copy_plan_delta.jsonl
copy_plan_total.jsonl
duplicates_delta.jsonl
duplicates_total.jsonl
needs_review_delta.jsonl
needs_review_total.jsonl
unmatched_sidecars_delta.jsonl
unmatched_sidecars_total.jsonl
copy_errors_delta.jsonl
copy_errors_total.jsonl
verify_errors_delta.jsonl
verify_errors_total.jsonl
*_delta.jsonl files and the [delta] summary section describe the current run invocation. *_total.jsonl files and the [totals] summary section describe the full SQLite manifest across all runs and source directories.
The SQLite database is the durable manifest. Reruns are optimized for adding new files to one or more source directories over time.
- Scan compares each source path's size and filesystem modified timestamp to the last observed values.
- If a file is unchanged, existing hash and metadata records are reused.
- If a file changed, derived records for that file are invalidated so it can be hashed, planned, copied, and verified again.
- If a file is already copied and verified for the current planned destination and source hash, copy and verify skip it.
- To force a complete rebuild, delete the SQLite database and run from the beginning.
The individual phase commands operate on the manifest as a whole. They are useful for debugging or manually stepping through a run, but per-run delta reports are clearest when using run.
.venv/bin/python -m fotofolder scan --source /path/to/source-photos --db work/manifest.sqlite
.venv/bin/python -m fotofolder metadata --db work/manifest.sqlite
.venv/bin/python -m fotofolder hash --db work/manifest.sqlite --hash-workers 1
.venv/bin/python -m fotofolder plan --db work/manifest.sqlite --dest /path/to/organized-photos
.venv/bin/python -m fotofolder copy --db work/manifest.sqlite --copy-workers 1
.venv/bin/python -m fotofolder verify --db work/manifest.sqlite --verify-workers 1
.venv/bin/python -m fotofolder report --db work/manifest.sqlite --out work/reportsVerbose progress logs are enabled by default and are written to stderr. The final command summary remains JSON on stdout. Use --quiet when stdout must contain only JSON.
Common actions include:
scan source_added,source_unchanged,source_changedhash skip_already_hashedmetadata skip_metadata_cachedplan plan_copy,plan_sidecar,plan_unmatched_sidecarcopy skip_already_copied
The Docker image includes ExifTool.
make docker-build
docker run --rm fotofolder:latest --versionRun with bind-mounted source, destination, and work directories. Pass container paths to fotofolder, not host paths.
mkdir -p work
docker run --rm \
-v /host/source-photos:/source:ro \
-v /host/organized-photos:/dest \
-v "$PWD/work:/work" \
fotofolder:latest run \
--source /source \
--dest /dest \
--db /work/manifest.sqlite \
--reports /work/reportsExport an image tarball when needed:
make docker-saveGenerated tarballs are intentionally ignored by Git.
make test
make docker-smokeThe implementation design notes are in docs/python-photo-organizer-design.md.