diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-11 00:19:06 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-11 00:19:06 +0200 |
| commit | f059cb118c556d1f0279350507f74b8a47d5a98a (patch) | |
| tree | 9a97762a844038a06134b4b7dcead1758477dfc1 /docs/photos.md | |
| parent | b045ba0010d69360b6a0265eb7c73a07900fe328 (diff) | |
| download | meshbay-f059cb118c556d1f0279350507f74b8a47d5a98a.tar.gz | |
docs: remove the documents MESHBAY_DESIGN.md replaces
Twenty-four files, about 17 000 lines: the two architecture drafts, the three
security reviews, eleven design notes, the roadmap, the decisions file, the v1–v4
archive, the deprecated user guide and the stale quickstart. Their content is in
MESHBAY_DESIGN.md, and git history holds the originals.
The reason to delete rather than keep bannered: a document that is superseded but
present still gets read, and a reader cannot always tell which of two accounts of
one mechanism is the live one. That was the argument for retiring the user guide
rather than repairing it, and it applies to the whole set.
What made this safe is the concordance. Roughly 290 comments and docstrings cite
these files by section — `musicbay.md §6`, `mediacenter.md §5.5`, `draft-v6 §2.11`
— and section 16 maps every one onto its replacement, so not a single comment
needs editing to stay followable. It now says plainly that the files are gone and
where to recover them, and it gained rows for the three reviews (their findings
are section 13), and for the two guides.
Four kept documents pointed into the set and were repointed first: `playlists.md`
(nine references — it is a live proposal and must not dangle), `WINDOWS-PORT.md`,
and CLAUDE.md's example. No dangling reference remains outside section 16.
Two files were dropped from the list after checking what they hold. `HTTPS.md` is
an operational runbook — Caddy, certificate renewal, DNS, troubleshooting — and
MESHBAY_DESIGN.md deliberately covers no operations, so nothing would replace it;
the versioned Caddyfile is the config, not the procedure. `cast-smart-tv.md` is
the plan for the unbuilt DLNA phase of a feature whose first two phases ship, and
section 11.4 summarises it in four lines rather than carrying the SSDP/UPnP work.
There is no user guide now, and section 0.1 says so rather than leaving a reader
to discover it.
Suites green: 2258 passed, 4 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
Diffstat (limited to 'docs/photos.md')
| -rw-r--r-- | docs/photos.md | 516 |
1 files changed, 0 insertions, 516 deletions
diff --git a/docs/photos.md b/docs/photos.md deleted file mode 100644 index b5612ec..0000000 --- a/docs/photos.md +++ /dev/null @@ -1,516 +0,0 @@ -# MeshBay — Photos application (design) - -> **Superseded by `MESHBAY_DESIGN.md`.** This was the Photos application design; its design -> content now lives in §9.9. -> -> It is kept because code comments, tests and other documents cite its -> sections and its labels, and because it records reasoning a synthesis -> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design -> document is right; where either disagrees with the code, the code is.** -> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its -> replacement, and §13 defines every label. - -> Status: **built** — `photos-app.js`, `photos-app-settings.js` and -> `enrich_photo.py` all shipped; this header said "not implemented" long after -> they did. 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 | - -## 10b. One photo, not two, in the cross-group Search view (2026-09-02) - -Reported against Videos and true here by construction: two groups sharing one -directory listed every photo twice inside one album. `source-merge.js` folds -entries on the content hash and resolves one source per album; the units come -from `groupPhotoAlbums` itself (exported for this), called on the un-merged -list purely to learn them. - -**One consequence is deliberate and is not a bug.** Albums are keyed by -directory (§2.5), so two groups whose roots have *different basenames* put the -same photo in two differently-named albums, and the merge — scoped to a unit — -leaves it in both. That is correct: they are two albums. Only same-named albums -collapse, which is the reported shape. Videos and Music do not have this case, -their units being title- and tag-based rather than path-based. - -The album card's source badge counts the union over **the album**, not the -cover photo: the cover is `photos.find((p) => p.thumb_hash) || photos[0]`, so -an album in two groups whose cover sits in only one would have claimed a single -source. - -Design: `docs/refactoring-search.md`. `test_search_media_merge.py` covers both -the collapsing and the non-collapsing case. - -## 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. |