summaryrefslogtreecommitdiffstats
path: root/docs/photos.md
blob: ed4a826190de5c03b5fcd2da222f21e0b2e026a9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
# MeshBay — Photos application (design)

> Status: **proposal**, not implemented. Read `docs/apps.md` first — Photos is
> a new group application built on the plug-in mechanism described there.
> Read `docs/mediacenter.md` and `docs/musicbay.md` second: Photos reuses
> their node-side pattern (thumbnails generated and cached by the node,
> delivered through the existing chunk path, `IndexEntry` gains a few more
> additive fields) wherever the same shape applies, and this document states
> only where Photos differs and why.
>
> Follows the project convention: every claim names the adversary it holds
> against (§8).

---

## 0. What was asked, in one paragraph

A group "application" with the same principles as Videos/Music — a view over
the existing file index, no catalogue, enable/disable per group on the same
signed-op mechanism — for a shared photo library: the classic photo-album
elements (album grid, next/previous within a folder, a lightbox), a small
button to download a photo folder as a zip (Files already has this), and
optional per-photo info read from the image's own EXIF data. Three things are
explicitly **not** wanted, and they are what makes Photos smaller than Videos,
not bigger: **several** root folders rather than one, a single album-grid view
rather than a mode toggle with a "flat" fallback, and no third-party service
at all.

---

## 1. What this design does not reopen

Everything Videos/Music already established stands, and this plan fits
inside it:

- **Views over the index, never a catalogue** (`desktop-client-v1.md` §6.10,
  draft-v6 §2.7). A file stays tied to its filesystem representation; an
  album is a directory, exactly as a season is a folder in Videos.
- **The apps plug-in mechanism** (`apps.md`): a new `photos-app.js`, one
  registry entry, one node-side `ALLOWED_APPS` entry, i18n keys, the asset
  list, the two file-set tests. Enablement is a per-group, operator-signed
  setting, same shape as `member_upload`/`apps_enabled`.
- **Group-related server state lives on the node** (E9). Nothing here puts a
  row on the hub.
- **Node-side derived-data caching, never in a shared root** — thumbnails
  live in `data_dir/media_cache.db`, the same file Videos and Music already
  use, never beside the originals.
- **Filesystem portability** (§6.8) — nothing here writes into a shared root.
- **`thumb_hash`/`width`/`height` on `IndexEntry` are already generic**, not
  video-specific despite their current comments (`protocol.py:166`) — Photos
  populates them exactly like Videos does, no new delivery mechanism.

---

## 2. Where Photos differs from Videos/Music, and why

### 2.1 Several roots, not one

Videos and Music each gate on a single `video_root`/`audio_root` — one
folder, because their expensive work (TMDB/MusicBrainz lookups) needed an
explicit, deliberate opt-in and a real media library is usually one tree.
A photo library is routinely scattered: a "Vacances" folder here, a
"Famille" folder there, an old "Scans" folder from a different import,
none of them nested inside a common parent that would make sense to expose
whole. **Photos takes a *set* of root folders**, each independently chosen,
each independently removable.

- New per-group setting: `photo_roots` — a JSON list of root-relative paths,
  stored the same way `enabled_apps` already is (`roster.py`,
  `SETTING_ENABLED_APPS`'s own `json.dumps(sorted(...))` pattern):

  ```python
  SETTING_PHOTO_ROOTS = "photo_roots"

  async def photo_roots(self, group_id: str) -> list[str]:
      value = await self.get_setting(group_id, self.SETTING_PHOTO_ROOTS)
      if value is None:
          return []
      try:
          return list(json.loads(value))
      except (ValueError, TypeError):
          return []

  async def set_photo_roots(self, group_id: str, roots: list[str],
                            set_by: str = "") -> list[str]:
      await self.set_setting(group_id, self.SETTING_PHOTO_ROOTS,
                             json.dumps(sorted(roots)), set_by)
      return roots
  ```

  Empty list means "nothing configured yet" — same "absent means show
  nothing" discipline `underVideoRoot` already established, not "the whole
  index": the node runs no thumbnail/EXIF work for a group before at least
  one root exists either (§2.3's enrichment gate), so falling back to
  everything would show files nothing has enriched.

- New signed op, same shape as `apps_enabled` (a *set*, not a single value,
  signed in one message rather than one op per root — adding three folders
  in Settings costs one signature):

  ```
  photo_roots      { roots: [...] }        # client → node
  photo_roots_ack  { roots: [...] }        # node → every connected peer
  ```

  `OP_PHOTO_ROOTS = "photo_roots"` in `adminop.py`, subject = the sorted,
  comma-joined root list — identical convention to `apps_enabled`'s subject,
  so the operator's browser and the node arrive at identical bytes to
  sign/verify without inventing a second serialization.

- **Validation happens before a signature is ever asked for**, same
  principle as `apps_enabled`'s "empty set refused up front" and
  `video_root`'s path check: every candidate path is resolved against the
  group's actual `RootSet` and must name a real, currently-readable
  directory, or the whole request is refused immediately — one bad path
  in a batch of five never reaches the operator's browser as a signing
  prompt. Unlike `apps_enabled`, an **empty** `roots` list is accepted (it
  is the "nothing configured yet" state, not a lockout — there is no
  Photos-equivalent of "the operator would be locked out of their own
  group" to guard against here).
- Broadcast in `handshake_ack` next to `video_root`/`audio_root`
  (`"photo_roots": list(self._group_ctx().get("photo_roots") or [])`), and
  `photo_roots_ack` to every already-connected peer on change, same as
  `video_root_ack`.
- **No root may be nested inside another already-configured root** —
  same rule §6.7 of `desktop-client-v1.md` already applies to a group's
  *named* roots, applied here one level down to avoid double-listing the
  same directory's images once directly and once as part of a parent.
  Checked case-insensitively (§6.8), at validation time, alongside the
  real-directory check.

### 2.2 UI: an add/remove list, not a single `<select>`

`group-settings.js`'s existing `videoRootDraft`/`saveVideoRoot` pattern
(a depth-indented `<select>` built from `rootFolderOptions`, one path,
confirm-on-change) does not fit a *set*. Photos gets its own small
component: the same `rootFolderOptions` `<select>` to **pick a folder to
add**, plus a list of already-configured roots each with a remove button,
and one **Save** that signs the whole resulting set in one op — mirroring
how `apps_enabled`'s checkbox list stages several changes before one
signature, not `video_root`'s single-value save. No separate "confirm,
this is destructive" dialog is needed the way `video_root`'s change has:
removing one root only drops that root's albums from view, it does not
replace the whole tab's content the way changing `video_root` does.

### 2.3 One view, not two — and no third-party service

Videos and Music each offer a toggle between an enriched view (TMDB/
MusicBrainz-driven grouping) and a plain "flat" fallback, because the
enriched view can fail to resolve a match and the flat view is the
honest fallback for that case. **Photos has no enriched view to fall back
from** — there is no external catalogue for photos, no matching step that
can succeed or fail. So there is exactly **one** mode: a directory-driven
album grid, which is structurally what Videos' own "flat" mode already is.
This is also why the toolbar has no poster/flat toggle and no `localStorage`
view-mode preference — nothing to choose between.

**Concretely, per this document's title, "pas de vue de type Flat files"
means:** no raw sortable file-listing table (that is Files' job, already
available in the same group), and no secondary "ungrouped, alphabetical"
mode alongside a primary one — the album grid *is* the only mode, not one
of two.

**No 3rd-party lookups at all.** Per-photo "info" (§2.4) is read from the
file's own embedded EXIF data, entirely locally, on the node, at index
time — no credential, no `tmdb_enabled`-style per-group toggle, no
`tmdb_api_token`-style node-wide config, no outbound network call to
anything. This is strictly less exposure than Videos/Music (§8): the class
of risk `mediacenter.md` §8 spent a whole table row on ("new outbound
traffic (node → TMDB)") does not exist for Photos.

### 2.4 EXIF: what gets read, and what deliberately does not

`Pillow` (new dependency, `packages/meshbay-node/pyproject.toml`, same
"one purpose-built pip dependency per app" precedent as `guessit`/
`mutagen`) reads two things per image at index time, mirroring the ffprobe
technical-probe / guessit-parse split Videos already has:

- **Technical facts, always read**: pixel `width`/`height` (already generic
  fields on `IndexEntry`, §5.1) and, for the thumbnail itself, the EXIF
  `Orientation` tag — **not exposed to clients**, only used to correct the
  thumbnail's own rotation before it is generated (§6). A phone photo
  stored "sideways" with an orientation tag is an extremely common real
  file, and skipping this produces a library of sideways thumbnails — a
  concrete, testable correctness requirement, not a nice-to-have.
- **A minimal info set, best-effort**: `taken_at` (from `DateTimeOriginal`,
  falling back to nothing rather than guessing — `added_at`, the index
  timestamp, is already shown elsewhere and is not a substitute) and
  `camera` (`Make` + `Model`, joined, when both are present). That is the
  entire new wire surface (§5.2) — deliberately narrow, matching the
  "éventuellement" (optional, best-effort) framing of the ask rather than
  building a full EXIF-viewer panel (dozens of fields: exposure, ISO, focal
  length, lens, GPS) that nobody asked for. A fuller info panel is listed as
  an open item (§10), not built here.

**GPS is read by nobody, on purpose.** `GPSInfo` is present in the EXIF of a
photo taken on most phones and is a location disclosure the instant it is
surfaced — to every group member, from a thumbnail's own metadata, with no
extra step. Nothing in this design extracts it, caches it, or wires it onto
`IndexEntry` or any response. This does not change what a member who
downloads the *original* file can already extract themselves — the file's
own bytes are unchanged, exactly as they are for Files today — but the
Photos application itself never becomes a channel that makes that data
casually visible to everyone browsing an album. Named per the project
convention (§8): this holds against another member and a passive/active
hub identically (the node never computes or transmits the field), and it is
a policy choice about what the *view* shows, not a claim that GPS data does
not exist in the file.

### 2.5 The album view: every directory that contains an image

The ask is explicit: a view of every directory containing images under the
configured roots, not a folder tree to click through level by level. This
needs **no new wire data** — it is a client-side derivation over the
existing per-file index, the same kind of work `underVideoRoot`/
`groupVideoEntries` already do:

```js
function underAnyPhotoRoot(entry, photoRoots) {
  const p = entry.path || '';
  return photoRoots.some(r => p === r || p.startsWith(r + '/'));
}

function groupPhotoAlbums(entries, photoRoots) {
  const byDir = new Map();
  for (const e of entries) {
    if (e.type !== 'image' || !underAnyPhotoRoot(e, photoRoots)) continue;
    const dir = e.path.includes('/') ? e.path.slice(0, e.path.lastIndexOf('/')) : '';
    if (!byDir.has(dir)) byDir.set(dir, []);
    byDir.get(dir).push(e);
  }
  return [...byDir.entries()]
    .map(([dir, photos]) => ({ dir, photos: photos.sort(/* name */) }))
    .sort((a, b) => a.dir.localeCompare(b.dir));
}
```

Every directory that has at least one image becomes one album card — a
subfolder of a subfolder qualifies independently, exactly as a season
folder is its own category under Videos. No recursion is needed to build
the *list* of albums; recursion is not merged away, it just means a deeply
nested library produces more, smaller album cards rather than fewer, larger
ones, which matches "directory is the category" already established for
Videos/Music.

**Landing page**: a flat, alphabetically sorted grid of album cards (dir
name, photo count, a thumbnail from the first — or, better, a stable
"first with a thumbnail already ready" — photo, same fallback Videos'
`PosterGrid` already uses when picking a representative episode). Clicking
a card opens that one directory's own photo grid.

---

## 3. Album grid and lightbox — the classic elements asked for

- **Grid**: one thumbnail tile per photo in the open album, same
  `LazyTile`/`MediaThumb` virtualization Videos already built and exported
  for reuse (`video-app.js`'s trailing `export { VideoApp, MediaThumb,
  LazyTile }`, already consumed by `music-app.js` the same way) — Photos
  imports both rather than reimplementing them, per `apps.md`'s checklist.
- **Lightbox**: clicking a tile opens a full-size view. Unlike a grid tile
  (which shows the cached, resized thumbnail), the lightbox fetches the
  **original file** through the existing chunk path — the same mechanism
  `FilePreview`/`ChatImage` already use for an image attachment — cached
  per session by file id, same pattern as `_thumbBlobCache`.
- **Next/previous**: cycles through the currently open album's photo list
  (the same array the grid rendered from — no server round trip to know
  what's next), bound to on-screen buttons and the left/right arrow keys.
  Wraps or stops at the ends (a UI choice, not architectural — pick
  whichever `video-player.js`'s own seek controls already read as
  idiomatic for this codebase).
- **Per-photo info**: filename, dimensions (`width`×`height`), file size,
  `taken_at` and `camera` when present (§2.4) — shown in the lightbox,
  never on the grid tile itself (a grid of a hundred thumbnails does not
  need a hundred date stamps competing with the image).
- **Zip an album**: a small toolbar button in the open album view, next to
  the mode-less toolbar (filter only — there is no mode toggle, §2.3).
  **Reuses Files' existing mechanism rather than reimplementing it.**
  `files-app.js`'s `downloadDirectory` (`entriesUnder`/`ZipStream`/
  `_openDownloadTarget`/`transfers`, `files-app.js:95-172`) is
  directory-path-in, streamed-zip-out and has nothing Files-specific in it
  once `entries`/`transportRef`/`gekRef`/`setError` are already props any
  app receives (`apps.md` §2). **Refactor**: lift `downloadDirectory` out of
  `files-app.js` into `file-utils.js` (already the shared home for the
  download/decrypt pipeline it's built on — `pipelinedDownload`,
  `_openDownloadTarget`, `_saveBlob`, `CHUNK_SIZE`) as an exported function
  taking `(transport, gek, entries, dir, { setError })`; `files-app.js`'s
  own toolbar action becomes a one-line caller, and `photos-app.js` calls
  the same function for the open album's `dir`. One implementation, two
  call sites — not a second zip writer.

---

## 4. Protocol and index changes

### 4.1 Reused, not new

`thumb_hash`, `width`, `height` (`protocol.py:166`) — already declared,
already wired through `index_entry_wire`, already delivered via the chunk
path (`_try_serve_thumbnail`). Photos populates them for `type == "image"`
entries exactly as Videos populates them for `type == "video"`. Their
comments ("video only") should be updated to reflect that they are
media-type-generic once this lands — a one-line doc fix, not a protocol
change.

### 4.2 New: two small fields

```python
taken_at:  int | None = None   # unix timestamp, EXIF DateTimeOriginal — Photos app
camera:    str | None = None   # "Make Model", when both present — Photos app
```

Additive fields on the same dataclass, added to `index_entry_wire`'s dict —
MNP **MINOR** bump (whatever the current version is by the time this is
built), same class of change as Videos' `display_title`/`season`/`episode`
addition. An older client simply doesn't render them.

### 4.3 New wire messages: only for the root-set change

```
photo_roots      { roots: [...] }   # client → node, admin-challenged (§2.1)
photo_roots_ack  { roots: [...] }   # node → every connected peer of the group
```

No `photo_meta_req`/`resp` pair (contrast Videos' `media_meta_req`, needed
because a TMDB call is a network round trip worth deferring per-tile).
`taken_at`/`camera` are cheap, local, and already computed once at index
time, so they ride the ordinary index the same way `duration` does — no
per-tile fetch, no virtualization concern for the *metadata* (only the
thumbnail image bytes themselves are fetched lazily, same as any
`thumb_hash`).

### 4.4 `ALLOWED_APPS` / `DEFAULT_APPS`

`webrtc_server.py`'s `ALLOWED_APPS` frozenset gains `"photo"`.
`roster.py`'s `DEFAULT_APPS` stays `("chat", "files")` — a brand-new group
does not get Photos for free, same reasoning as Videos/Music: it is new
per-file node CPU cost (thumbnail generation, EXIF parse) across whatever
the operator eventually points it at, and it shows nothing useful until at
least one root is chosen anyway (§2.1), so there is nothing lost by making
it an explicit opt-in via the existing Settings checklist.

---

## 5. Node-side implementation, concretely

| Piece | Where | What |
|---|---|---|
| Thumbnail + EXIF enrichment | new `meshbay_node/indexer/enrich_photo.py`, `PhotoEnricher` class | Own bounded pool (`asyncio.Semaphore`, own small `max_concurrent`, own short timeout) — sibling to `enrich.py`'s Videos pool and `enrich_audio.py`'s Music pool, **never shared with either**, same "never touches `max_concurrent_streams`" rule §6.10/§5.2 of the other two docs already establish. Pillow-based: `ImageOps.exif_transpose` before resizing (orientation correction, §2.4), resize to a bounded long edge (e.g. 480px, matching the size class Videos' own thumbnails already use), re-encode JPEG, extract `DateTimeOriginal`/`Make`/`Model` via `Image.getexif()` |
| Enrichment gate | `meshbay_node/daemon.py`, new `_enrich_new_photo_entries`/`_enrich_photo_roots_now`, mirroring `_enrich_new_video_entries`/`_enrich_video_root_now` exactly, but checking membership against a **list** of roots (`_under_any_root(entry.path, photo_roots)`) rather than one string | Fires for `type == "image"` entries under any configured `photo_roots`; a sweep re-runs whenever the root *set* changes (add or remove), from `ops.set_photo_roots` |
| Caches | `data_dir/media_cache.db`, existing `thumbs` table (`(thumb_hash) → jpeg bytes`, keyed by the file's own id — no synthetic id needed, a photo's thumbnail belongs to exactly one file, unlike a TMDB poster shared by many episodes) | No schema change |
| Cache lifecycle | same pruning hook Videos/Music already use on an `IndexEntry` leaving the index | No new code path, same event |
| Operator config | `roster.py` `group_settings`, real `group_id` (not the `""` sentinel — `photo_roots` is per-group, like `video_root`/`audio_root`, unlike the node-wide TMDB credential) | `SETTING_PHOTO_ROOTS`, `photo_roots()`/`set_photo_roots()`; `ops.py` gains `set_photo_roots(state, group_id, roots)`, one `_op(...)` line, same loopback/CLI/MNP adapters as everything else in `ops.py` |
| Admin op | `meshbay_common/adminop.py` | `OP_PHOTO_ROOTS = "photo_roots"` |
| Wire types | `meshbay_common/protocol.py` (`MNP.*`) | `PHOTO_ROOTS`, `PHOTO_ROOTS_ACK` |
| Handler | `meshbay_node/transport/webrtc_server.py` | `_do_photo_roots`/`_admin_exec_photo_roots`, mirroring `_do_video_root`/`_admin_exec_video_root`, validating a list; `handshake_ack` gains `"photo_roots": list(self._group_ctx().get("photo_roots") or [])` |
| `pyproject.toml` | `packages/meshbay-node/pyproject.toml` | add `Pillow>=10` |

---

## 6. Client-side, per `apps.md`'s checklist

1. `photos-app.js` — receives the standard props (`apps.md` §2), plus
   `photoRoots` (threaded through `group-page.js` exactly like `videoRoot`/
   `audioRoot`: `useState`, reset on `groupId` change, passed down, updated
   from `photo_roots_ack`). Imports `MediaThumb`/`LazyTile` from
   `video-app.js` and the lifted `downloadDirectory` from `file-utils.js`
   (§3) — no reimplementation of either.
2. Register `{ key: "photo", icon: "image", labelKey: "group.tab_photos",
   Component: PhotosApp }` in `apps.js`.
3. `ALLOWED_APPS` (§4.4).
4. `group.tab_photos` (and a handful of `photo.*` strings — lightbox
   labels, "no roots configured yet", the zip button's tooltip) in all ten
   `static/locales/*.js`. `test_locales.py` holds them to the same key set.
5. `webapp.py`'s `_ASSETS` tuple — add `photos-app.js`.
6. `test_hook_ordering.py` (`STATIC_FILES`) and `test_transport_contracts.py`
   (`SPLIT_FILES`) — add the new file to both.
7. `group-settings.js` — the add/remove root-list component (§2.2), wired
   the same way the Videos/Music root pickers already are (`transport.
   setPhotoRoots(roots, signFn)`, a new `transportRef` method mirroring
   `setVideoRoot`/`setAudioRoot`).
8. `npm run sync-ui` in `meshbay-client`.

No hub change. Protocol change is limited to §4.2's two additive fields and
§4.3's one message pair — smaller than either Videos or Music, consistent
with Photos doing less (no metadata-matching round trip, no mode toggle).

---

## 7. What Photos deliberately does not do

- **No TMDB/MusicBrainz-equivalent matching service.** There is nothing to
  match a photo *to* — it already is what it is, per its own folder and
  filename. §2.3.
- **No mode toggle, no `localStorage` view preference.** One album-grid
  view. §2.3.
- **No GPS surfaced anywhere in the application.** §2.4.
- **No recursive "album of albums" browsing UI beyond the flat landing
  list.** Every qualifying directory is one card; there is no folder-tree
  affordance to build or maintain. §2.5.
- **No RAW / HEIC support in v1.** The node's indexer today classifies
  `.jpg/.jpeg/.png/.gif/.webp/.svg/.bmp/.tiff` as `image`
  (`indexer.py:58`) — Pillow reads all of those natively. HEIC (the default
  format on recent iPhones) needs an extra native dependency
  (`pillow-heif`) not currently in the tree; RAW formats need a different
  library family entirely (`rawpy`/LibRaw). Both are real gaps for a
  photo-focused audience and are listed as open items (§10), not silently
  assumed away.
- **No thumbnail preloading of the next/previous lightbox image.** A
  nice-to-have for a snappier feel on a slow connection; not required for
  a working v1. §10.

---

## 8. Security — per adversary

| Claim | Passive hub | Active hub | Malicious node operator | Another member |
|---|---|---|---|---|
| Thumbnail/original delivery | ✅ unchanged transport | ✅ unchanged transport | sees it already (holds the plaintext file) | same GEK-proofed MNP channel as files/streaming — no new authorization surface |
| EXIF extraction | — | — | already has the plaintext file, could read EXIF manually — no new exposure | reads only what the node chooses to surface (`taken_at`/`camera`), never GPS (§2.4) — a strictly narrower surface than downloading the original, which any member with file access could already do |
| New outbound traffic | **none** — Photos makes zero third-party network calls, unlike Videos/Music | **none** | — | — |
| Stale cache after file deletion | — | — | pruned on the same index-deletion event Videos/Music already use — no new gap to introduce | — |
| Root-set change (`photo_roots`) | ✅ signed, admin-challenged | ✅ signed, admin-challenged | the operator's own instruction | cannot forge — same `_verify_admin_sig` path as `apps_enabled`/`video_root` |

**The claim this design supports:** Photos adds no new authorization
boundary and, unlike Videos/Music, no new *category* of exposure either —
there is no credential to hold, no third party to leak metadata to, and the
one genuinely new piece of client-visible data (EXIF) is deliberately
narrowed to exclude the one field (GPS) that would matter.

**The claim it must not make:** that GPS "isn't in the file" — it is, for
most phone photos, in the original bytes any member with file access can
already download. What this design controls is only what the *Photos
application itself* computes and surfaces, not what the underlying file
contains.

---

## 9. Filesystem/Windows

Nothing new beyond what `desktop-client-v1.md` §6.8/§7.5 already
establishes. Pillow ships with its own codecs for every format §7's table
lists and needs no external `ffmpeg`-style binary the way Videos' thumbnail
path does — if anything, Photos has *fewer* platform-dependent moving parts
than Videos, not more.

---

## 10. Open items

| # | Item | Why it is not decided here |
|---|---|---|
| P1 | HEIC/RAW support | Needs a real dependency decision (`pillow-heif`, `rawpy`/LibRaw) and a licensing/build check, not just a config constant |
| P2 | A fuller EXIF info panel (exposure, ISO, focal length, lens) beyond `taken_at`/`camera` | Product/UX call — the ask said "éventuellement", and the minimal set already answers it; extending is cheap once the plumbing (§4.2's pattern) exists |
| P3 | Lightbox next/previous image preloading | Perf nicety, not required for a working v1 |
| P4 | Wrap-around vs. stop-at-ends for next/previous at album boundaries | UI choice, mirror whatever `video-player.js`'s own controls already do for consistency |
| P5 | Album cover selection (always "first photo" vs. an operator/member choice) | Product call; "first photo, stable" is a reasonable, zero-config default and is what this document assumes |

## 11. Acceptance before shipping

1. Orientation correction verified against a real EXIF-rotated phone photo
   (§2.4) — a thumbnail generated from a "sideways" source file renders
   upright. Covered by `test_photo_enrichment.py`.
2. Cache pruning on file deletion actually fires for photo thumbnails, same
   acceptance step `mediacenter.md` §11 already required for video
   thumbnails — not just argued, covered by a test.
3. `taken_at`/`camera` come back empty (not an error) for a file with no
   EXIF block at all (a screenshot, a scanned/edited image with metadata
   stripped) — the ordinary case for a lot of real libraries, must degrade
   the same way "no TMDB match" already does for Videos.
4. GPS fields are confirmed absent from every response/index field a client
   ever receives — not just "not intentionally added" (§2.4's claim), a
   grep-based test over `index_entry_wire` and any new response shape, the
   same discipline `test_hub_address_seam.py`/`test_task_lifetime.py`
   already apply elsewhere in this codebase to a property that must never
   silently regress.
5. Live smoke test against a real, messy photo library (scattered roots,
   nested subfolders, a mix of phone photos with orientation tags and old
   scans with none) before calling this done — per this project's own
   repeated lesson (`CLAUDE.md`) that a source-reading test is weak
   evidence and launching the real thing finds what it cannot.