summaryrefslogtreecommitdiffstats
path: root/docs/photos.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/photos.md')
-rw-r--r--docs/photos.md516
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.