Metadata-Version: 2.4
Name: ibatch
Version: 0.6.1
Summary: Batch-fix the geolocation, timestamps and names of photos and videos
Author: David Polák
Author-email: David Polák <email@davidpolak.cz>
License-Expression: GPL-3.0-or-later
License-File: LICENSE
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Dist: click>=8.3.1
Requires-Dist: geopy>=2.4.1
Requires-Dist: pyexiftool>=0.5.6
Requires-Dist: timezonefinder>=9.0.0
Requires-Python: >=3.13, <4
Description-Content-Type: text/markdown

# ibatch

`ibatch` works on a directory of photos and videos straight off a camera or
phone: it fills in missing GPS positions, sets each file's modification time
to the moment it was shot, and renames files after their capture time.

## Install

Requires Python 3.13+ and [ExifTool](https://exiftool.org/) 12.15 or newer on
`PATH` (all metadata is read and written through it).

```sh
uv tool install ibatch   # or: pipx install ibatch
```

## Usage

```sh
ibatch geo [-n] [--latitude LAT --longitude LON] DIR
ibatch timestamp [-n] DIR
ibatch rename [-n] DIR
ibatch all DIR          # geo, then timestamp, then rename (no options)
ibatch --version
```

Only the files directly in `DIR` are processed (no subdirectories), photos
first, then videos, each in name order. Supported files:

- photos: `.jpg`, `.jpeg`, `.avif`, `.jxl`
- videos: `.mp4`, `.mov`

Extensions must be all lower or all upper case (`.JPG` works, `.Jpg` is
ignored).

`-n`/`--dry-run` shows what would change without modifying any files. `all`
has no dry run and no options; it stops at the first step that fails. Its geo
step always prompts when some photos lack a position, so for an unattended run
use `ibatch geo --latitude LAT --longitude LON DIR` first. If every photo
already has a position, the geo step prints "Nothing to do" and `all` goes on.

## Capture time

Photos take their date from the first of `EXIF:DateTimeOriginal`,
`XMP:DateTimeOriginal`, `EXIF:CreateDate`, `XMP:CreateDate` that holds one. A
date with seconds wins over one without, since names are built from the
seconds; a lone date without seconds counts as `:00`. `ModifyDate` is never
used as the capture date. Videos take theirs as described under
Videos below.

A file without a capture date is skipped with a warning by `timestamp` and
`rename`; this is not a failure.

## Rename

`ibatch rename DIR` renames each file to its capture wall-clock time,
`YYYYMMDD_HHMMSS`, keeping the extension as it was (`IMG_0001.JPG` →
`20240701_075426.JPG`). For photos the name is the wall clock as
shot, whatever the timezone, and does not depend on the offset found by
`timestamp`. A video's name depends on the zone found for it, see
Videos below.

- A file already named after its time (with or without a `_N` suffix) is left
  alone, so running it again changes nothing.
- Renames never overwrite: if the name is taken, `_1`, `_2`, … is appended
  (`20240701_075426_1.jpg`). This also keeps photos shot in the same second.
- Photos are renamed before videos.
- `--dry-run` simulates the run in order, including names freed by earlier
  renames, and follows the file system's case sensitivity.

## Timestamps

`ibatch timestamp DIR` sets each file's modification (and access) time to the
moment it was shot. Only whole seconds count: a file whose mtime already
matches to the second is left alone. For photos the exact moment is worked out
in this order:

1. an offset embedded in the date (XMP, e.g. `2024:07:01 07:54:26-05:00`, or
   `Z`), taken from any of the date tags with the same wall clock;
2. the EXIF offset tag belonging to the date: `OffsetTimeOriginal` for
   `DateTimeOriginal`, `OffsetTimeDigitized` for `CreateDate`; `OffsetTime`
   only if `ModifyDate` equals the capture time (otherwise it describes an
   edit);
3. the offset implied by the GPS time: the capture wall clock minus the GPS
   UTC time, rounded to 15 minutes. It is used only if rounding moves it by at
   most 2 minutes, and if it is within 3 hours of the longitude's solar
   time (longitude / 15), which rules out most stale GPS fixes. Without a GPS
   longitude this step is skipped;
4. otherwise the machine's timezone is assumed. The command ends with a note
   counting such photos.

Offsets outside -12:00 to +14:00 are ignored. `--dry-run` previews the new
times, including the offset that was used.

Limits of the GPS step: it cannot tell a fresh fix from an old one, as long as
the result looks like a real offset near the photo's longitude. A fix taken an
hour before a photo in Prague gives `+03:00` instead of `+02:00`, silently, and
near the date line a fix a day old can be a whole day off. Prefer the offset
tags; the GPS step is a best effort.

### Videos

QuickTime/MP4 dates are UTC by spec, so a video's moment is known exactly. Unless
there is a `Keys:CreationDate` (see below), the date is the first of
`CreateDate`, `MediaCreateDate`, `TrackCreateDate` that holds one, and the
file's own modification time is never used. A video with none is skipped with a
warning. Only the wall clock in the name depends on a zone,
which is found in this order:

1. Apple's `Keys:CreationDate`, if present, is a local time with its offset; it
   is used as it stands (the offset also gives the moment). Without an offset
   its wall clock is kept and read in the machine's timezone;
2. `Keys:AndroidTimeZone`;
3. the GPS position (`Composite:GPSLatitude`/`GPSLongitude`, e.g. from
   `GPSCoordinates`), looked up offline in a time zone map
   ([timezonefinder](https://pypi.org/project/timezonefinder/)), with daylight
   saving time as it was on that date. Unlike for photos there is no wall clock
   to check the position against, so a wrong position gives a wrong zone;
4. otherwise the machine's timezone is assumed. The command ends with a note
   counting such videos.

The modification time is the moment itself, so it does not depend on the
machine's timezone (except for a `Keys:CreationDate` without an offset); the
names of videos that reach the last step do.

## Geolocation

`ibatch geo DIR` gives photos without a GPS position one (videos are not
touched). Photos that already have a position are left as they are. A photo
with only a latitude or only a longitude counts as having none and is
overwritten.

- By default it takes the mean position of the photos that have one, after
  dropping latitudes and longitudes more than 2 standard deviations from
  their mean, prints it with an OpenStreetMap link and asks whether to apply
  it (default yes).
- If that is declined, or no photo has a position, it asks for a latitude and
  longitude and confirms them; declining again exits with status 1.
- `--latitude` and `--longitude` (given together) apply those coordinates
  without asking.

Positions are written in place with ExifTool (no `_original` backup). With
`--dry-run` the mean position is assumed accepted; if no photo has a position,
the dry run needs `--latitude`/`--longitude` and fails without them.

## Files that fail

A photo or video that cannot be read, or a file that cannot be touched or renamed, is
reported and the rest are still processed. `timestamp` and `rename` then exit
with an error and count it as failed (`--dry-run` too, for an unreadable
photo or video). `geo` only warns about an unreadable photo and leaves it out;
a photo whose position cannot be written is reported and makes it exit with an
error.

Exit status: 0 on success, 2 on a usage error, 1 on any other error (a file
that failed, `geo` declined, `geo --dry-run` without coordinates to preview,
ExifTool missing, an aborted prompt).

## Development

```sh
uv sync
uv run pytest
uv run pre-commit install   # ruff check/format and file hygiene hooks
```

Tests that build sample files on the fly are skipped unless `exiftool`,
`ffmpeg` and `cjxl` are on `PATH`. `./publish.sh` builds the
current version with `uv build` and publishes it to PyPI, taking the token
from 1Password (`op`).

## License

GPL-3.0-or-later, see the `LICENSE` file.
