diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-25 11:46:17 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-25 11:46:17 +0200 |
| commit | 2fcdd07d1e5d331ad02b723f1c45603a0989c264 (patch) | |
| tree | f606f01f5492648824876efe4c8a431d9b3a59d6 /docs/photos.md | |
| parent | d427118bd91d67f1a041e5daf267aebcd34ca9d7 (diff) | |
| download | meshbay-2fcdd07d1e5d331ad02b723f1c45603a0989c264.tar.gz | |
feat: add Photos group app
A new group application (docs/apps.md's plug-in mechanism), following the
plan in docs/photos.md. Unlike Videos/Music: several photo roots per group
instead of one (photo_roots is a set, one signed op replaces it whole),
a single album-grid view with no third-party matching step, and per-photo
info read from the file's own EXIF at index time — no metadata service,
no credential, no outbound network call at all.
Protocol (meshbay-common, MNP 0.10 -> 0.11, additive): `taken_at`/`camera`
on IndexEntry; `photo_roots`/`photo_roots_ack`; `OP_PHOTO_ROOTS`.
Node: roster.py stores photo_roots as a group_settings entry (JSON list,
same shape as enabled_apps); ops.py/webrtc_server.py validate and sign the
whole set in one op, same pattern as apps_enabled; a new PhotoEnricher
(indexer/enrich_photo.py) runs Pillow in its own small bounded pool,
separate from the video/audio pools, producing a resized thumbnail plus
the two EXIF fields — never GPS, checked by a grep-based regression test.
Client: photos-app.js — one album card per directory containing images,
a per-album photo grid, and a lightbox with next/previous (keyboard and
buttons), zoom in/out/fit/100% starting from the actual on-screen fit
percentage, and a "zip this album" button reusing files-app.js's own zip
mechanism (lifted into file-utils.js's downloadDirectory so both call the
same implementation). group-settings.js gets an add/remove multi-root
picker, distinct from Videos/Music's single-value one.
Bugs found and fixed before this ever shipped, worth keeping the story of:
- enrich_photo.py read width/height from the raw image *before* applying
EXIF orientation correction, and read DateTimeOriginal off the plain
0th-IFD Exif object — a real camera stores it in the Exif sub-IFD, which
Pillow only exposes via get_ifd(Exif). A flat, hand-built EXIF dict
round-trips through Pillow either way, which is exactly what would have
hidden both bugs; the regression test builds EXIF with piexif instead,
matching what real hardware produces.
- photos-app.js's album grouping stripped a trailing path segment from
entry.path under the assumption it still carried a filename — it
doesn't (files-app.js's own convention: e.path is already the
containing directory), so every album collapsed one level into its
parent. Found live against a real multi-folder library.
- transport.js's ADMIN_OP_TYPES allowlist (already the fix for an
identical bug on video_root/apps_enabled, see 4783d81) was missing
photo_roots: its admin_challenge matched no pending request and was
silently dropped, so saving a photo root just timed out after 30s with
no error.
- daemon.py pruned a thumbnail when its file left the index (root removed
or reconfigured) but never forgot the content hash was "already
attempted" — the same bytes reappearing under a renamed/relocated root
(an operator's real workflow) were then permanently skipped, forever,
with nothing to indicate why. Discarding the attempt alongside the
cache entry on prune is what makes pruning actually reversible.
- packages/meshbay-client's app:// protocol handler served every file
with no Cache-Control header, so Chromium was free to serve a stale
cached copy indefinitely — none of several `npm run sync-ui` + reload
cycles during development actually picked up the new code until the
renderer's disk cache was cleared by hand. Now sends Cache-Control:
no-store.
- the lightbox's zoomed image used flex centering (align-items/
justify-content: center) combined with overflow: auto — a well-known
trap where the browser centers overflowing content by shifting it, and
the leading half of that overflow (here, the top of a zoomed photo)
sits outside what the scrollport can actually reach. Reported live as
"unusable". Fixed by switching to top/left alignment once zoomed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiZG4AuSnxHohQMpwTHTyL
Diffstat (limited to 'docs/photos.md')
| -rw-r--r-- | docs/photos.md | 481 |
1 files changed, 481 insertions, 0 deletions
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. |