summaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/apps.md11
-rw-r--r--docs/photos.md481
2 files changed, 488 insertions, 4 deletions
diff --git a/docs/apps.md b/docs/apps.md
index a68a6e6..1cd6339 100644
--- a/docs/apps.md
+++ b/docs/apps.md
@@ -190,11 +190,14 @@ the only node-side touches, and both are allow-lists, not new wire messages.
app can reuse the same node-side machinery (thumbnail cache, chunk-path
delivery) without re-deciding any of this.
- **Videos, Music, Photos themselves.** Videos is now built (`video-app.js`,
- `docs/mediacenter.md`). Music is **planned, not built** — see
- `docs/musicbay.md` — and reuses Videos' node-side thumbnail/chunk-delivery
+ `docs/mediacenter.md`). Music is now built (`music-app.js`,
+ `docs/musicbay.md`) and reuses Videos' node-side thumbnail/chunk-delivery
machinery, with no new streaming path (a track is small enough to
- download-then-play, unlike a film). Photos remains deliberately out of
- scope — see `meshbay-draft-v6.md` §2.7.
+ download-then-play, unlike a film). Photos is **designed, not built** —
+ see `docs/photos.md` — and reuses the same `thumb_hash`/chunk-delivery
+ machinery again; unlike Videos/Music it needs several root folders per
+ group rather than one, has a single album-grid view with no third-party
+ matching step, and reads EXIF locally on the node instead.
- **The offline/loopback settings path.** `member_upload` can be toggled two
ways: over a live MNP connection, or (Electron only) via the node's local
HTTP API when MNP isn't connected (`platform.node.call('PUT', .../member-
diff --git a/docs/photos.md b/docs/photos.md
new file mode 100644
index 0000000..ed4a826
--- /dev/null
+++ b/docs/photos.md
@@ -0,0 +1,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.