CLI ยท commit 81f4fb5

photosift

Photo library organizer & duplicate finder

Download photosift.zip
Quickstart
unzip photosift.zip && cd photosift
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/python -m pytest -q

photosift

photosift turns a chaotic dump of phone backups, WhatsApp exports and camera cards into a clean, date-organized library. It finds exact and near-duplicate photos (resized, re-compressed, slightly edited) and never moves anything until you have reviewed a plan. Every apply is hash-verified, journaled and undoable.

Features

Install

Python 3.12+.

uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
source .venv/bin/activate

Quickstart

python examples/make_sample_library.py /tmp/sample          # ~200 messy synthetic files
photosift scan /tmp/sample
photosift dupes --html dupes.html                           # open in a browser
photosift plan --dest /tmp/Library --out plan.json
photosift apply plan.json --move                            # dry run
photosift apply plan.json --move --execute --journal run.journal.jsonl
photosift undo run.journal.jsonl --execute                  # put everything back

The cache lives in ~/.cache/photosift/cache.db; override with --db PATH or PHOTOSIFT_DB. In the transcripts below the sample library path is shortened to /demo.

Usage

scan

$ photosift scan /demo/lib
found 204 files: 204 analyzed, 0 cached, 0 stale removed, 24 fully hashed (size+prefix collisions)
$ photosift scan /demo/lib
found 204 files: 0 analyzed, 204 cached, 0 stale removed, 0 fully hashed (size+prefix collisions)

--workers N sets the process pool size. Vanished files under the scanned roots are dropped from the cache. Corrupt images are reported as warnings and kept (hashed bytes, dated by filename or mtime, no perceptual hash).

dupes

--near (default) groups visually similar images, including byte-identical ones; --exact groups only identical files. --threshold is the maximum pHash Hamming distance (default 8). --prefer sets the path-substring ranking used as a keeper tie-breaker (default DCIM, Camera). --include-bursts treats burst frames as duplicates.

$ photosift dupes
g0001 [exact] 2 files, 82.9 KB reclaimable
  KEEP    /demo/lib/phone_backup/DCIM/Camera/IMG_20210519_214322.jpg  (preferred path)
  dist 0  /demo/lib/camera_card/IMG_20210519_214322_copy.jpg  (less preferred path)
...
g0013 [near] 2 files, 18.7 KB reclaimable
  KEEP    /demo/lib/phone_backup/DCIM/Camera/IMG_20210831_084639.jpg  (highest resolution)
  dist 0  /demo/lib/downloads/Screenshot_2022-07-01-14-30-00.jpg  (lower resolution (640x480 vs 1200x900))
...
61 groups, 2.7 MB reclaimable, 1 bursts kept separate

--html report.html writes one self-contained page: base64 thumbnails side by side, the keeper outlined in green with a KEEP badge, reasons, pHash distances, and a separate section for bursts.

plan

$ photosift plan --dest /demo/library --out plan.json
plan: plan.json 143 place, 61 quarantine

Options: --template, --dupes quarantine|skip|delete, --exact-only, --threshold, --prefer, --include-bursts, --under PATH.

The template is a Python format string. Fields: year month day hour minute second date time camera seq ext stem kind source. {month:02d} works because numbers are ints. The default is {year}/{year}-{month:02d}/{date}_{time}_{camera}{seq}.{ext}, which renders for example 2023/2023-08/2023-08-13_144031_Apple-iPhone-13.jpg. Collisions get -2, -3, ... in {seq} (inserted before the extension if the template has no {seq}). A destination that already holds identical bytes becomes a skip; one that holds different bytes is never overwritten. Templates that escape the destination are rejected.

Duplicate losers follow --dupes: quarantine places them under <dest>/_duplicates/<group>/, skip leaves them where they are, delete removes them from the source (as a move into <dest>/.photosift-trash/, only with --move).

A plan entry:

{
 "src": "/demo/lib/whatsapp/Media/WhatsApp Images/IMG-20210313-WA1438.jpg",
 "action": "quarantine",
 "reason": "near duplicate of IMG_20210313_233140.jpg (g0041): lower resolution (800x600 vs 1200x900)",
 "dest": "/demo/library/_duplicates/g0041/IMG-20210313-WA1438.jpg",
 "sha256": "477038d3...",
 "size": 19481,
 "group": "g0041"
}

apply and undo

$ photosift apply plan.json --move
move   /demo/lib/old_phone/future.jpg -> /demo/library/2028/2028-12/2028-12-24_192423_Apple-iPhone-13.jpg
...
dry run: 204 done, 0 skipped, 0 failed
re-run with --execute to apply
$ photosift apply plan.json --move --execute --journal run.journal.jsonl
...
applied: 204 done, 0 skipped, 0 failed
$ photosift undo run.journal.jsonl            # dry run
dry run: 204 done, 0 skipped, 0 failed
$ photosift undo run.journal.jsonl --execute
undone: 204 done, 0 skipped, 0 failed

--copy (default) keeps sources and ignores delete entries; --move removes each source only after its copy is verified. Exit status is 1 if any file failed. See docs/safety-model.md.

stats

$ photosift stats
204 files, 13.2 MB  (201 image, 3 video)

By year
  1904                         1
  1970                         1
  2021                         57
  ...
By camera
  unknown                      46
  Google Pixel 7               42
  ...
By date source
  exif                         158
  filename                     42
  mp4                          3
  mtime                        1

Duplicates
  exact: 13 groups, 952.2 KB reclaimable
  near:  61 groups, 2.7 MB reclaimable (includes exact)

Date problems (4 files)
  /demo/lib/old_phone/epoch.jpg
    - missing: no EXIF/container or filename date, using file mtime
    - suspicious: 1970 is before 1990
  /demo/lib/old_phone/future.jpg
    - suspicious: date is in the future
  /demo/lib/old_phone/IMG_20180101_100000.jpg
    - suspicious: EXIF/container and filename dates differ by 1251 days

fix-dates

$ photosift fix-dates --from-filename
would write 2023:06:15 12:00:00  /demo/lib/whatsapp/Media/WhatsApp Images/IMG-20230615-WA2236.jpg
...
42 files need updating (dry run, add --execute)

Only JPEGs whose date came from the filename are touched. Date-only names (WhatsApp) get 12:00:00. Each write goes to a temp copy, is read back, then swapped in; the file's mtime is preserved.

map

$ photosift map --html map.html
101 geotagged photos in 3 clusters -> map.html

Points are bucketed into a grid (--radius-km, default 5) and touching cells merge into one cluster. The page loads Leaflet from cdnjs and tiles from OpenStreetMap, so it needs a network connection to display.

Configuration

Setting Where Default
Cache database --db, PHOTOSIFT_DB ~/.cache/photosift/cache.db
pHash threshold --threshold 8
Keeper path priority --prefer (repeatable) DCIM, Camera
Destination template plan --template see above

Commands that read the cache accept --under PATH to limit them to one directory.

Architecture

Module Role
cli.py click commands, thin glue
scanner.py walk, process pool, cache reuse, collision-only full hashing
metadata.py per-file analysis (EXIF, GPS, dates, hashes), runs in workers
dates.py, mp4.py filename/EXIF date parsing, mvhd atom parsing
hashing.py SHA-256, aHash, dHash, pHash, Hamming distance
bktree.py BK-tree radius search
grouping.py union-find grouping, keeper policy, burst detection
planner.py template rendering, collision handling, plan JSON
applier.py verified copy/move, journal, undo
stats.py, fixdates.py, mapper.py, report.py the matching commands
db.py, models.py SQLite cache and the FileInfo record

Deeper docs: perceptual hashing and safety model.

Tests

.venv/bin/pytest

72 tests cover date priority and patterns, MP4 parsing, hash invariance, BK-tree against brute force, keeper policy, bursts, template rendering and collisions, apply/undo round trips with hash verification, tamper and overwrite refusal, stats, fix-dates and the CLI end to end.

Limitations