From 2fcdd07d1e5d331ad02b723f1c45603a0989c264 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 25 Aug 2026 11:46:17 +0200 Subject: feat: add Photos group app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TiZG4AuSnxHohQMpwTHTyL --- docs/photos.md | 481 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 docs/photos.md (limited to 'docs/photos.md') 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 `` built from `rootFolderOptions`, one path, +confirm-on-change) does not fit a *set*. Photos gets its own small +component: the same `rootFolderOptions` `