diff options
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. |