I Unleashed Apple's Free Local AI on My Photos Library

I use the map in Apple Photos to find pictures from trips. That only works when the pictures have useful GPS data. Some of my older photos have no location; others have a pin far from where I took them. Fixing a whole album one picture at a time takes ages.

Then there is the pile of pictures outside albums. In my Apple Photos MCP post, I used Codex to review a batch of 100 of them. Here I wanted to see whether Apple’s local model could recognize events in that pile and suggest albums without my opening every picture. Capture dates might help find a group, but pictures imported together can show entirely different days.

A foundation model is trained for a range of tasks rather than one narrow job. An LLM is a language-focused kind of foundation model, not an alternative to one. Apple calls the on-device model behind Apple Intelligence an LLM. It describes that device-scale model as useful for extraction, classification, and summarization, while broader world knowledge and advanced reasoning are better suited to larger server models. NIST defines the broader term, and Apple describes its on-device LLM in its developer introduction.

Apple introduced the models behind Apple Intelligence in June 2024. At WWDC25, it introduced the Foundation Models framework, a Swift API that lets developers use the on-device LLM in their apps. At WWDC26, Apple added the fm command to macOS 27. The command can take an image and uses the on-device model by default. That gave me a way to test my Photos ideas from Terminal without an API key or cloud API charge.

fm can analyze a picture I give it, but it doesn’t search my Photos library or edit albums by itself. I wrote two separate Python scripts to supply the pictures and act on the answers. Photos-Auto-Geotag.py tackles the map problem by proposing coordinates for one album. Photos-AI-Organizer.py tackles the album backlog by finding unfiled pictures and proposing event groups. Both leave the final change for me to approve.

Run one script for one job

Both scripts are included in full at the end of this post. Save the one you want to use under its filename, then open Terminal in that folder. Each script reads Photos’ local SQLite database to find the relevant pictures and locates originals inside the library package. It sends sampled images to fm respond --image and shows me the proposal. When I type YES, it asks Photos through AppleScript to make the change. Neither script edits Photos.sqlite directly.

The shared setup is one environment variable. Set it in the Terminal session where you’ll run a script, using the path to the library Photos has open:

export PHOTOS_LIBRARY_PATH="$HOME/Pictures/Photos Library.photoslibrary"

Apple says fm comes with macOS 27. Run fm --help if you want to check that the command is available on your Mac. Then choose one of these scripts.

To correct an album’s map pins, save the full geotagger script as Photos-Auto-Geotag.py and run:

python3 Photos-Auto-Geotag.py

It asks for an album name, whether to fill only missing GPS or re-tag the whole album, and whether to use one known place or analyze the pictures individually.

To find pictures outside albums and propose new event albums, save the full organizer script as Photos-AI-Organizer.py and run:

python3 Photos-AI-Organizer.py

It asks what you want to organize, then shows groups and proposed album names before asking for YES. The scripts rely on private Photos database tables and original-file paths, so an OS update could break discovery or verification. macOS may also request Automation permission when Python’s AppleScript calls reach Photos. I would start with a small album and a backed-up library. These questions appear after launch; neither script takes the album name or location mode as command-line flags.

I started with the map pins

I ran the geotagger first. It asks for an exact album name, counts the pictures and missing GPS values, then offers missing-only or re-tag everything. I tried missing-only on 2026 - Trick or Treat, an album with nine photos. All nine already had coordinates, so the script stopped without changing anything.

This is the first terminal run, including the menu I answered:

Enter Photos album name: 2026 - Trick or Treat

ALBUM: 2026 - Trick or Treat
TOTAL PHOTOS: 9
MISSING GPS: 0

GPS UPDATE MODE:
1. Tag only photographs missing GPS
2. Re-tag ALL photographs in this album

Select 1 or 2: 1
All photographs already have GPS.

The more interesting case was 2023 - Annapolis: 63 photos, 49 missing GPS. I chose option 2 because I was testing whether FM could improve the whole album, including existing but imprecise pins. The next menu offers a single known location for every selected photo, individual landmark analysis, or a typed location that enters the known-location path. For a trip spread across several stops, I chose individual analysis.

The Annapolis run shows the second menu and the choice to analyze each picture:

Enter Photos album name: 2023 - Annapolis

ALBUM: 2023 - Annapolis
TOTAL PHOTOS: 63
MISSING GPS: 49

GPS UPDATE MODE:
1. Tag only photographs missing GPS
2. Re-tag ALL photographs in this album

Select 1 or 2: 2
RE-TAG MODE ENABLED
PHOTOGRAPHS SELECTED: 63
Existing GPS coordinates will be replaced after approval.

LOCATION MODE:
1. Use one known location for the entire album
2. Analyze each photograph individually with FM

You may also enter a location directly.

Selection or location: 2

The known-location route is useful for a whole album shot at one venue. The script samples up to five images, asks FM whether each is visually consistent with the named place, and stops if fewer than 80 percent match or any sample contradicts it. It then asks FM for coordinates and lets me accept, correct, or cancel those numbers before a final YES. It is a bulk placement tool, not a way to map a city-wide trip to individual stops.

In per-photo mode, the script asks FM for a city, landmark, signed coordinates, and HIGH/MEDIUM/LOW confidence. It caches responses as JSONL so an interrupted scan can resume. Missing originals cannot be analyzed. Only HIGH responses with numerically valid latitude and longitude become proposals. The numeric check rejects impossible coordinates; it cannot tell whether a valid point is anywhere near the landmark FM named.

The model recognized landmarks in the Annapolis images, including the Maryland State House, and the terminal showed 61 HIGH proposals and two unresolved photos. One unresolved item was a missing MOV original; the other got MEDIUM. Being able to inspect a long album and get a labeled proposal for nearly every image is the part I find exciting. I approved all 61. The script reported 61 successful AppleScript updates and zero errors. It did not re-read the final GPS values after the write.

The terminal looked convincing even where the coordinate was wrong:

4/63 ANALYZING: IMG_5528.HEIC
   Annapolis | Maryland State House | HIGH

0003. IMG_5528.HEIC | Annapolis | Maryland State House | 38.934000, -76.606000
Type YES to apply ALL listed locations, or anything else to cancel: YES
3/61 UPDATED: IMG_5528.HEIC

Annapolis trip photograph used in the FM geotag test; its landmark label looked plausible, but its stored map pin needed independent checking

FM named the Maryland State House in this picture, but a read-only check found that Photos stored its proposed point near Davidsonville. The same run assigned “Annapolis Brown University HIGH” to IMG_4621.HEIC at 38.908500, -66.426500, a point in the North Atlantic. Another “Annapolis waterfront” photo got 38.658300, -67.128300. A read-only check found those ocean pins persisted too. “UPDATED” meant the write call succeeded; it did not mean the answer was right.

The script currently offers one final approval for all proposed coordinates. I would change that before another large re-tag: reject points outside a known region, flag repeated landmarks whose coordinates disagree, and let me approve or correct individual rows. Until then, missing-only is the safer default, and I would inspect the map pins in Photos after every small run. Apple’s Photos location controls can correct bad locations manually.

Full geotag terminal output: the no-op check and the 63-photo Annapolis run
$ python3 Photos-Auto-Geotag.py
========== APPLE PHOTOS AUTO-GEOTAG ==========

Enter Photos album name: 2026 - Trick or Treat

ALBUM: 2026 - Trick or Treat
TOTAL PHOTOS: 9
MISSING GPS: 0

GPS UPDATE MODE:
1. Tag only photographs missing GPS
2. Re-tag ALL photographs in this album

Select 1 or 2: 1
All photographs already have GPS.
$ python3 Photos-Auto-Geotag.py
========== APPLE PHOTOS AUTO-GEOTAG ==========

Enter Photos album name: 2023 - Annapolis

ALBUM: 2023 - Annapolis
TOTAL PHOTOS: 63
MISSING GPS: 49

GPS UPDATE MODE:
1. Tag only photographs missing GPS
2. Re-tag ALL photographs in this album

Select 1 or 2: 2

RE-TAG MODE ENABLED
PHOTOGRAPHS SELECTED: 63
Existing GPS coordinates will be replaced after approval.

LOCATION MODE:
1. Use one known location for the entire album
2. Analyze each photograph individually with FM

You may also enter a location directly.

Selection or location: 2

========== ANALYZING PHOTOGRAPHS ==========
1/63 ANALYZING: IMG_5525.HEIC
   Annapolis | Robert E. Lee statue | HIGH
2/63 ANALYZING: IMG_5526.HEIC
   Annapolis | U.S. Treasury Building | HIGH
3/63 ORIGINAL NOT FOUND: IMG_5527.MOV
4/63 ANALYZING: IMG_5528.HEIC
   Annapolis | Maryland State House | HIGH
5/63 ANALYZING: IMG_5529.HEIC
   Annapolis | House of Delegates | HIGH
6/63 ANALYZING: IMG_5530.HEIC
   Annapolis | Maryland Senate Office Complex | HIGH
7/63 ANALYZING: IMG_5531.HEIC
   Annapolis | Maryland Senate Office Complex | HIGH
8/63 ANALYZING: IMG_5532.HEIC
   Annapolis | Jefferson Hall | HIGH
9/63 ANALYZING: IMG_5533.HEIC
   Annapolis | Maryland State House | HIGH
10/63 ANALYZING: IMG_5534.HEIC
   Annapolis | Maryland State House | HIGH
11/63 ANALYZING: IMG_5535.HEIC
   Annapolis | Maryland State House | HIGH
12/63 ANALYZING: IMG_5536.HEIC
   Annapolis | Maryland State House | HIGH
13/63 ANALYZING: IMG_5537.HEIC
   Annapolis | Maryland Veterans Memorial | HIGH
14/63 ANALYZING: IMG_5538.HEIC
   Annapolis | Monument to the Chesapeake Bay Naval Expeditionary Force | HIGH
15/63 ANALYZING: IMG_5539.HEIC
   Annapolis | U.S. Naval Academy Memorial | HIGH
16/63 ANALYZING: IMG_5540.HEIC
   Annapolis | Maryland Veterans Memorial | HIGH
17/63 ANALYZING: IMG_5541.HEIC
   Annapolis | Maryland Veterans Memorial | HIGH
18/63 ANALYZING: IMG_5542.HEIC
   Annapolis | USS Maryland Memorial | HIGH
19/63 ANALYZING: IMG_4615.HEIC
   Annapolis | Statue of the Annapolis Children's Museum | HIGH
20/63 ANALYZING: IMG_4617.HEIC
   Annapolis | Statue of Robert Smalls | HIGH
21/63 ANALYZING: IMG_5543.HEIC
   Annapolis | Senate Chamber | HIGH
22/63 ANALYZING: IMG_5544.HEIC
   Annapolis | Maryland State House | HIGH
23/63 ANALYZING: IMG_5545.HEIC
   Annapolis | Maryland State House | HIGH
24/63 ANALYZING: IMG_5546.HEIC
   Annapolis | Senate Chamber | HIGH
25/63 ANALYZING: IMG_5547.HEIC
   Annapolis | The Secret Chamber | HIGH
26/63 ANALYZING: IMG_5548.HEIC
   Annapolis | Maryland State House | HIGH
27/63 ANALYZING: IMG_5549.HEIC
   Annapolis | Maryland House of Delegates | HIGH
28/63 ANALYZING: IMG_5550.HEIC
   Annapolis | Maryland State House dome | HIGH
29/63 ANALYZING: IMG_5551.HEIC
   Annapolis | Maryland State House dome | HIGH
30/63 ANALYZING: IMG_5552.HEIC
   Annapolis | Maryland State House | HIGH
31/63 ANALYZING: IMG_5553.HEIC
   Annapolis | Maryland State House | HIGH
32/63 ANALYZING: IMG_5554.HEIC
   Annapolis | Statue of Robert Treat Paine | HIGH
33/63 ANALYZING: IMG_5555.HEIC
   Annapolis | Statue of George Washington (foreground) and "Declaration of Independence" painting (background) | HIGH
34/63 ANALYZING: IMG_5556.HEIC
   Annapolis | Maryland State House | HIGH
35/63 ANALYZING: IMG_5557.HEIC
   Annapolis | Maryland State House | HIGH
36/63 ANALYZING: IMG_5558.HEIC
   Annapolis | Governor’s Palace | HIGH
37/63 ANALYZING: IMG_5559.HEIC
   Annapolis | Portrait of a historical figure in a formal setting | HIGH
38/63 ANALYZING: IMG_5560.HEIC
   Annapolis | Statue of a woman in historical dress | HIGH
39/63 ANALYZING: IMG_5561.HEIC
   Annapolis | Annapolis statue | HIGH
40/63 ANALYZING: IMG_5562.HEIC
   Annapolis | Statue of George Washington | HIGH
41/63 ANALYZING: IMG_4618.HEIC
   Annapolis | Maryland State House | HIGH
42/63 ANALYZING: IMG_4619.HEIC
   Annapolis | Maryland Medical Center | HIGH
43/63 ANALYZING: IMG_5563.HEIC
   Annapolis | Maryland State House | HIGH
44/63 ANALYZING: IMG_5564.HEIC
   Annapolis | Maryland State House | HIGH
45/63 ANALYZING: IMG_4621.HEIC
   Annapolis | Brown University | HIGH
46/63 ANALYZING: IMG_5565.HEIC
   Annapolis | Thurgood Marsh monument | HIGH
47/63 ANALYZING: IMG_5566.HEIC
   Annapolis | Thurgood Marshall statue | HIGH
48/63 ANALYZING: IMG_4622.HEIC
   Annapolis | Statue of William Cushing | HIGH
49/63 ANALYZING: IMG_4623.HEIC
   Annapolis | Statue of William Preston Hobby | HIGH
50/63 ANALYZING: IMG_4625.HEIC
   Annapolis | John Paul Jones Statue | HIGH
51/63 ANALYZING: IMG_5567.HEIC
   Annapolis | House with ornate wrought iron fence | HIGH
52/63 ANALYZING: IMG_5568.HEIC
   Annapolis | Shuttle information sign | HIGH
53/63 ANALYZING: IMG_4631.HEIC
   Annapolis | Annapolis waterfront | HIGH
54/63 ANALYZING: IMG_4632.HEIC
   Annapolis | Annapolis waterfront | HIGH
55/63 ANALYZING: IMG_4634.HEIC
   Annapolis | Maryland State House | HIGH
56/63 ANALYZING: IMG_4635.HEIC
   Annapolis | Maryland State House (visible in background) | HIGH
57/63 ANALYZING: IMG_4636.HEIC
   Annapolis | Annapolis waterfront | HIGH
58/63 ANALYZING: IMG_5569.HEIC
   Annapolis | United States Naval Academy | HIGH
59/63 ANALYZING: IMG_5570.HEIC
   Annapolis | Bust of Halsey | HIGH
60/63 ANALYZING: IMG_5571.HEIC
   Iowa City | Bust of Halsey | MEDIUM
61/63 ANALYZING: IMG_4637.HEIC
   Annapolis | Chesapeake Bay | HIGH
62/63 ANALYZING: IMG_5572.HEIC
   Annapolis | Maryland State House (implied by the context and arcade setting) | HIGH
63/63 ANALYZING: IMG_5573.HEIC
   Annapolis | Indoor amusement center or arcade | HIGH

========== PROPOSED GPS LOCATIONS ==========
HIGH-CONFIDENCE PROPOSALS: 61
UNRESOLVED: 2
0001. IMG_5525.HEIC | Annapolis | Robert E. Lee statue | 38.984100, -76.928300
0002. IMG_5526.HEIC | Annapolis | U.S. Treasury Building | 38.957200, -76.676800
0003. IMG_5528.HEIC | Annapolis | Maryland State House | 38.934000, -76.606000
0004. IMG_5529.HEIC | Annapolis | House of Delegates | 38.904600, -76.422300
0005. IMG_5530.HEIC | Annapolis | Maryland Senate Office Complex | 38.627100, -76.453400
0006. IMG_5531.HEIC | Annapolis | Maryland Senate Office Complex | 38.978200, -76.615800
0007. IMG_5532.HEIC | Annapolis | Jefferson Hall | 38.961500, -76.479500
0008. IMG_5533.HEIC | Annapolis | Maryland State House | 38.992800, -76.449800
0009. IMG_5534.HEIC | Annapolis | Maryland State House | 38.900600, -76.657800
0010. IMG_5535.HEIC | Annapolis | Maryland State House | 38.934400, -76.633300
0011. IMG_5536.HEIC | Annapolis | Maryland State House | 38.658800, -76.651500
0012. IMG_5537.HEIC | Annapolis | Maryland Veterans Memorial | 38.628900, -76.443200
0013. IMG_5538.HEIC | Annapolis | Monument to the Chesapeake Bay Naval Expeditionary Force | 38.924800, -76.998400
0014. IMG_5539.HEIC | Annapolis | U.S. Naval Academy Memorial | 38.645500, -76.617500
0015. IMG_5540.HEIC | Annapolis | Maryland Veterans Memorial | 38.929300, -76.409800
0016. IMG_5541.HEIC | Annapolis | Maryland Veterans Memorial | 38.905300, -76.422600
0017. IMG_5542.HEIC | Annapolis | USS Maryland Memorial | 38.897500, -76.623000
0018. IMG_4615.HEIC | Annapolis | Statue of the Annapolis Children's Museum | 38.933300, -76.916700
0019. IMG_4617.HEIC | Annapolis | Statue of Robert Smalls | 38.974500, -76.401500
0020. IMG_5543.HEIC | Annapolis | Senate Chamber | 38.960600, -76.754800
0021. IMG_5544.HEIC | Annapolis | Maryland State House | 38.675000, -76.460000
0022. IMG_5545.HEIC | Annapolis | Maryland State House | 38.644400, -76.954400
0023. IMG_5546.HEIC | Annapolis | Senate Chamber | 38.994600, -76.948200
0024. IMG_5547.HEIC | Annapolis | The Secret Chamber | 38.975000, -76.550000
0025. IMG_5548.HEIC | Annapolis | Maryland State House | 38.956900, -76.462800
0026. IMG_5549.HEIC | Annapolis | Maryland House of Delegates | 38.945400, -76.552800
0027. IMG_5550.HEIC | Annapolis | Maryland State House dome | 38.655000, -76.912500
0028. IMG_5551.HEIC | Annapolis | Maryland State House dome | 38.666700, -76.500000
0029. IMG_5552.HEIC | Annapolis | Maryland State House | 38.833300, -76.666700
0030. IMG_5553.HEIC | Annapolis | Maryland State House | 38.951500, -76.574000
0031. IMG_5554.HEIC | Annapolis | Statue of Robert Treat Paine | 38.942800, -76.961200
0032. IMG_5555.HEIC | Annapolis | Statue of George Washington (foreground) and "Declaration of Independence" painting (background) | 38.967500, -76.953000
0033. IMG_5556.HEIC | Annapolis | Maryland State House | 38.942500, -76.556500
0034. IMG_5557.HEIC | Annapolis | Maryland State House | 38.944500, -76.563500
0035. IMG_5558.HEIC | Annapolis | Governor’s Palace | 38.804500, -76.974000
0036. IMG_5559.HEIC | Annapolis | Portrait of a historical figure in a formal setting | 38.920000, -76.730000
0037. IMG_5560.HEIC | Annapolis | Statue of a woman in historical dress | 38.679400, -76.973800
0038. IMG_5561.HEIC | Annapolis | Annapolis statue | 38.966700, -76.816700
0039. IMG_5562.HEIC | Annapolis | Statue of George Washington | 38.719500, -76.624000
0040. IMG_4618.HEIC | Annapolis | Maryland State House | 38.683300, -76.466700
0041. IMG_4619.HEIC | Annapolis | Maryland Medical Center | 38.928800, -76.619700
0042. IMG_5563.HEIC | Annapolis | Maryland State House | 38.981700, -76.938300
0043. IMG_5564.HEIC | Annapolis | Maryland State House | 38.933300, -76.483300
0044. IMG_4621.HEIC | Annapolis | Brown University | 38.908500, -66.426500
0045. IMG_5565.HEIC | Annapolis | Thurgood Marsh monument | 38.981900, -76.569100
0046. IMG_5566.HEIC | Annapolis | Thurgood Marshall statue | 38.773300, -76.667400
0047. IMG_4622.HEIC | Annapolis | Statue of William Cushing | 38.920000, -76.400000
0048. IMG_4623.HEIC | Annapolis | Statue of William Preston Hobby | 38.995000, -76.680000
0049. IMG_4625.HEIC | Annapolis | John Paul Jones Statue | 38.954900, -76.584300
0050. IMG_5567.HEIC | Annapolis | House with ornate wrought iron fence | 38.956900, -76.969500
0051. IMG_5568.HEIC | Annapolis | Shuttle information sign | 38.678900, -66.843200
0052. IMG_4631.HEIC | Annapolis | Annapolis waterfront | 38.923300, -76.734300
0053. IMG_4632.HEIC | Annapolis | Annapolis waterfront | 38.658300, -67.128300
0054. IMG_4634.HEIC | Annapolis | Maryland State House | 38.909100, -76.656700
0055. IMG_4635.HEIC | Annapolis | Maryland State House (visible in background) | 38.600000, -76.600000
0056. IMG_4636.HEIC | Annapolis | Annapolis waterfront | 38.975000, -76.715000
0057. IMG_5569.HEIC | Annapolis | United States Naval Academy | 38.842900, -76.462700
0058. IMG_5570.HEIC | Annapolis | Bust of Halsey | 38.932000, -76.415000
0059. IMG_4637.HEIC | Annapolis | Chesapeake Bay | 38.918900, -76.953400
0060. IMG_5572.HEIC | Annapolis | Maryland State House (implied by the context and arcade setting) | 38.978300, -76.532500
0061. IMG_5573.HEIC | Annapolis | Indoor amusement center or arcade | 38.692900, -76.455400

WARNING: AI locations may be incorrect.
Review the proposed locations before approving.

Type YES to apply ALL listed locations, or anything else to cancel: YES

========== APPLYING LOCATIONS ==========
1/61 UPDATED: IMG_5525.HEIC
2/61 UPDATED: IMG_5526.HEIC
3/61 UPDATED: IMG_5528.HEIC
4/61 UPDATED: IMG_5529.HEIC
5/61 UPDATED: IMG_5530.HEIC
6/61 UPDATED: IMG_5531.HEIC
7/61 UPDATED: IMG_5532.HEIC
8/61 UPDATED: IMG_5533.HEIC
9/61 UPDATED: IMG_5534.HEIC
10/61 UPDATED: IMG_5535.HEIC
11/61 UPDATED: IMG_5536.HEIC
12/61 UPDATED: IMG_5537.HEIC
13/61 UPDATED: IMG_5538.HEIC
14/61 UPDATED: IMG_5539.HEIC
15/61 UPDATED: IMG_5540.HEIC
16/61 UPDATED: IMG_5541.HEIC
17/61 UPDATED: IMG_5542.HEIC
18/61 UPDATED: IMG_4615.HEIC
19/61 UPDATED: IMG_4617.HEIC
20/61 UPDATED: IMG_5543.HEIC
21/61 UPDATED: IMG_5544.HEIC
22/61 UPDATED: IMG_5545.HEIC
23/61 UPDATED: IMG_5546.HEIC
24/61 UPDATED: IMG_5547.HEIC
25/61 UPDATED: IMG_5548.HEIC
26/61 UPDATED: IMG_5549.HEIC
27/61 UPDATED: IMG_5550.HEIC
28/61 UPDATED: IMG_5551.HEIC
29/61 UPDATED: IMG_5552.HEIC
30/61 UPDATED: IMG_5553.HEIC
31/61 UPDATED: IMG_5554.HEIC
32/61 UPDATED: IMG_5555.HEIC
33/61 UPDATED: IMG_5556.HEIC
34/61 UPDATED: IMG_5557.HEIC
35/61 UPDATED: IMG_5558.HEIC
36/61 UPDATED: IMG_5559.HEIC
37/61 UPDATED: IMG_5560.HEIC
38/61 UPDATED: IMG_5561.HEIC
39/61 UPDATED: IMG_5562.HEIC
40/61 UPDATED: IMG_4618.HEIC
41/61 UPDATED: IMG_4619.HEIC
42/61 UPDATED: IMG_5563.HEIC
43/61 UPDATED: IMG_5564.HEIC
44/61 UPDATED: IMG_4621.HEIC
45/61 UPDATED: IMG_5565.HEIC
46/61 UPDATED: IMG_5566.HEIC
47/61 UPDATED: IMG_4622.HEIC
48/61 UPDATED: IMG_4623.HEIC
49/61 UPDATED: IMG_4625.HEIC
50/61 UPDATED: IMG_5567.HEIC
51/61 UPDATED: IMG_5568.HEIC
52/61 UPDATED: IMG_4631.HEIC
53/61 UPDATED: IMG_4632.HEIC
54/61 UPDATED: IMG_4634.HEIC
55/61 UPDATED: IMG_4635.HEIC
56/61 UPDATED: IMG_4636.HEIC
57/61 UPDATED: IMG_5569.HEIC
58/61 UPDATED: IMG_5570.HEIC
59/61 UPDATED: IMG_4637.HEIC
60/61 UPDATED: IMG_5572.HEIC
61/61 UPDATED: IMG_5573.HEIC

========== COMPLETE ==========
ALBUM: 2023 - Annapolis
LOCATIONS UPDATED: 61
ERRORS: 0
UNRESOLVED: 2
PHOTOS DELETED: 0
ALBUMS DELETED: 0
DATABASE DIRECTLY MODIFIED: NO
PACKAGES INSTALLED: NONE

Then I tried the photos outside albums

The GPS run dealt with pictures already in an album. My second script, Photos-AI-Organizer.py, starts with the ones outside albums. I typed “Find photos of the tidal basin in March 2026.” The script parsed the month and year, found photos without regular-album membership in that date range, and grouped nearby capture times before FM saw an image. It found 266 candidates in six qualifying time groups. For each group, it sampled up to 12 originals, asked FM what each picture showed, and then asked whether the observations supported one event worth naming in my YYYY - Name format.

This is an excerpt of the first prompt the organizer sends to fm with a sampled photo. The script fills in the request and date for each image:

Analyze this photograph for an Apple Photos organizer.

USER REQUEST:
Find photos of the tidal basin in March 2026

PHOTO DATE:
[this photo's capture date]

Identify where this photograph was taken, the activity or event,
and visible landmarks. Do not invent details.

Return LOCATION, EVENT, EVIDENCE, CONFIDENCE, LATITUDE, LONGITUDE.

The second FM prompt takes those individual observations and asks whether they describe one event. It requests EVENT, LOCATION, SUPPORTING_SAMPLES, ALBUM_NAME, and DESCRIPTION. The script requires at least 80 percent of the usable samples to support the same event before it proposes an album.

The time groups needed visual checking. One group of 132 imported photos mixed portraits, travel scenes, a hospital room, and other unrelated pictures. Only 1 of 10 usable samples supported the same event, so the script skipped it. A birthday group hit an FM safety error on one image and ended with too few observations. The Tidal Basin group was clearer: 18 photos from March 29, with 10 of 12 sampled images supporting the cherry-blossom outing. FM proposed 2026 - Tidal Blossom Celebration.

The terminal then showed the proposal, my approval, and the failed verification:

What would you like to organize? Find photos of the tidal basin in March 2026

GROUP 6/6 | 18 photos | 2026-03-29
FM AGREEMENT: 10/12 (83.3%)
PROPOSED: 2026 - Tidal Blossom Celebration

Type YES to create ALL proposed albums: YES
PROCESSED: 18/18
VERIFIED IN ALBUM: 0
NOT VERIFIED: 18
APPLESCRIPT LOOKUP ERRORS: 18
STATUS: INCOMPLETE

FM helped identify the outing and name an album, but the script didn’t finish filing it. “Processed” counted attempted work; verification found no new membership. A later read-only album query found the proposed album with zero photos. The script’s use of Photos’ internal UUIDs and AppleScript lookup needs repair before I would trust it to file a batch. Although the organizer collects FM location observations, it does not change GPS data. That is the geotagger’s job.

The full terminal output below shows all six groups. It also contains descriptions of family and medical pictures from my library. If you save a log from your own run, read it before sharing it.

Full organizer terminal output: 266 candidates, six groups, and failed membership verification
What would you like to organize? Find photos of the tidal basin in March 2026

READING SMART ALBUM: Not in Album
DATE FILTER: {'start': '2026-03-01', 'end': '2026-03-31'}
QUERYING PHOTOS DATABASE...
SMART ALBUM PHOTOS: 266
PHOTOS FOUND IN DATABASE: 266
PHOTOS MATCHING REQUEST: 266

TEMPORAL BURSTS: 16
  EVENT 1: 14 photos 2026-03-08 00:15 to 2026-03-08 22:25
  EVENT 2: 34 photos 2026-03-09 23:38 to 2026-03-09 23:42
  EVENT 3: 22 photos 2026-03-11 00:25 to 2026-03-12 03:47
  EVENT 4: 132 photos 2026-03-13 00:23 to 2026-03-14 23:40
  EVENT 5: 43 photos 2026-03-15 14:44 to 2026-03-16 18:52
  EVENT 6: 18 photos 2026-03-29 17:33 to 2026-03-29 17:34

QUALIFYING EVENT GROUPS: 6
CANDIDATE GROUPS: 6

GROUP 1/6 | 14 photos | 2026-03-08 to 2026-03-08
  GROUP SIZE: 14 | FM SAMPLES: 12
  ORIGINAL MISSING: IMG_3203.MOV
  FM: IMG_3203.HEIC
    LOCATION: Unknown | EVENT: A child sleeping in a living room | CONFIDENCE: LOW
  ORIGINAL MISSING: IMG_3204.MOV
  ORIGINAL MISSING: IMG_3205.MOV
  FM: IMG_3205.HEIC
    LOCATION: Unknown | EVENT: A young boy relaxing with stuffed animals | CONFIDENCE: HIGH
  ORIGINAL MISSING: IMG_3206.MOV
  FM: IMG_3206.HEIC
    LOCATION: Unknown | EVENT: A child sleeping with stuffed animals | CONFIDENCE: HIGH
  ORIGINAL MISSING: IMG_3207.MOV
  ORIGINAL MISSING: IMG_3208.MOV
  FM: IMG_3208.HEIC
    LOCATION: Home | EVENT: Relaxing or resting | CONFIDENCE: HIGH
  FM: IMG_3209.HEIC
    LOCATION: Unrecognizable | EVENT: Retail display of a chocolate bunny | CONFIDENCE: HIGH
  FM: IMG_3210.PNG
    LOCATION: Unknown | EVENT: Brewing process for IPA beer | CONFIDENCE: LOW
  FM AGREEMENT: 1/6 (16.7%)
  SKIPPED: Below 80% agreement
  SKIPPED: No coherent high-confidence event

GROUP 2/6 | 34 photos | 2026-03-09 to 2026-03-09
  GROUP SIZE: 34 | FM SAMPLES: 12
  ORIGINAL MISSING: IMG_3211.MOV
  FM: IMG_3212.HEIC
    LOCATION: Unknown | EVENT: Birthday celebration | CONFIDENCE: HIGH
  ORIGINAL MISSING: IMG_3214.MOV
  FM: IMG_3215.HEIC
    LOCATION: Unknown | EVENT: Birthday celebration | CONFIDENCE: HIGH
  ORIGINAL MISSING: IMG_3217.MOV
  FM: IMG_3218.HEIC
    LOCATION: Unknown | EVENT: Birthday celebration | CONFIDENCE: HIGH
  ORIGINAL MISSING: IMG_3220.MOV
  FM: IMG_3221.HEIC
    LOCATION: Unknown | EVENT: Birthday celebration | CONFIDENCE: HIGH
  ORIGINAL MISSING: IMG_3223.MOV
  ORIGINAL MISSING: IMG_3225.MOV
  ORIGINAL MISSING: IMG_3226.MOV
  FM: IMG_3227.HEIC
  FM ERROR: Error: The model's safety guardrails were triggered.
  SKIPPED: Insufficient visual observations
  SKIPPED: No coherent high-confidence event

GROUP 3/6 | 22 photos | 2026-03-11 to 2026-03-12
  GROUP SIZE: 22 | FM SAMPLES: 12
  FM: 6cc234b7-c168-4da3-bbd0-3a1e6bc3af69.jpg
    LOCATION: North Summit, Denver, Colorado | EVENT: March 2026 event featuring large "2026" signage | CONFIDENCE: HIGH
  FM: 50197155112.jpg
    LOCATION: Beach | EVENT: Bird on the shore | CONFIDENCE: HIGH
  FM: 50197154972.jpg
    LOCATION: Doane's Wharf, Dublin, Ireland | EVENT: No specific event or occasion is visible | CONFIDENCE: HIGH
  FM: 50196891991.jpg
    LOCATION: Unknown | EVENT: Decorative fountain in a pond | CONFIDENCE: HIGH
  FM: 50196890316.jpg
    LOCATION: Unknown | EVENT: Unknown | CONFIDENCE: HIGH
  FM: 50197152917.jpg
    LOCATION: Unknown | EVENT: A butterfly resting on a flower | CONFIDENCE: HIGH
  FM: 50197152892.jpg
    LOCATION: Exterior of a building with potted plants. | EVENT: A woman standing at the entrance of a building. | CONFIDENCE: HIGH
  FM: 50197152577.jpg
    LOCATION: Coastal area with a small town and boats, possibly a fishing village. | EVENT: General view of a harbor or tidal basin with boats. | CONFIDENCE: HIGH
  FM: 50196889791.jpg
    LOCATION: Near the Korean coast, with mountains and a harbor visible. | EVENT: No specific event or occasion is identifiable. | CONFIDENCE: HIGH
  FM: 50196341503.jpg
    LOCATION: Offshore rocks in a coastal area, possibly near a lighthouse or similar structure. | EVENT: No specific event is visible; the image shows a natural seascape. | CONFIDENCE: HIGH
  FM: 50196889451.jpg
    LOCATION: Unknown | EVENT: Archaeological excavation or site survey | CONFIDENCE: HIGH
  FM: 50196341188.jpg
    LOCATION: Unknown | EVENT: Tidal activity | CONFIDENCE: HIGH
  FM AGREEMENT: 1/12 (8.3%)
  SKIPPED: Below 80% agreement
  SKIPPED: No coherent high-confidence event

GROUP 4/6 | 132 photos | 2026-03-13 to 2026-03-14
  GROUP SIZE: 132 | FM SAMPLES: 12
  FM: 31271009748.jpg
    LOCATION: Airfield or open grassy area near a helicopter | EVENT: Public event or display featuring a U.S. Coast Guard helicopter | CONFIDENCE: HIGH
  ORIGINAL MISSING: 39834620084.mp4
  FM: Scan_FAM 1.jpeg
    LOCATION: Unrecognizable | EVENT: Portrait | CONFIDENCE: LOW
  FM: Scan_port 10.jpeg
    LOCATION: Unknown | EVENT: School photo | CONFIDENCE: HIGH
  FM: Scan_port 5.jpeg
    LOCATION: [Not Recognizable] | EVENT: [Unknown] | CONFIDENCE: LOW
  FM: 00000414.jpg
    LOCATION: Japan | EVENT: Snow sculpture event | CONFIDENCE: HIGH
  FM: 30780269251.jpg
    LOCATION: Unknown | EVENT: Portrait session | CONFIDENCE: LOW
  FM: 27568501774.jpg
    LOCATION: Unknown | EVENT: Family sleeping | CONFIDENCE: HIGH
  FM: 27242851253.jpg
    LOCATION: Hospital room | EVENT: A child reading in a hospital setting | CONFIDENCE: HIGH
  FM: 28771873920.jpg
    LOCATION: Not recognizable | EVENT: Birthday celebration | CONFIDENCE: HIGH
  FM: 28984657786.jpg
    LOCATION: Unknown | EVENT: Walking | CONFIDENCE: MEDIUM
  ORIGINAL MISSING: IMG_3242.MOV
  FM AGREEMENT: 1/10 (10.0%)
  SKIPPED: Below 80% agreement
  SKIPPED: No coherent high-confidence event

GROUP 5/6 | 43 photos | 2026-03-15 to 2026-03-16
  GROUP SIZE: 43 | FM SAMPLES: 12
  FM: IMG_3244.HEIC
    LOCATION: Kitchen | EVENT: Cleaning dishes | CONFIDENCE: HIGH
  FM: IMG_3248.HEIC
    LOCATION: Kitchen | EVENT: Preparing to cook | CONFIDENCE: HIGH
  FM: 50197151957.jpg
    LOCATION: Unknown | EVENT: Flags displayed on a balcony | CONFIDENCE: HIGH
  FM: 50196340253.jpg
    LOCATION: Unknown | EVENT: Two men observing the sea | CONFIDENCE: HIGH
  FM: 50196885356.jpg
    LOCATION: Unknown | EVENT: Informal gathering or casual moment | CONFIDENCE: LOW
  FM: 50197128162.jpg
    LOCATION: Inside a building near a garden area with trees and a tent. | EVENT: The photo was taken during a casual gathering or event, possibly outdoors or in an open-air space. | CONFIDENCE: HIGH
  FM: 50196963822.jpg
    LOCATION: Unknown | EVENT: The person is holding a flower, possibly for a casual activity or observation. | CONFIDENCE: HIGH
  FM: 16941911779.jpg
    LOCATION: Unknown | EVENT: Jewelry display | CONFIDENCE: HIGH
  FM: 16507972353.jpg
    LOCATION: Unknown | EVENT: Unknown | CONFIDENCE: LOW
  FM: 16940437668.jpg
    LOCATION: Unknown | EVENT: Food stall or outdoor dining event | CONFIDENCE: HIGH
  FM: 16940705200.jpg
    LOCATION: Beach near water | EVENT: Casual outing or family time | CONFIDENCE: HIGH
  FM: 17128269075.jpg
    LOCATION: Unknown | EVENT: Unknown | CONFIDENCE: LOW
  FM AGREEMENT: 1/12 (8.3%)
  SKIPPED: Below 80% agreement
  SKIPPED: No coherent high-confidence event

GROUP 6/6 | 18 photos | 2026-03-29 to 2026-03-29
  GROUP SIZE: 18 | FM SAMPLES: 12
  FM: IMG_9794.JPG
    LOCATION: Tidal Basin, Washington, D.C. | EVENT: Cherry blossom viewing | CONFIDENCE: HIGH
  FM: IMG_9797.JPG
    LOCATION: Tidal Basin, Washington, D.C. | EVENT: Cherry blossom viewing | CONFIDENCE: HIGH
  FM: IMG_9798.JPG
    LOCATION: Cherry blossoms at the Tidal Basin, Washington, D.C. | EVENT: Cherry blossom viewing | CONFIDENCE: HIGH
  FM: IMG_9800.JPG
    LOCATION: Cherry blossoms in a park in Washington, DC | EVENT: Cherry blossom viewing | CONFIDENCE: HIGH
  FM: IMG_9801.JPG
    LOCATION: Cherry blossoms in a park, likely near the Tidal Basin in Washington, D.C. | EVENT: A person taking a selfie in a park with cherry blossom trees. | CONFIDENCE: HIGH
  FM: IMG_9803.JPG
    LOCATION: Cherry blossoms in a park | EVENT: Viewing cherry blossoms | CONFIDENCE: HIGH
  FM: IMG_9804.JPG
    LOCATION: Washington, D.C. | EVENT: A woman posing in front of a monument. | CONFIDENCE: HIGH
  FM: IMG_9806.JPG
    LOCATION: Washington, D.C. | EVENT: A person visiting the Tidal Basin in March. | CONFIDENCE: HIGH
  FM: IMG_9807.JPG
    LOCATION: Washington, D.C. | EVENT: A person visiting the National Mall, likely near the Tidal Basin. | CONFIDENCE: HIGH
  FM: IMG_9809.JPG
    LOCATION: Washington, D.C., USA | EVENT: Visiting the Tidal Basin, likely during a spring event featuring cherry blossoms | CONFIDENCE: HIGH
  FM: IMG_9811.JPG
    LOCATION: Near the Washington Monument, Washington, D.C. | EVENT: Self-portrait with the Washington Monument in the background. | CONFIDENCE: HIGH
  FM: IMG_9813.JPG
    LOCATION: Washington, D.C. | EVENT: Casual outing or sightseeing. | CONFIDENCE: HIGH
  FM AGREEMENT: 10/12 (83.3%)
  PROPOSED: 2026 - Tidal Blossom Celebration

========== PROPOSED ALBUMS ==========

1. 2026 - Tidal Blossom Celebration
   PHOTOS: 18
   AGREEMENT: 0.833
   REASON: Photographs documenting cherry blossom viewing at the Tidal Basin, Washington, D.C., in March 2026, including blooming trees, park settings, and people gathering.
   GPS: None, None

Type YES to create ALL proposed albums: YES

========== ORGANIZING PHOTOS ==========

ALBUM: 2026 - Tidal Blossom Celebration
PHOTOS SELECTED: 18
ADDING PHOTOS...
  PROCESSED: 18/18

VERIFYING ALBUM MEMBERSHIP...

========== ALBUM RESULTS ==========
ALBUM: 2026 - Tidal Blossom Celebration
EXPECTED PHOTOS: 18
VERIFIED IN ALBUM: 0
NOT VERIFIED: 18
APPLESCRIPT LOOKUP ERRORS: 18
OTHER APPLESCRIPT ERRORS: 0
STATUS: INCOMPLETE
Some photographs could not be verified in the destination album.

========== COMPLETE ==========
PHOTOS DELETED: 0
ALBUMS DELETED: 0
DATABASE DIRECTLY MODIFIED: NO
PACKAGES INSTALLED: NONE

Why the scripts kept going when the chat stopped

The two scripts handle the long-running work in Python. The organizer found 266 candidate pictures in the Photos database, divided them by capture time, and chose up to 12 images from each group. For each image, it asked fm one question: what place or event does this show? A separate call checked whether those answers supported one album. The geotagger followed the same pattern for locations, saving each answer in a JSONL file so I could resume an interrupted scan. The model didn’t have to remember the whole library or manage a long conversation.

I felt the difference when I tried Siri on a separate Bali album task. It used Photos’ own controls to add pictures from the 2023 trip, which was impressive. It also asked for repeated approvals and eventually ended the chat at its token limit. The screenshots show the album approval and result. My Apple Photos MCP post covers the Bali run in detail.

Siri asks approval before adding selected 2023 Bali items to an existing Photos album
Siri reports adding 38 items to the 2023 Bali album in the separate Bali test

Each fm respond call has its own limits and can still fail, as the birthday group and bad map pins showed. But there was no single growing chat that had to hold 266 photos, six groups, every model answer, and my decisions. Python kept the list, applied the 80 percent agreement rule, asked me for YES, and checked the result after writing. I could ask the model to scan one image or identify one location, then let the script handle the rest.

I would like Apple to bring that pattern into Photos itself: let me give it a large job, work through bounded batches, show the evidence for each proposed change, and verify what it did. The same approach could help Music build playlists or GarageBand organize a project library. The native app connection is already useful. The missing piece for me is a way to keep working without a chat ending halfway through the job.

The full scripts follow, including their fm prompts, SQL, menus, AppleScript, and verification code. Save the one for the job you want to try under the filename shown in its heading.

Photos-AI-Organizer.py

#!/usr/bin/env python3

import subprocess
import os
import sqlite3
import json
import re
import time
import math
import sys
from pathlib import Path
from datetime import datetime, timezone, timedelta
from collections import Counter

SOURCE_ALBUM = "Not in Album"

LIBRARY = Path(os.environ["PHOTOS_LIBRARY_PATH"]).expanduser()
DB = LIBRARY / "database/Photos.sqlite"

APPLE_EPOCH = 978307200

MIN_GROUP = 6
MAX_GAP_HOURS = 4
MAX_GROUP_SPAN_DAYS = 7
FM_SAMPLE_SIZE = 5

def log(*messages):
    print(*messages, flush=True)

def applescript(script, *args, timeout=600):
    result = subprocess.run(
        ["osascript", "-e", script, *map(str, args)],
        capture_output=True,
        text=True,
        timeout=timeout
    )

    if result.returncode:
        raise RuntimeError(
            result.stderr.strip() or result.stdout.strip()
        )

    return result.stdout.strip()

def get_smart_album_photos(period):
    """
    Query unorganized photographs within the requested date range.
    """

    start = period.get("start")
    end = period.get("end")

    if start:
        start_ts = (
            datetime.fromisoformat(start)
            .replace(tzinfo=timezone.utc)
            .timestamp() - APPLE_EPOCH
        )
    else:
        start_ts = -99999999999

    if end:
        end_ts = (
            datetime.fromisoformat(end)
            .replace(tzinfo=timezone.utc)
            .timestamp() - APPLE_EPOCH + 86400
        )
    else:
        end_ts = 99999999999

    db = sqlite3.connect(DB.as_uri() + "?mode=ro", uri=True)
    db.execute("PRAGMA query_only=ON")

    try:
        rows = db.execute("""
            SELECT a.ZUUID
            FROM ZASSET a
            WHERE a.ZDATECREATED >= ?
              AND a.ZDATECREATED < ?
              AND COALESCE(a.ZTRASHEDSTATE, 0) = 0
              AND NOT EXISTS (
                  SELECT 1
                  FROM Z_34ASSETS m
                  JOIN ZGENERICALBUM g
                    ON g.Z_PK = m.Z_34ALBUMS
                  WHERE m.Z_3ASSETS = a.Z_PK
                    AND COALESCE(g.ZTRASHEDSTATE, 0) = 0
                    AND g.ZKIND = 2
              )
        """, (start_ts, end_ts)).fetchall()

        return {row[0].upper() for row in rows}

    finally:
        db.close()

def read_photos(uuids):
    db = sqlite3.connect(DB.as_uri() + "?mode=ro", uri=True)
    db.execute("PRAGMA query_only=ON")

    try:
        photos = []

        for uuid, captured, lat, lon, filename in db.execute("""
            SELECT
                a.ZUUID,
                a.ZDATECREATED,
                a.ZLATITUDE,
                a.ZLONGITUDE,
                COALESCE(x.ZORIGINALFILENAME, a.ZFILENAME)
            FROM ZASSET a
            LEFT JOIN ZADDITIONALASSETATTRIBUTES x
              ON x.ZASSET = a.Z_PK
            WHERE COALESCE(a.ZTRASHEDSTATE, 0) = 0
        """):
            if uuid.upper() not in uuids or captured is None:
                continue

            date = datetime.fromtimestamp(
                captured + APPLE_EPOCH,
                timezone.utc
            )

            photos.append({
                "uuid": uuid,
                "date": date,
                "filename": filename,
                "latitude": lat,
                "longitude": lon
            })

        return sorted(photos, key=lambda x: x["date"])

    finally:
        db.close()

def original_path(uuid):
    folder = LIBRARY / "originals" / uuid[0].upper()

    if not folder.exists():
        return None

    for path in folder.glob(uuid + ".*"):
        if path.suffix.lower() in (
            ".heic", ".jpg", ".jpeg", ".png"
        ):
            return path

    return None


def parse_request(request, photos=None):
    import calendar

    months = {
        name.lower(): number
        for number, name in enumerate(calendar.month_name)
        if name
    }

    months.update({
        name.lower(): number
        for number, name in enumerate(calendar.month_abbr)
        if name
    })

    pattern = (
        r"\b(" +
        "|".join(map(re.escape, months)) +
        r")\s+(\d{4})\b"
    )

    match = re.search(pattern, request, re.IGNORECASE)

    if not match:
        raise SystemExit(
            "ERROR: Include a month and year, "
            "for example March 2026."
        )

    month = months[match.group(1).lower()]
    year = int(match.group(2))

    last_day = calendar.monthrange(year, month)[1]

    return {
        "start": f"{year:04d}-{month:02d}-01",
        "end": f"{year:04d}-{month:02d}-{last_day:02d}"
    }

def filter_photos(photos, period):
    start = period.get("start")
    end = period.get("end")

    if start:
        start = datetime.fromisoformat(start).date()

    if end:
        end = datetime.fromisoformat(end).date()

    return [
        photo for photo in photos
        if (start is None or photo["date"].date() >= start)
        and (end is None or photo["date"].date() <= end)
    ]


def cluster_photos(photos):
    """
    Discover short photographic events using timestamps.

    FM is not involved in this process.
    """

    from collections import Counter

    if not photos:
        return []

    photos = sorted(photos, key=lambda p: p["date"])

    MIN_PHOTOS = 6
    MAX_GAP_HOURS = 4
    MAX_EVENT_DAYS = 2
    MAX_OVERNIGHT_HOURS = 18

    # Build individual photographic bursts.
    bursts = []
    current = [photos[0]]

    for photo in photos[1:]:

        previous = current[-1]

        gap = (
            photo["date"] - previous["date"]
        ).total_seconds() / 3600

        if gap <= MAX_GAP_HOURS:
            current.append(photo)
        else:
            bursts.append(current)
            current = [photo]

    bursts.append(current)

    log("\nTEMPORAL BURSTS:", len(bursts))

    # Combine nearby bursts from the same day.
    daily = {}

    for burst in bursts:
        for photo in burst:
            day = photo["date"].date()
            daily.setdefault(day, []).append(photo)

    days = sorted(daily)

    events = []
    index = 0

    while index < len(days):

        day = days[index]
        group = sorted(
            daily[day],
            key=lambda p: p["date"]
        )

        # Consider an overnight continuation.
        if index + 1 < len(days):

            next_day = days[index + 1]

            if (next_day - day).days == 1:

                next_group = sorted(
                    daily[next_day],
                    key=lambda p: p["date"]
                )

                overnight_gap = (
                    next_group[0]["date"] -
                    group[-1]["date"]
                ).total_seconds() / 3600

                if (
                    len(group) >= MIN_PHOTOS
                    and len(next_group) >= MIN_PHOTOS
                    and 0 <= overnight_gap <= MAX_OVERNIGHT_HOURS
                ):
                    group.extend(next_group)
                    index += 1

        if len(group) >= MIN_PHOTOS:

            group.sort(key=lambda p: p["date"])

            events.append(group)

            log(
                f"  EVENT {len(events)}:",
                f"{len(group)} photos",
                group[0]["date"].strftime("%Y-%m-%d %H:%M"),
                "to",
                group[-1]["date"].strftime("%Y-%m-%d %H:%M")
            )

        index += 1

    log("\nQUALIFYING EVENT GROUPS:", len(events))

    return events

def fm_image(path, request, date):
    prompt = f"""
Analyze this photograph for an Apple Photos organizer.

USER REQUEST:
{request}

PHOTO DATE:
{date}

Identify:
- Where this photograph was taken, if recognizable.
- The activity, event, or occasion.
- Visible landmarks, signs, or contextual clues.

Do not invent details.

Return exactly:

LOCATION:
EVENT:
EVIDENCE:
CONFIDENCE: HIGH / MEDIUM / LOW / UNKNOWN
LATITUDE:
LONGITUDE:
"""

    result = subprocess.run(
        [
            "fm", "respond",
            "--no-stream",
            "--image", str(path),
            "--text", prompt
        ],
        capture_output=True,
        text=True,
        timeout=180
    )

    if result.returncode:
        raise RuntimeError(result.stderr.strip())

    fields = {}

    for key in (
        "LOCATION", "EVENT", "EVIDENCE",
        "CONFIDENCE", "LATITUDE", "LONGITUDE"
    ):
        match = re.search(
            rf"^{key}:\s*(.*)$",
            result.stdout,
            re.M | re.I
        )
        fields[key.lower()] = (
            match.group(1).strip() if match else "UNKNOWN"
        )

    return fields


def analyze_group(group, request):
    """
    FM validates and names a group.

    It cannot change the group's boundaries.
    """

    import json
    import math

    SAMPLE_SIZE = min(12, len(group))

    indices = sorted({
        round(
            i * (len(group) - 1) /
            max(1, SAMPLE_SIZE - 1)
        )
        for i in range(SAMPLE_SIZE)
    })

    observations = []

    log(
        "  GROUP SIZE:", len(group),
        "| FM SAMPLES:", len(indices)
    )

    for index in indices:

        photo = group[index]
        path = original_path(photo["uuid"])

        if not path:
            log("  ORIGINAL MISSING:", photo["filename"])
            continue

        log("  FM:", photo["filename"])

        try:
            result = fm_image(
                path,
                request,
                photo["date"].isoformat()
            )

            observations.append(result)

            log(
                "    LOCATION:",
                result.get("location", "UNKNOWN"),
                "| EVENT:",
                result.get("event", "UNKNOWN"),
                "| CONFIDENCE:",
                result.get("confidence", "UNKNOWN")
            )

        except Exception as error:
            log("  FM ERROR:", error)

    if len(observations) < 5:
        log("  SKIPPED: Insufficient visual observations")
        return None

    summary = "\n".join(
        json.dumps(o, ensure_ascii=False)
        for o in observations
    )

    year = group[0]["date"].year

    prompt = f"""
You are naming an Apple Photos event album.

The script has ALREADY identified the event group
using photographic timestamps.

You must NOT regroup photographs or change dates.

USER REQUEST:
{request}

YEAR:
{year}

EVENT DATES:
{group[0]['date']} to {group[-1]['date']}

TOTAL PHOTOGRAPHS:
{len(group)}

VISUAL OBSERVATIONS:
{summary}

Determine whether the observations describe
one coherent event, outing, celebration, or trip.

Do not invent locations, landmarks, or activities.

Return exactly:

EVENT:
LOCATION:
SUPPORTING_SAMPLES:
ALBUM_NAME:
DESCRIPTION:

SUPPORTING_SAMPLES must be an integer counting
observations that clearly support the SAME event.

ALBUM_NAME must follow:
{year} - Descriptive Name

If the evidence is insufficient, return UNKNOWN.

Do not return JSON.
"""

    result = subprocess.run(
        [
            "fm",
            "respond",
            "--no-stream",
            "--text",
            prompt
        ],
        capture_output=True,
        text=True,
        timeout=180
    )

    if result.returncode:
        raise RuntimeError(result.stderr.strip())

    def field(name):
        match = re.search(
            rf"^{name}:\s*(.*)$",
            result.stdout,
            re.MULTILINE | re.IGNORECASE
        )

        return match.group(1).strip() if match else "UNKNOWN"

    event = field("EVENT")
    location = field("LOCATION")
    name = field("ALBUM_NAME")
    description = field("DESCRIPTION")

    try:
        supporting = int(field("SUPPORTING_SAMPLES"))
    except ValueError:
        supporting = 0

    if supporting < 0 or supporting > len(observations):
        log(
            "  FM RETURNED INVALID SUPPORT COUNT:",
            supporting,
            "for",
            len(observations),
            "samples"
        )
        return None

    agreement = supporting / len(observations)

    log(
        "  FM AGREEMENT:",
        f"{supporting}/{len(observations)}",
        f"({agreement:.1%})"
    )

    if agreement < 0.80:
        log("  SKIPPED: Below 80% agreement")
        return None

    if (
        event.upper() == "UNKNOWN"
        or name.upper() == "UNKNOWN"
    ):
        log("  SKIPPED: Event not identified")
        return None

    if not re.fullmatch(
        rf"{year} - .+",
        name
    ):
        log("  SKIPPED: Invalid album name")
        return None

    return {
        "accepted": True,
        "agreement": round(agreement, 3),
        "album_name": name,
        "description": description,
        "event": event,
        "location": location,
        "latitude": None,
        "longitude": None,
        "photos": group,
        "observations": observations
    }

CREATE_ALBUM = '''
on run argv
    tell application "Photos"
        with timeout of 300 seconds
            set albumName to item 1 of argv
            set matches to every album whose name is albumName

            if (count of matches) > 0 then
                return id of item 1 of matches
            end if

            set newAlbum to make new album named albumName
            return id of newAlbum
        end timeout
    end tell
end run
'''

ADD_PHOTO = '''
on run argv
    tell application "Photos"
        with timeout of 300 seconds
            set photoID to item 1 of argv
            set albumID to item 2 of argv

            set targetPhoto to media item id photoID
            set targetAlbum to album id albumID

            add {targetPhoto} to targetAlbum

            return "ADDED"
        end timeout
    end tell
end run
'''

SET_GPS = '''
on run argv
    tell application "Photos"
        with timeout of 300 seconds
            set photoID to item 1 of argv
            set lat to (item 2 of argv) as real
            set lon to (item 3 of argv) as real

            set targetPhoto to media item id photoID
            set location of targetPhoto to {lat, lon}

            return "GPS UPDATED"
        end timeout
    end tell
end run
'''


def verify_album_membership(album_id, expected_uuids):
    """
    Read-only verification against the Photos database.

    Returns:
        present: UUIDs currently in the destination album
        missing: UUIDs not currently in the destination album
    """

    album_uuid = album_id.split("/")[0].upper()

    db = sqlite3.connect(
        DB.as_uri() + "?mode=ro",
        uri=True,
        timeout=60
    )

    db.execute("PRAGMA query_only=ON")

    try:
        rows = db.execute("""
            SELECT UPPER(a.ZUUID)
            FROM ZGENERICALBUM g
            JOIN Z_34ASSETS m
              ON m.Z_34ALBUMS = g.Z_PK
            JOIN ZASSET a
              ON a.Z_PK = m.Z_3ASSETS
            WHERE UPPER(g.ZUUID) = ?
              AND COALESCE(g.ZTRASHEDSTATE, 0) = 0
              AND COALESCE(a.ZTRASHEDSTATE, 0) = 0
        """, (album_uuid,)).fetchall()

        actual = {row[0] for row in rows}

        expected = {
            uuid.upper()
            for uuid in expected_uuids
        }

        return expected & actual, expected - actual

    finally:
        db.close()

def main():
    log("========== APPLE PHOTOS AI ORGANIZER ==========")

    request = input("\nWhat would you like to organize? ").strip()

    if not request:
        raise SystemExit("No request entered.")

    log("\nREADING SMART ALBUM: " + SOURCE_ALBUM)

    period = parse_request(request, [])

    log("DATE FILTER: " + str(period))

    log("QUERYING PHOTOS DATABASE...")
    uuids = get_smart_album_photos(period)

    log("SMART ALBUM PHOTOS: " + str(len(uuids)))

    photos = read_photos(uuids)

    log("PHOTOS FOUND IN DATABASE: " + str(len(photos)))

    selected = filter_photos(photos, period)

    log("PHOTOS MATCHING REQUEST: " + str(len(selected)))

    groups = cluster_photos(selected)

    log("CANDIDATE GROUPS: " + str(len(groups)))

    proposals = []

    for index, group in enumerate(groups, 1):
        log("")
        log(
            f"GROUP {index}/{len(groups)} | "
            f"{len(group)} photos | "
            f"{group[0]['date'].date()} "
            f"to {group[-1]['date'].date()}"
        )

        try:
            proposal = analyze_group(group, request)

            if proposal:
                proposals.append(proposal)
                log("  PROPOSED: " + proposal["album_name"])
            else:
                log("  SKIPPED: No coherent high-confidence event")

        except Exception as error:
            log("  ERROR: " + str(error))

    log("\n========== PROPOSED ALBUMS ==========")

    if not proposals:
        log("No qualifying albums found.")
        return

    for index, proposal in enumerate(proposals, 1):
        log("")
        log(f"{index}. {proposal['album_name']}")
        log("   PHOTOS: " + str(len(proposal["photos"])))
        log("   AGREEMENT: " + str(proposal["agreement"]))
        log("   REASON: " + proposal["description"])
        log(
            "   GPS: "
            + str(proposal.get("latitude"))
            + ", "
            + str(proposal.get("longitude"))
        )

    approval = input(
        "\nType YES to create ALL proposed albums: "
    ).strip()

    if approval != "YES":
        log("CANCELLED: No changes made.")
        return

    log("\n========== ORGANIZING PHOTOS ==========")

    for proposal in proposals:
        name = proposal["album_name"]

        log("\nALBUM: " + name)

        album_id = applescript(CREATE_ALBUM, name)

        expected = {
            photo["uuid"].upper()
            for photo in proposal["photos"]
        }

        total = len(expected)

        print("PHOTOS SELECTED:", total)
        print("ADDING PHOTOS...", flush=True)

        successful_requests = 0
        lookup_errors = 0
        other_errors = 0

        for index, photo in enumerate(proposal["photos"], 1):

            photo_id = photo["uuid"] + "/L0/001"

            try:
                result = applescript(
                    ADD_PHOTO,
                    photo_id,
                    album_id
                )

                if result == "ADDED":
                    successful_requests += 1
                else:
                    other_errors += 1

            except Exception as error:
                message = str(error)

                if (
                    "Can't get media item id" in message
                    or "Can’t get media item id" in message
                    or "(-1728)" in message
                ):
                    lookup_errors += 1
                else:
                    other_errors += 1

            if index % 25 == 0 or index == len(proposal["photos"]):
                print(
                    f"  PROCESSED: {index}/{len(proposal['photos'])}",
                    flush=True
                )

        print("\nVERIFYING ALBUM MEMBERSHIP...", flush=True)

        present = set()
        missing = expected

        for attempt in range(1, 6):

            try:
                present, missing = verify_album_membership(
                    album_id,
                    expected
                )

            except Exception as error:
                print(
                    "VERIFICATION ERROR:",
                    str(error)
                )
                break

            if not missing:
                break

            if attempt < 5:
                time.sleep(2)

        print()
        print("========== ALBUM RESULTS ==========")
        print("ALBUM:", name)
        print("EXPECTED PHOTOS:", total)
        print("VERIFIED IN ALBUM:", len(present))
        print("NOT VERIFIED:", len(missing))
        print("APPLESCRIPT LOOKUP ERRORS:", lookup_errors)
        print("OTHER APPLESCRIPT ERRORS:", other_errors)

        if not missing:
            print("STATUS: COMPLETE")
        else:
            print("STATUS: INCOMPLETE")
            print(
                "Some photographs could not be verified "
                "in the destination album."
            )

    log("\n========== COMPLETE ==========")
    log("PHOTOS DELETED: 0")
    log("ALBUMS DELETED: 0")
    log("DATABASE DIRECTLY MODIFIED: NO")
    log("PACKAGES INSTALLED: NONE")

if __name__ == "__main__":
    main()

Photos-Auto-Geotag.py

#!/usr/bin/env python3

import sqlite3
import subprocess
import os
import json
import re
import time
from pathlib import Path
from datetime import datetime, timezone

LIBRARY = Path(os.environ["PHOTOS_LIBRARY_PATH"]).expanduser()
DB = LIBRARY / "database/Photos.sqlite"
APPLE_EPOCH = 978307200

WORK = Path.home() / "Desktop" / "Photos-Geotag-Work"
WORK.mkdir(exist_ok=True)

def run_applescript(script, *args, timeout=300):
    result = subprocess.run(
        ["osascript", "-e", script, *map(str, args)],
        capture_output=True,
        text=True,
        timeout=timeout
    )
    if result.returncode:
        raise RuntimeError(result.stderr.strip() or result.stdout.strip())
    return result.stdout.strip()

def original_path(uuid):
    folder = LIBRARY / "originals" / uuid[0].upper()
    for ext in (".heic", ".HEIC", ".jpg", ".JPG", ".jpeg", ".JPEG", ".png", ".PNG"):
        path = folder / (uuid + ext)
        if path.is_file():
            return path
    return None

def capture_date(value):
    if value is None:
        return "UNKNOWN"
    return datetime.fromtimestamp(
        value + APPLE_EPOCH, timezone.utc
    ).isoformat()

def missing_gps(lat, lon):
    return (
        lat is None or lon is None
        or not (-90 <= lat <= 90 and -180 <= lon <= 180)
        or (lat, lon) == (0, 0)
    )

def get_album():
    name = input("\nEnter Photos album name: ").strip()
    if not name:
        raise SystemExit("No album entered.")

    db = sqlite3.connect(DB.as_uri() + "?mode=ro", uri=True)
    db.execute("PRAGMA query_only=ON")

    try:
        matches = db.execute("""
            SELECT Z_PK, ZUUID, ZTITLE
            FROM ZGENERICALBUM
            WHERE ZTITLE = ?
              AND COALESCE(ZTRASHEDSTATE, 0) = 0
        """, (name,)).fetchall()

        if len(matches) != 1:
            raise SystemExit(
                f"Expected exactly one album named {name!r}; found {len(matches)}."
            )

        pk, album_uuid, album_name = matches[0]

        rows = db.execute("""
            SELECT DISTINCT
                a.ZUUID,
                a.ZDATECREATED,
                a.ZLATITUDE,
                a.ZLONGITUDE,
                x.ZORIGINALFILENAME
            FROM Z_34ASSETS m
            JOIN ZASSET a ON a.Z_PK = m.Z_3ASSETS
            LEFT JOIN ZADDITIONALASSETATTRIBUTES x
              ON x.ZASSET = a.Z_PK
            WHERE m.Z_34ALBUMS = ?
              AND COALESCE(a.ZTRASHEDSTATE, 0) = 0
            ORDER BY a.ZDATECREATED, a.ZUUID
        """, (pk,)).fetchall()

        photos = []
        all_photos = []

        for uuid, date, lat, lon, filename in rows:
            item = {
                "uuid": uuid,
                "filename": filename or uuid,
                "date": capture_date(date)
            }

            all_photos.append(item)

            if missing_gps(lat, lon):
                photos.append(item)

        return album_name, album_uuid, len(rows), photos, all_photos

    finally:
        db.close()

def analyze(photo, album, date):
    prompt = f"""
Analyze this actual photograph for geographic identification.

KNOWN PHOTO ALBUM:
{album}

PHOTO CAPTURE DATE:
{date}

The album provides context about the event or trip.

Examine the actual photograph carefully.

Identify the visible geographic location using:
- Recognizable monuments and landmarks
- Distinctive architecture
- Visible signs and inscriptions
- Surrounding buildings and geography
- Context from the album and capture date

Identify the landmark and city before estimating coordinates.

Use the album context to distinguish visually similar
locations, but do not invent evidence or assume that
every photograph must match the album.

If the photograph is consistent with the album's
known event or location, prefer that interpretation
over an unrelated speculative location.

Do not invent landmarks, cities, or coordinates.

Return exactly these fields, one per line:

COUNTRY:
REGION:
CITY:
LANDMARK:
EVIDENCE:
CONFIDENCE: HIGH / MEDIUM / LOW / UNKNOWN
LATITUDE:
LONGITUDE:

Coordinates must use signed decimal degrees.

North latitude is positive.
South latitude is negative.
East longitude is positive.
West longitude is negative.

For example, western longitude must have a minus sign.

If the location cannot be identified reliably,
return UNKNOWN rather than guessing.
"""

    result = subprocess.run(
        [
            "fm", "respond",
            "--no-stream",
            "--image", str(photo),
            "--text", prompt
        ],
        capture_output=True,
        text=True,
        timeout=180
    )

    if result.returncode:
        raise RuntimeError(result.stderr.strip() or result.stdout.strip())

    raw = result.stdout

    fields = {}

    for key in (
        "COUNTRY", "REGION", "CITY", "LANDMARK",
        "EVIDENCE", "CONFIDENCE", "LATITUDE", "LONGITUDE"
    ):
        match = re.search(
            rf"^{key}:\s*(.*)$",
            raw,
            re.MULTILINE | re.IGNORECASE
        )
        fields[key.lower()] = match.group(1).strip() if match else "UNKNOWN"

    return fields

def coordinate(value, low, high):
    import math
    import re

    if value is None:
        return None

    text = str(value).strip().upper()

    if text in ("", "UNKNOWN", "NONE", "NULL", "N/A"):
        return None

    match = re.fullmatch(
        r"([+-]?\d+(?:\.\d+)?)\s*°?\s*([NSEW])?",
        text
    )

    if not match:
        return None

    number = float(match.group(1))
    direction = match.group(2)

    if direction in ("S", "W"):
        number = -abs(number)
    elif direction in ("N", "E"):
        number = abs(number)

    if not math.isfinite(number):
        return None

    if not low <= number <= high:
        return None

    return number


SET_LOCATION = '''
on run argv
    set photoID to item 1 of argv
    set newLatitude to (item 2 of argv) as real
    set newLongitude to (item 3 of argv) as real

    tell application "Photos"
        with timeout of 300 seconds
            set targetPhoto to media item id photoID
            set location of targetPhoto to {newLatitude, newLongitude}
            return "UPDATED"
        end timeout
    end tell
end run
'''





def trusted_album_location(album, photos, known_location=None):
    import math

    location = known_location or input(
        "\nEnter known location: "
    ).strip()

    if not location:
        print("CANCELLED")
        return

    print("\n========== FM TRUSTED LOCATION ==========")
    print("ALBUM:", album)
    print("EXPECTED LOCATION:", location)
    print("PHOTOS SELECTED:", len(photos))

    sample_count = min(5, len(photos))

    indices = sorted({
        round(i * (len(photos) - 1) / max(1, sample_count - 1))
        for i in range(sample_count)
    })

    def field(text, name):
        match = re.search(
            rf"^\s*{re.escape(name)}:\s*(.*)$",
            text,
            re.MULTILINE | re.IGNORECASE
        )
        return match.group(1).strip() if match else "UNKNOWN"

    def ask_fm(prompt, image=None):
        command = ["fm", "respond", "--no-stream"]

        if image:
            command.extend(["--image", str(image)])

        command.extend(["--text", prompt])

        result = subprocess.run(
            command,
            capture_output=True,
            text=True,
            timeout=180
        )

        if result.returncode:
            raise RuntimeError(
                result.stderr.strip() or result.stdout.strip()
            )

        return result.stdout

    print("\n========== VALIDATING SAMPLE PHOTOS ==========")

    matches = 0
    contradictions = 0
    unknown = 0

    for index, photo_index in enumerate(indices, 1):
        item = photos[photo_index]
        path = original_path(item["uuid"])

        if not path:
            print("ORIGINAL MISSING:", item["filename"])
            unknown += 1
            continue

        print(
            f"{index}/{len(indices)} ANALYZING:",
            item["filename"],
            flush=True
        )

        prompt = f"""
Examine this photograph.

KNOWN ALBUM:
{album}

EXPECTED LOCATION:
{location}

Determine whether the photograph is visually
consistent with the expected location.

Nearby landmarks and buildings may be consistent.

Do not assume a match solely from the album name.
Do not guess GPS coordinates.

Return exactly:

MATCH: YES / NO / UNKNOWN
IDENTIFIED LOCATION:
EVIDENCE:
"""

        try:
            raw = ask_fm(prompt, path)
            match = field(raw, "MATCH").upper()

            print("MATCH:", match)
            print("IDENTIFIED:", field(raw, "IDENTIFIED LOCATION"))
            print("EVIDENCE:", field(raw, "EVIDENCE"))

            if match == "YES":
                matches += 1
            elif match == "NO":
                contradictions += 1
            else:
                unknown += 1

        except Exception as error:
            print("FM ERROR:", error)
            unknown += 1

    print("\n========== SAMPLE RESULTS ==========")
    print("MATCHES:", matches)
    print("CONTRADICTIONS:", contradictions)
    print("UNKNOWN:", unknown)

    required = math.ceil(len(indices) * 0.8)

    if matches < required or contradictions:
        print("STOP: Insufficient location agreement.")
        print("No photographs modified.")
        return

    print("\n========== RESOLVING LOCATION ==========")

    prompt = f"""
Identify approximate GPS coordinates for this
specific named geographic location:

{location}

Use the actual landmark, not the city center.

The location may be anywhere in the world.

Use signed decimal degrees:
North positive, South negative.
East positive, West negative.

Return exactly:

LOCATION:
LATITUDE:
LONGITUDE:
"""

    try:
        raw = ask_fm(prompt)
    except Exception as error:
        print("FM ERROR:", error)
        return

    resolved = field(raw, "LOCATION")

    latitude = coordinate(field(raw, "LATITUDE"), -90, 90)
    longitude = coordinate(field(raw, "LONGITUDE"), -180, 180)

    print("\n========== FM LOCATION PROPOSAL ==========")
    print("LOCATION:", resolved)
    print("LATITUDE:", latitude)
    print("LONGITUDE:", longitude)

    print("\nFM coordinates are estimates.")
    print("Review both numbers, especially longitude signs.")

    print("\nCOORDINATE OPTIONS:")
    print("1. Accept FM coordinates")
    print("2. Enter corrected coordinates")
    print("3. Cancel")

    choice = input("\nSelection: ").strip()

    if choice == "1":
        if latitude is None or longitude is None:
            print("STOP: FM did not provide valid coordinates.")
            return

    elif choice == "2":
        try:
            latitude = float(
                input("Correct latitude: ").strip()
            )
            longitude = float(
                input("Correct longitude: ").strip()
            )
        except ValueError:
            print("STOP: Invalid coordinates.")
            return

    else:
        print("CANCELLED.")
        return

    if not (
        math.isfinite(latitude)
        and math.isfinite(longitude)
        and -90 <= latitude <= 90
        and -180 <= longitude <= 180
    ):
        print("STOP: Coordinates outside valid ranges.")
        return

    print("\n========== FINAL GPS CONFIRMATION ==========")
    print("ALBUM:", album)
    print("LOCATION:", location)
    print("PHOTOS TO UPDATE:", len(photos))
    print("LATITUDE:", latitude)
    print("LONGITUDE:", longitude)
    print(
        "LONGITUDE HEMISPHERE:",
        "WEST" if longitude < 0 else "EAST"
    )

    print("\nAll selected photos will receive these coordinates.")

    approval = input(
        "\nType YES to apply GPS coordinates: "
    ).strip()

    if approval != "YES":
        print("CANCELLED: No changes made.")
        return

    print("\n========== APPLYING GPS ==========")

    updated = 0
    errors = 0

    for index, item in enumerate(photos, 1):
        photo_id = item["uuid"] + "/L0/001"

        try:
            result = run_applescript(
                SET_LOCATION,
                photo_id,
                latitude,
                longitude
            )

            if result == "UPDATED":
                updated += 1
                print(
                    f"{index}/{len(photos)} UPDATED:",
                    item["filename"],
                    flush=True
                )
            else:
                errors += 1
                print(
                    "UNEXPECTED RESULT:",
                    item["filename"],
                    result
                )

        except Exception as error:
            errors += 1
            print("ERROR:", item["filename"], error)

    print("\n========== COMPLETE ==========")
    print("GPS UPDATE REQUESTS SUCCEEDED:", updated)
    print("ERRORS:", errors)
    print("PHOTOS DELETED: 0")
    print("ALBUMS DELETED: 0")
    print("DATABASE DIRECTLY MODIFIED: NO")

def main():
    print("========== APPLE PHOTOS AUTO-GEOTAG ==========")

    album, album_uuid, total, photos, all_photos = get_album()

    print("\nALBUM:", album)
    print("TOTAL PHOTOS:", total)
    print("MISSING GPS:", len(photos))

    print()
    print("GPS UPDATE MODE:")
    print("1. Tag only photographs missing GPS")
    print("2. Re-tag ALL photographs in this album")
    print()

    gps_mode = input("Select 1 or 2: ").strip()

    if gps_mode == "2":
        photos = all_photos
        print()
        print("RE-TAG MODE ENABLED")
        print("PHOTOGRAPHS SELECTED:", len(photos))
        print("Existing GPS coordinates will be replaced after approval.")

    elif gps_mode == "1":
        if not photos:
            print("All photographs already have GPS.")
            return

    else:
        print("CANCELLED.")
        return


    print()
    print("LOCATION MODE:")
    print("1. Use one known location for the entire album")
    print("2. Analyze each photograph individually with FM")
    print()
    print("You may also enter a location directly.")

    mode = input("\nSelection or location: ").strip()

    if mode == "1":
        trusted_album_location(album, photos)
        return

    if mode != "2":
        if not mode:
            print("CANCELLED.")
            return

        trusted_album_location(album, photos, known_location=mode)
        return


    safe_album = re.sub(r"[^A-Za-z0-9_-]+", "_", album)
    cache = WORK / f"{safe_album}-FM-results.jsonl"
    completed = {}

    if cache.exists():
        with cache.open("r", encoding="utf-8") as previous:
            for line in previous:
                try:
                    record = json.loads(line)
                    if "uuid" in record:
                        completed[record["uuid"]] = record
                except (ValueError, KeyError):
                    continue

    print("\n========== ANALYZING PHOTOGRAPHS ==========")

    with cache.open("a", encoding="utf-8") as output:
        for index, item in enumerate(photos, 1):
            uuid = item["uuid"]

            if uuid in completed:
                continue

            path = original_path(uuid)

            if not path:
                print(f"{index}/{len(photos)} ORIGINAL NOT FOUND: {item['filename']}")
                continue

            print(
                f"{index}/{len(photos)} ANALYZING: {item['filename']}",
                flush=True
            )

            try:
                result = analyze(path, album, item["date"])

                record = {**item, **result}

                completed[uuid] = record

                output.write(json.dumps(record, ensure_ascii=False) + "\n")
                output.flush()

                print(
                    "  ",
                    result["city"],
                    "|",
                    result["landmark"],
                    "|",
                    result["confidence"],
                    flush=True
                )

            except Exception as error:
                print("  ERROR:", error, flush=True)

    proposals = []

    for item in photos:
        record = completed.get(item["uuid"])
        if not record:
            continue

        lat = coordinate(record.get("latitude"), -90, 90)
        lon = coordinate(record.get("longitude"), -180, 180)

        if (
            record.get("confidence", "").upper() == "HIGH"
            and lat is not None
            and lon is not None
        ):
            proposals.append((record, lat, lon))

    print("\n========== PROPOSED GPS LOCATIONS ==========")
    print("HIGH-CONFIDENCE PROPOSALS:", len(proposals))
    print("UNRESOLVED:", len(photos) - len(proposals))

    for index, (record, lat, lon) in enumerate(proposals, 1):
        print(
            f"{index:04d}. {record['filename']} | "
            f"{record['city']} | {record['landmark']} | "
            f"{lat:.6f}, {lon:.6f}"
        )

    if not proposals:
        print("\nNo high-confidence locations available.")
        return

    print("\nWARNING: AI locations may be incorrect.")
    print("Review the proposed locations before approving.")

    approval = input(
        "\nType YES to apply ALL listed locations, or anything else to cancel: "
    ).strip()

    if approval != "YES":
        print("CANCELLED. No locations changed.")
        return

    print("\n========== APPLYING LOCATIONS ==========")

    updated = 0
    errors = 0

    for index, (record, lat, lon) in enumerate(proposals, 1):
        photo_id = record["uuid"] + "/L0/001"

        try:
            result = run_applescript(
                SET_LOCATION,
                photo_id,
                lat,
                lon
            )

            if result == "UPDATED":
                updated += 1
                print(
                    f"{index}/{len(proposals)} UPDATED: "
                    f"{record['filename']}",
                    flush=True
                )
            else:
                errors += 1
                print("UNEXPECTED RESULT:", result)

        except Exception as error:
            errors += 1
            print("ERROR:", record["filename"], error)

    print("\n========== COMPLETE ==========")
    print("ALBUM:", album)
    print("LOCATIONS UPDATED:", updated)
    print("ERRORS:", errors)
    print("UNRESOLVED:", len(photos) - len(proposals))
    print("PHOTOS DELETED: 0")
    print("ALBUMS DELETED: 0")
    print("DATABASE DIRECTLY MODIFIED: NO")
    print("PACKAGES INSTALLED: NONE")

if __name__ == "__main__":
    main()

Sources

AI Usage Transparency Report

AI Era · Written during widespread use of AI tools

AI Signal Composition

Rep Tone Struct List Instr
Repetition: 65%
Tone: 52%
Structure: 59%
List: 1%
Instructional: 100%
Emoji: 0%

Score: 0.46 · Moderate AI Influence

Summary

Apple's free, local Foundation Model can be used to organize and geotag photos in Photos library.

Related Posts

Running Image Generation Locally on macOS with Draw Things (2026)

Local LLMs have rapidly evolved beyond text and are now capable of producing high-quality images directly on-device. For users running Apple Silicon machines—especially M-series Mac Studios and MacBook Pros—this represents a major shift in what’s possible without relying on cloud services. Just a few years ago, image generation required powerful remote GPUs, subscriptions, and long processing times. Today, thanks to optimized models and Apple’s Metal acceleration, you can generate and edit images locally with impressive speed and quality. The result is a workflow that is faster, private, and entirely under...

Read more

Setting up Ollama on macOS

Recently, after some bad experiences with OpenAI's ChatGPT and CODEX, I decided to look into and learn more about running local AI models. On its face it was intimidating, but I had seen a lot of people in the MacAdmins community posting examples of macOS setups, which really helped lower the bar for me both in terms of approachability and just making me more aware of the local AI community that exists out there today.

Read more

AI Agent Constraints and Security

I really feel like in this era of AI it's essential to write about and share experiences for others who are leveraging AI, especially now that AI usage seems almost ubiquitous. Specifically, when it comes to AI in development and the rapid growth of AI-driven automations in the IT landscape, I believe there's a need for open discussion and exploration.

Read more