@thenavidm/apple-photos-mcp-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +485 -0
- package/SKILL.md +149 -0
- package/package.json +35 -0
- package/pyproject.toml +79 -0
- package/src/apple_photos_mcp/__init__.py +3 -0
- package/src/apple_photos_mcp/__main__.py +40 -0
- package/src/apple_photos_mcp/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/__main__.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/config.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/doctor.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/library.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/previews.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/safety.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/search.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/server.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/__pycache__/writes.cpython-313.pyc +0 -0
- package/src/apple_photos_mcp/config.py +82 -0
- package/src/apple_photos_mcp/doctor.py +162 -0
- package/src/apple_photos_mcp/library.py +385 -0
- package/src/apple_photos_mcp/previews.py +140 -0
- package/src/apple_photos_mcp/safety.py +72 -0
- package/src/apple_photos_mcp/search.py +283 -0
- package/src/apple_photos_mcp/server.py +352 -0
- package/src/apple_photos_mcp/writes.py +225 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Changing the library.
|
|
2
|
+
|
|
3
|
+
Reading uses SQLite directly. Writing cannot: Photos owns the database while it
|
|
4
|
+
is running, and editing it underneath the app corrupts state. Every write here
|
|
5
|
+
therefore goes through Photos.app itself via ``photoscript`` (AppleScript), which
|
|
6
|
+
is the only supported way to change a library.
|
|
7
|
+
|
|
8
|
+
Three rules hold everywhere in this module:
|
|
9
|
+
|
|
10
|
+
* **Nothing is ever deleted.** Apple does not expose scripted permanent deletion
|
|
11
|
+
to any app, and this server does not try to work around that. "Delete" moves
|
|
12
|
+
items into an archive album for a human to empty.
|
|
13
|
+
* **Batches are bounded and explicit.** No wildcards, no "everything matching".
|
|
14
|
+
A caller names the items it means, up to a configured maximum.
|
|
15
|
+
* **Writes work by default.** Organizing a library is the point of the tool.
|
|
16
|
+
`APPLE_PHOTOS_READ_ONLY=1` removes the write tools from the list entirely.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .config import Config
|
|
26
|
+
from .library import Asset, PhotosLibrary
|
|
27
|
+
from .safety import AuditLog
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WritesDisabled(RuntimeError):
|
|
31
|
+
"""Raised if a write is somehow reached while read-only mode is on.
|
|
32
|
+
|
|
33
|
+
In practice the tools are never registered in that mode, so this is a
|
|
34
|
+
backstop rather than the user-facing behavior.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
super().__init__(
|
|
39
|
+
"This server is running in read-only mode because APPLE_PHOTOS_READ_ONLY "
|
|
40
|
+
"is set. Remove it and restart the client to allow changes."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TooManyItems(ValueError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class WriteResult:
|
|
50
|
+
changed: list[str]
|
|
51
|
+
skipped: list[dict[str, str]]
|
|
52
|
+
|
|
53
|
+
def as_dict(self, action: str, **extra: Any) -> dict[str, Any]:
|
|
54
|
+
out: dict[str, Any] = {
|
|
55
|
+
"action": action,
|
|
56
|
+
"changed": len(self.changed),
|
|
57
|
+
"uuids": self.changed,
|
|
58
|
+
}
|
|
59
|
+
if self.skipped:
|
|
60
|
+
out["skipped"] = self.skipped
|
|
61
|
+
out.update(extra)
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Writer:
|
|
66
|
+
def __init__(self, config: Config, lib: PhotosLibrary):
|
|
67
|
+
self.config = config
|
|
68
|
+
self.lib = lib
|
|
69
|
+
self.audit = AuditLog(config)
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------- guardrails
|
|
72
|
+
|
|
73
|
+
def _guard(self, refs: list[str]) -> list[Asset]:
|
|
74
|
+
if self.config.read_only:
|
|
75
|
+
self.audit.record(
|
|
76
|
+
"blocked", allowed=False, summary=f"{len(refs)} item(s), read-only mode"
|
|
77
|
+
)
|
|
78
|
+
raise WritesDisabled()
|
|
79
|
+
if len(refs) > self.config.write_batch_max:
|
|
80
|
+
raise TooManyItems(
|
|
81
|
+
f"{len(refs)} items requested; this server allows at most "
|
|
82
|
+
f"{self.config.write_batch_max} per call. Split the work into batches."
|
|
83
|
+
)
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
def _resolve(self, refs: list[str]) -> tuple[list[Asset], list[dict[str, str]]]:
|
|
87
|
+
found: list[Asset] = []
|
|
88
|
+
missing: list[dict[str, str]] = []
|
|
89
|
+
for ref in refs:
|
|
90
|
+
asset = self.lib.get(ref)
|
|
91
|
+
if asset:
|
|
92
|
+
found.append(asset)
|
|
93
|
+
else:
|
|
94
|
+
missing.append({"ref": ref, "reason": "not found in this library"})
|
|
95
|
+
return found, missing
|
|
96
|
+
|
|
97
|
+
def _photoslib(self):
|
|
98
|
+
import photoscript
|
|
99
|
+
|
|
100
|
+
return photoscript.PhotosLibrary()
|
|
101
|
+
|
|
102
|
+
def _apply(
|
|
103
|
+
self,
|
|
104
|
+
refs: list[str],
|
|
105
|
+
action: Callable[[Any, Asset], None],
|
|
106
|
+
) -> WriteResult:
|
|
107
|
+
self._guard(refs)
|
|
108
|
+
assets, skipped = self._resolve(refs)
|
|
109
|
+
if not assets:
|
|
110
|
+
return WriteResult([], skipped)
|
|
111
|
+
|
|
112
|
+
import photoscript
|
|
113
|
+
|
|
114
|
+
# Instantiating the library launches Photos.app if it is not running,
|
|
115
|
+
# which every AppleScript write below depends on. The handle itself is
|
|
116
|
+
# not needed: writes address photos by uuid.
|
|
117
|
+
self._photoslib()
|
|
118
|
+
changed: list[str] = []
|
|
119
|
+
for asset in assets:
|
|
120
|
+
try:
|
|
121
|
+
photo = photoscript.Photo(asset.uuid)
|
|
122
|
+
action(photo, asset)
|
|
123
|
+
changed.append(asset.uuid)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
skipped.append({"ref": asset.uuid, "reason": str(exc)})
|
|
126
|
+
if changed:
|
|
127
|
+
# The cached index is now stale for these assets.
|
|
128
|
+
self.lib.load(force=True)
|
|
129
|
+
return WriteResult(changed, skipped)
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------ tools
|
|
132
|
+
|
|
133
|
+
def set_favorite(self, refs: list[str], favorite: bool = True) -> dict[str, Any]:
|
|
134
|
+
res = self._apply(refs, lambda photo, _a: setattr(photo, "favorite", favorite))
|
|
135
|
+
action = "favorite" if favorite else "unfavorite"
|
|
136
|
+
self.audit.record(action, allowed=True, summary=f"{len(res.changed)} item(s)")
|
|
137
|
+
return res.as_dict(action, favorite=favorite)
|
|
138
|
+
|
|
139
|
+
def set_title(self, ref: str, title: str) -> dict[str, Any]:
|
|
140
|
+
res = self._apply([ref], lambda photo, _a: setattr(photo, "title", title))
|
|
141
|
+
return res.as_dict("set_title", title=title)
|
|
142
|
+
|
|
143
|
+
def set_description(self, ref: str, description: str) -> dict[str, Any]:
|
|
144
|
+
res = self._apply([ref], lambda photo, _a: setattr(photo, "description", description))
|
|
145
|
+
return res.as_dict("set_description", description=description)
|
|
146
|
+
|
|
147
|
+
def add_keywords(self, refs: list[str], keywords: list[str]) -> dict[str, Any]:
|
|
148
|
+
def action(photo: Any, _a: Asset) -> None:
|
|
149
|
+
existing = list(photo.keywords or [])
|
|
150
|
+
photo.keywords = existing + [k for k in keywords if k not in existing]
|
|
151
|
+
|
|
152
|
+
res = self._apply(refs, action)
|
|
153
|
+
return res.as_dict("add_keywords", keywords=keywords)
|
|
154
|
+
|
|
155
|
+
def add_to_album(self, album: str, refs: list[str]) -> dict[str, Any]:
|
|
156
|
+
self._guard(refs)
|
|
157
|
+
|
|
158
|
+
# Resolve before touching Photos.app. Launching Photos is a visible,
|
|
159
|
+
# slow side effect, and doing it for a call that turns out to have
|
|
160
|
+
# nothing to add is both surprising and a good way to make the test
|
|
161
|
+
# suite depend on a running Photos.
|
|
162
|
+
assets, skipped = self._resolve(refs)
|
|
163
|
+
if not assets and refs:
|
|
164
|
+
return WriteResult([], skipped).as_dict("add_to_album", album=album)
|
|
165
|
+
|
|
166
|
+
import photoscript
|
|
167
|
+
|
|
168
|
+
pl = self._photoslib()
|
|
169
|
+
target = None
|
|
170
|
+
for existing in pl.albums():
|
|
171
|
+
if existing.name == album:
|
|
172
|
+
target = existing
|
|
173
|
+
break
|
|
174
|
+
created = target is None
|
|
175
|
+
if target is None:
|
|
176
|
+
target = pl.create_album(album)
|
|
177
|
+
|
|
178
|
+
changed: list[str] = []
|
|
179
|
+
for asset in assets:
|
|
180
|
+
try:
|
|
181
|
+
target.add([photoscript.Photo(asset.uuid)])
|
|
182
|
+
changed.append(asset.uuid)
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
skipped.append({"ref": asset.uuid, "reason": str(exc)})
|
|
185
|
+
if changed:
|
|
186
|
+
self.lib.load(force=True)
|
|
187
|
+
return WriteResult(changed, skipped).as_dict(
|
|
188
|
+
"add_to_album", album=album, album_created=created
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def archive(self, refs: list[str], confirm: bool = False) -> dict[str, Any]:
|
|
192
|
+
"""The closest thing to delete that macOS permits a script to do.
|
|
193
|
+
|
|
194
|
+
This is the only tool here that asks. Everything else it can do is one
|
|
195
|
+
click to undo in Photos, and confirming every write teaches a model to
|
|
196
|
+
pass `confirm` reflexively, which is exactly what must not happen on the
|
|
197
|
+
one action a user reads as deletion.
|
|
198
|
+
"""
|
|
199
|
+
if not confirm:
|
|
200
|
+
self.audit.record(
|
|
201
|
+
"archive", allowed=False, summary=f"{len(refs)} item(s), unconfirmed"
|
|
202
|
+
)
|
|
203
|
+
return {
|
|
204
|
+
"action": "archive",
|
|
205
|
+
"confirmed": False,
|
|
206
|
+
"would_archive": len(refs),
|
|
207
|
+
"album": self.config.archive_album,
|
|
208
|
+
"message": (
|
|
209
|
+
f"Not done yet. This moves {len(refs)} item(s) out of the main view and "
|
|
210
|
+
f"into the album \"{self.config.archive_album}\". Call again with "
|
|
211
|
+
f"confirm=true to proceed."
|
|
212
|
+
),
|
|
213
|
+
}
|
|
214
|
+
out = self.add_to_album(self.config.archive_album, refs)
|
|
215
|
+
out["action"] = "archive"
|
|
216
|
+
out["confirmed"] = True
|
|
217
|
+
self.audit.record(
|
|
218
|
+
"archive", allowed=True, summary=f"{out.get('changed', 0)} item(s) archived"
|
|
219
|
+
)
|
|
220
|
+
out["note"] = (
|
|
221
|
+
f"macOS does not allow any app to delete photos by script. These were moved "
|
|
222
|
+
f"into the album \"{self.config.archive_album}\" instead. To remove them for "
|
|
223
|
+
f"real, open that album in Photos and delete them there."
|
|
224
|
+
)
|
|
225
|
+
return out
|