Discover is the cheap preprocessing step that turns a flat tree of FTP uploads into a list of proposed events you can accept or reject. It runs before ingest, reads EXIF only, and is safe to re-run every time new frames land.
The library is a rolling tree — every frame the camera has ever uploaded. Events are the atomic unit of work. Discover reads EXIF only, clusters by time, and proposes events. It never opens pixel data. It never copies a file.
Re-running is safe. Already-known frames are skipped. Discover is the cheap, idempotent step you can re-run every time new FTP uploads land.
The library path must exist and be a directory. The events root from ~/.photofly/config.json must be set, or the command exits with a hint to run photofly init.
src/agent-photofly/internal/discover/discover.go:62–83Read every existing event.json under <eventsRoot>/events/. For each member, compute the key library + "\x00" + relative-path and build a set. This single set is the entire idempotency mechanism.
src/agent-photofly/internal/event/event.go:IndexedMembersDepth-first via filepath.WalkDir. For each file: filter by extension (JPEG only in v1 — .jpg, .jpeg, .JPG, .JPEG), skip if the (library, relpath) key is in the known set, otherwise read EXIF. The EXIF reader extracts DateTimeOriginal (becomes captured), Model (becomes camera), and LensModel (becomes lens). When a JPEG has no EXIF — re-saved files, screenshots — the file's mtime is used as captured and the photo still flows through. Emit a record per frame.
discover.go:104–150 (the walk), internal/exif/exif.go:Read (the EXIF read)A single sort.Slice over the collected frames.
discover.go:142Walk the sorted slice pairwise. Start a new event whenever either of these is true: the gap between this frame and the previous one is greater than the gap threshold (default 2 hours, configurable with --gap-hours); or day-split is on (default) and the day-of-year has changed. Otherwise, append the frame to the current cluster.
discover.go:cluster (around line 152)For each cluster, derive a slug of the shape YYYY-MM-DD-event-N where N disambiguates multiple events the same day. Unique the cameras and lenses across members. Set status to proposed. Set genre to unknown— discover does not predict genre in v1; that's ingest's job once it has the materialized set.
discover.go:buildEvent (around line 175)One file per event under <eventsRoot>/events/<slug>/event.json. Atomic write: tmp file plus rename, so a crashed run never leaves a partial manifest.
internal/event/event.go:WriteDiscover writes one file per event. The schema is documented in PRD §5.4. The shape, abbreviated, looks like this:
{
"id": "2026-05-15-event-1",
"name": "Fri May 15 2026",
"status": "proposed",
"discoveredAt": "2026-05-17T22:15:44Z",
"cluster": {
"method": "exif-v1",
"timeWindow": { "start": "2026-05-15T09:01:00Z", "end": "2026-05-15T12:47:00Z" },
"cameras": ["ILCE-7M4"]
},
"genre": { "detected": "unknown", "confidence": 0 },
"source": {
"library": "/var/lib/.../uploads/camera/",
"memberCount": 412,
"members": [{ "path": "DCIM/100MSDCF/DSC00001.JPG", ... }]
}
}Review with photofly events ls. Rename, merge, or split if a cluster looks wrong. Then events accept <slug> to gate ingest. Accept, merge, split, and rename are stubs in v1 — the manifests are the contract; the editing tools land next.