diff options
Diffstat (limited to 'docs/photos.md')
| -rw-r--r-- | docs/photos.md | 481 |
1 files changed, 481 insertions, 0 deletions
diff --git a/docs/photos.md b/docs/photos.md new file mode 100644 index 0000000..ed4a826 --- /dev/null +++ b/docs/photos.md @@ -0,0 +1,481 @@ +# MeshBay — Photos application (design) + +> Status: **proposal**, not implemented. Read `docs/apps.md` first — Photos is +> a new group application built on the plug-in mechanism described there. +> Read `docs/mediacenter.md` and `docs/musicbay.md` second: Photos reuses +> their node-side pattern (thumbnails generated and cached by the node, +> delivered through the existing chunk path, `IndexEntry` gains a few more +> additive fields) wherever the same shape applies, and this document states +> only where Photos differs and why. +> +> Follows the project convention: every claim names the adversary it holds +> against (§8). + +--- + +## 0. What was asked, in one paragraph + +A group "application" with the same principles as Videos/Music — a view over +the existing file index, no catalogue, enable/disable per group on the same +signed-op mechanism — for a shared photo library: the classic photo-album +elements (album grid, next/previous within a folder, a lightbox), a small +button to download a photo folder as a zip (Files already has this), and +optional per-photo info read from the image's own EXIF data. Three things are +explicitly **not** wanted, and they are what makes Photos smaller than Videos, +not bigger: **several** root folders rather than one, a single album-grid view +rather than a mode toggle with a "flat" fallback, and no third-party service +at all. + +--- + +## 1. What this design does not reopen + +Everything Videos/Music already established stands, and this plan fits +inside it: + +- **Views over the index, never a catalogue** (`desktop-client-v1.md` §6.10, + draft-v6 §2.7). A file stays tied to its filesystem representation; an + album is a directory, exactly as a season is a folder in Videos. +- **The apps plug-in mechanism** (`apps.md`): a new `photos-app.js`, one + registry entry, one node-side `ALLOWED_APPS` entry, i18n keys, the asset + list, the two file-set tests. Enablement is a per-group, operator-signed + setting, same shape as `member_upload`/`apps_enabled`. +- **Group-related server state lives on the node** (E9). Nothing here puts a + row on the hub. +- **Node-side derived-data caching, never in a shared root** — thumbnails + live in `data_dir/media_cache.db`, the same file Videos and Music already + use, never beside the originals. +- **Filesystem portability** (§6.8) — nothing here writes into a shared root. +- **`thumb_hash`/`width`/`height` on `IndexEntry` are already generic**, not + video-specific despite their current comments (`protocol.py:166`) — Photos + populates them exactly like Videos does, no new delivery mechanism. + +--- + +## 2. Where Photos differs from Videos/Music, and why + +### 2.1 Several roots, not one + +Videos and Music each gate on a single `video_root`/`audio_root` — one +folder, because their expensive work (TMDB/MusicBrainz lookups) needed an +explicit, deliberate opt-in and a real media library is usually one tree. +A photo library is routinely scattered: a "Vacances" folder here, a +"Famille" folder there, an old "Scans" folder from a different import, +none of them nested inside a common parent that would make sense to expose +whole. **Photos takes a *set* of root folders**, each independently chosen, +each independently removable. + +- New per-group setting: `photo_roots` — a JSON list of root-relative paths, + stored the same way `enabled_apps` already is (`roster.py`, + `SETTING_ENABLED_APPS`'s own `json.dumps(sorted(...))` pattern): + + ```python + SETTING_PHOTO_ROOTS = "photo_roots" + + async def photo_roots(self, group_id: str) -> list[str]: + value = await self.get_setting(group_id, self.SETTING_PHOTO_ROOTS) + if value is None: + return [] + try: + return list(json.loads(value)) + except (ValueError, TypeError): + return [] + + async def set_photo_roots(self, group_id: str, roots: list[str], + set_by: str = "") -> list[str]: + await self.set_setting(group_id, self.SETTING_PHOTO_ROOTS, + json.dumps(sorted(roots)), set_by) + return roots + ``` + + Empty list means "nothing configured yet" — same "absent means show + nothing" discipline `underVideoRoot` already established, not "the whole + index": the node runs no thumbnail/EXIF work for a group before at least + one root exists either (§2.3's enrichment gate), so falling back to + everything would show files nothing has enriched. + +- New signed op, same shape as `apps_enabled` (a *set*, not a single value, + signed in one message rather than one op per root — adding three folders + in Settings costs one signature): + + ``` + photo_roots { roots: [...] } # client → node + photo_roots_ack { roots: [...] } # node → every connected peer + ``` + + `OP_PHOTO_ROOTS = "photo_roots"` in `adminop.py`, subject = the sorted, + comma-joined root list — identical convention to `apps_enabled`'s subject, + so the operator's browser and the node arrive at identical bytes to + sign/verify without inventing a second serialization. + +- **Validation happens before a signature is ever asked for**, same + principle as `apps_enabled`'s "empty set refused up front" and + `video_root`'s path check: every candidate path is resolved against the + group's actual `RootSet` and must name a real, currently-readable + directory, or the whole request is refused immediately — one bad path + in a batch of five never reaches the operator's browser as a signing + prompt. Unlike `apps_enabled`, an **empty** `roots` list is accepted (it + is the "nothing configured yet" state, not a lockout — there is no + Photos-equivalent of "the operator would be locked out of their own + group" to guard against here). +- Broadcast in `handshake_ack` next to `video_root`/`audio_root` + (`"photo_roots": list(self._group_ctx().get("photo_roots") or [])`), and + `photo_roots_ack` to every already-connected peer on change, same as + `video_root_ack`. +- **No root may be nested inside another already-configured root** — + same rule §6.7 of `desktop-client-v1.md` already applies to a group's + *named* roots, applied here one level down to avoid double-listing the + same directory's images once directly and once as part of a parent. + Checked case-insensitively (§6.8), at validation time, alongside the + real-directory check. + +### 2.2 UI: an add/remove list, not a single `<select>` + +`group-settings.js`'s existing `videoRootDraft`/`saveVideoRoot` pattern +(a depth-indented `<select>` built from `rootFolderOptions`, one path, +confirm-on-change) does not fit a *set*. Photos gets its own small +component: the same `rootFolderOptions` `<select>` to **pick a folder to +add**, plus a list of already-configured roots each with a remove button, +and one **Save** that signs the whole resulting set in one op — mirroring +how `apps_enabled`'s checkbox list stages several changes before one +signature, not `video_root`'s single-value save. No separate "confirm, +this is destructive" dialog is needed the way `video_root`'s change has: +removing one root only drops that root's albums from view, it does not +replace the whole tab's content the way changing `video_root` does. + +### 2.3 One view, not two — and no third-party service + +Videos and Music each offer a toggle between an enriched view (TMDB/ +MusicBrainz-driven grouping) and a plain "flat" fallback, because the +enriched view can fail to resolve a match and the flat view is the +honest fallback for that case. **Photos has no enriched view to fall back +from** — there is no external catalogue for photos, no matching step that +can succeed or fail. So there is exactly **one** mode: a directory-driven +album grid, which is structurally what Videos' own "flat" mode already is. +This is also why the toolbar has no poster/flat toggle and no `localStorage` +view-mode preference — nothing to choose between. + +**Concretely, per this document's title, "pas de vue de type Flat files" +means:** no raw sortable file-listing table (that is Files' job, already +available in the same group), and no secondary "ungrouped, alphabetical" +mode alongside a primary one — the album grid *is* the only mode, not one +of two. + +**No 3rd-party lookups at all.** Per-photo "info" (§2.4) is read from the +file's own embedded EXIF data, entirely locally, on the node, at index +time — no credential, no `tmdb_enabled`-style per-group toggle, no +`tmdb_api_token`-style node-wide config, no outbound network call to +anything. This is strictly less exposure than Videos/Music (§8): the class +of risk `mediacenter.md` §8 spent a whole table row on ("new outbound +traffic (node → TMDB)") does not exist for Photos. + +### 2.4 EXIF: what gets read, and what deliberately does not + +`Pillow` (new dependency, `packages/meshbay-node/pyproject.toml`, same +"one purpose-built pip dependency per app" precedent as `guessit`/ +`mutagen`) reads two things per image at index time, mirroring the ffprobe +technical-probe / guessit-parse split Videos already has: + +- **Technical facts, always read**: pixel `width`/`height` (already generic + fields on `IndexEntry`, §5.1) and, for the thumbnail itself, the EXIF + `Orientation` tag — **not exposed to clients**, only used to correct the + thumbnail's own rotation before it is generated (§6). A phone photo + stored "sideways" with an orientation tag is an extremely common real + file, and skipping this produces a library of sideways thumbnails — a + concrete, testable correctness requirement, not a nice-to-have. +- **A minimal info set, best-effort**: `taken_at` (from `DateTimeOriginal`, + falling back to nothing rather than guessing — `added_at`, the index + timestamp, is already shown elsewhere and is not a substitute) and + `camera` (`Make` + `Model`, joined, when both are present). That is the + entire new wire surface (§5.2) — deliberately narrow, matching the + "éventuellement" (optional, best-effort) framing of the ask rather than + building a full EXIF-viewer panel (dozens of fields: exposure, ISO, focal + length, lens, GPS) that nobody asked for. A fuller info panel is listed as + an open item (§10), not built here. + +**GPS is read by nobody, on purpose.** `GPSInfo` is present in the EXIF of a +photo taken on most phones and is a location disclosure the instant it is +surfaced — to every group member, from a thumbnail's own metadata, with no +extra step. Nothing in this design extracts it, caches it, or wires it onto +`IndexEntry` or any response. This does not change what a member who +downloads the *original* file can already extract themselves — the file's +own bytes are unchanged, exactly as they are for Files today — but the +Photos application itself never becomes a channel that makes that data +casually visible to everyone browsing an album. Named per the project +convention (§8): this holds against another member and a passive/active +hub identically (the node never computes or transmits the field), and it is +a policy choice about what the *view* shows, not a claim that GPS data does +not exist in the file. + +### 2.5 The album view: every directory that contains an image + +The ask is explicit: a view of every directory containing images under the +configured roots, not a folder tree to click through level by level. This +needs **no new wire data** — it is a client-side derivation over the +existing per-file index, the same kind of work `underVideoRoot`/ +`groupVideoEntries` already do: + +```js +function underAnyPhotoRoot(entry, photoRoots) { + const p = entry.path || ''; + return photoRoots.some(r => p === r || p.startsWith(r + '/')); +} + +function groupPhotoAlbums(entries, photoRoots) { + const byDir = new Map(); + for (const e of entries) { + if (e.type !== 'image' || !underAnyPhotoRoot(e, photoRoots)) continue; + const dir = e.path.includes('/') ? e.path.slice(0, e.path.lastIndexOf('/')) : ''; + if (!byDir.has(dir)) byDir.set(dir, []); + byDir.get(dir).push(e); + } + return [...byDir.entries()] + .map(([dir, photos]) => ({ dir, photos: photos.sort(/* name */) })) + .sort((a, b) => a.dir.localeCompare(b.dir)); +} +``` + +Every directory that has at least one image becomes one album card — a +subfolder of a subfolder qualifies independently, exactly as a season +folder is its own category under Videos. No recursion is needed to build +the *list* of albums; recursion is not merged away, it just means a deeply +nested library produces more, smaller album cards rather than fewer, larger +ones, which matches "directory is the category" already established for +Videos/Music. + +**Landing page**: a flat, alphabetically sorted grid of album cards (dir +name, photo count, a thumbnail from the first — or, better, a stable +"first with a thumbnail already ready" — photo, same fallback Videos' +`PosterGrid` already uses when picking a representative episode). Clicking +a card opens that one directory's own photo grid. + +--- + +## 3. Album grid and lightbox — the classic elements asked for + +- **Grid**: one thumbnail tile per photo in the open album, same + `LazyTile`/`MediaThumb` virtualization Videos already built and exported + for reuse (`video-app.js`'s trailing `export { VideoApp, MediaThumb, + LazyTile }`, already consumed by `music-app.js` the same way) — Photos + imports both rather than reimplementing them, per `apps.md`'s checklist. +- **Lightbox**: clicking a tile opens a full-size view. Unlike a grid tile + (which shows the cached, resized thumbnail), the lightbox fetches the + **original file** through the existing chunk path — the same mechanism + `FilePreview`/`ChatImage` already use for an image attachment — cached + per session by file id, same pattern as `_thumbBlobCache`. +- **Next/previous**: cycles through the currently open album's photo list + (the same array the grid rendered from — no server round trip to know + what's next), bound to on-screen buttons and the left/right arrow keys. + Wraps or stops at the ends (a UI choice, not architectural — pick + whichever `video-player.js`'s own seek controls already read as + idiomatic for this codebase). +- **Per-photo info**: filename, dimensions (`width`×`height`), file size, + `taken_at` and `camera` when present (§2.4) — shown in the lightbox, + never on the grid tile itself (a grid of a hundred thumbnails does not + need a hundred date stamps competing with the image). +- **Zip an album**: a small toolbar button in the open album view, next to + the mode-less toolbar (filter only — there is no mode toggle, §2.3). + **Reuses Files' existing mechanism rather than reimplementing it.** + `files-app.js`'s `downloadDirectory` (`entriesUnder`/`ZipStream`/ + `_openDownloadTarget`/`transfers`, `files-app.js:95-172`) is + directory-path-in, streamed-zip-out and has nothing Files-specific in it + once `entries`/`transportRef`/`gekRef`/`setError` are already props any + app receives (`apps.md` §2). **Refactor**: lift `downloadDirectory` out of + `files-app.js` into `file-utils.js` (already the shared home for the + download/decrypt pipeline it's built on — `pipelinedDownload`, + `_openDownloadTarget`, `_saveBlob`, `CHUNK_SIZE`) as an exported function + taking `(transport, gek, entries, dir, { setError })`; `files-app.js`'s + own toolbar action becomes a one-line caller, and `photos-app.js` calls + the same function for the open album's `dir`. One implementation, two + call sites — not a second zip writer. + +--- + +## 4. Protocol and index changes + +### 4.1 Reused, not new + +`thumb_hash`, `width`, `height` (`protocol.py:166`) — already declared, +already wired through `index_entry_wire`, already delivered via the chunk +path (`_try_serve_thumbnail`). Photos populates them for `type == "image"` +entries exactly as Videos populates them for `type == "video"`. Their +comments ("video only") should be updated to reflect that they are +media-type-generic once this lands — a one-line doc fix, not a protocol +change. + +### 4.2 New: two small fields + +```python +taken_at: int | None = None # unix timestamp, EXIF DateTimeOriginal — Photos app +camera: str | None = None # "Make Model", when both present — Photos app +``` + +Additive fields on the same dataclass, added to `index_entry_wire`'s dict — +MNP **MINOR** bump (whatever the current version is by the time this is +built), same class of change as Videos' `display_title`/`season`/`episode` +addition. An older client simply doesn't render them. + +### 4.3 New wire messages: only for the root-set change + +``` +photo_roots { roots: [...] } # client → node, admin-challenged (§2.1) +photo_roots_ack { roots: [...] } # node → every connected peer of the group +``` + +No `photo_meta_req`/`resp` pair (contrast Videos' `media_meta_req`, needed +because a TMDB call is a network round trip worth deferring per-tile). +`taken_at`/`camera` are cheap, local, and already computed once at index +time, so they ride the ordinary index the same way `duration` does — no +per-tile fetch, no virtualization concern for the *metadata* (only the +thumbnail image bytes themselves are fetched lazily, same as any +`thumb_hash`). + +### 4.4 `ALLOWED_APPS` / `DEFAULT_APPS` + +`webrtc_server.py`'s `ALLOWED_APPS` frozenset gains `"photo"`. +`roster.py`'s `DEFAULT_APPS` stays `("chat", "files")` — a brand-new group +does not get Photos for free, same reasoning as Videos/Music: it is new +per-file node CPU cost (thumbnail generation, EXIF parse) across whatever +the operator eventually points it at, and it shows nothing useful until at +least one root is chosen anyway (§2.1), so there is nothing lost by making +it an explicit opt-in via the existing Settings checklist. + +--- + +## 5. Node-side implementation, concretely + +| Piece | Where | What | +|---|---|---| +| Thumbnail + EXIF enrichment | new `meshbay_node/indexer/enrich_photo.py`, `PhotoEnricher` class | Own bounded pool (`asyncio.Semaphore`, own small `max_concurrent`, own short timeout) — sibling to `enrich.py`'s Videos pool and `enrich_audio.py`'s Music pool, **never shared with either**, same "never touches `max_concurrent_streams`" rule §6.10/§5.2 of the other two docs already establish. Pillow-based: `ImageOps.exif_transpose` before resizing (orientation correction, §2.4), resize to a bounded long edge (e.g. 480px, matching the size class Videos' own thumbnails already use), re-encode JPEG, extract `DateTimeOriginal`/`Make`/`Model` via `Image.getexif()` | +| Enrichment gate | `meshbay_node/daemon.py`, new `_enrich_new_photo_entries`/`_enrich_photo_roots_now`, mirroring `_enrich_new_video_entries`/`_enrich_video_root_now` exactly, but checking membership against a **list** of roots (`_under_any_root(entry.path, photo_roots)`) rather than one string | Fires for `type == "image"` entries under any configured `photo_roots`; a sweep re-runs whenever the root *set* changes (add or remove), from `ops.set_photo_roots` | +| Caches | `data_dir/media_cache.db`, existing `thumbs` table (`(thumb_hash) → jpeg bytes`, keyed by the file's own id — no synthetic id needed, a photo's thumbnail belongs to exactly one file, unlike a TMDB poster shared by many episodes) | No schema change | +| Cache lifecycle | same pruning hook Videos/Music already use on an `IndexEntry` leaving the index | No new code path, same event | +| Operator config | `roster.py` `group_settings`, real `group_id` (not the `""` sentinel — `photo_roots` is per-group, like `video_root`/`audio_root`, unlike the node-wide TMDB credential) | `SETTING_PHOTO_ROOTS`, `photo_roots()`/`set_photo_roots()`; `ops.py` gains `set_photo_roots(state, group_id, roots)`, one `_op(...)` line, same loopback/CLI/MNP adapters as everything else in `ops.py` | +| Admin op | `meshbay_common/adminop.py` | `OP_PHOTO_ROOTS = "photo_roots"` | +| Wire types | `meshbay_common/protocol.py` (`MNP.*`) | `PHOTO_ROOTS`, `PHOTO_ROOTS_ACK` | +| Handler | `meshbay_node/transport/webrtc_server.py` | `_do_photo_roots`/`_admin_exec_photo_roots`, mirroring `_do_video_root`/`_admin_exec_video_root`, validating a list; `handshake_ack` gains `"photo_roots": list(self._group_ctx().get("photo_roots") or [])` | +| `pyproject.toml` | `packages/meshbay-node/pyproject.toml` | add `Pillow>=10` | + +--- + +## 6. Client-side, per `apps.md`'s checklist + +1. `photos-app.js` — receives the standard props (`apps.md` §2), plus + `photoRoots` (threaded through `group-page.js` exactly like `videoRoot`/ + `audioRoot`: `useState`, reset on `groupId` change, passed down, updated + from `photo_roots_ack`). Imports `MediaThumb`/`LazyTile` from + `video-app.js` and the lifted `downloadDirectory` from `file-utils.js` + (§3) — no reimplementation of either. +2. Register `{ key: "photo", icon: "image", labelKey: "group.tab_photos", + Component: PhotosApp }` in `apps.js`. +3. `ALLOWED_APPS` (§4.4). +4. `group.tab_photos` (and a handful of `photo.*` strings — lightbox + labels, "no roots configured yet", the zip button's tooltip) in all ten + `static/locales/*.js`. `test_locales.py` holds them to the same key set. +5. `webapp.py`'s `_ASSETS` tuple — add `photos-app.js`. +6. `test_hook_ordering.py` (`STATIC_FILES`) and `test_transport_contracts.py` + (`SPLIT_FILES`) — add the new file to both. +7. `group-settings.js` — the add/remove root-list component (§2.2), wired + the same way the Videos/Music root pickers already are (`transport. + setPhotoRoots(roots, signFn)`, a new `transportRef` method mirroring + `setVideoRoot`/`setAudioRoot`). +8. `npm run sync-ui` in `meshbay-client`. + +No hub change. Protocol change is limited to §4.2's two additive fields and +§4.3's one message pair — smaller than either Videos or Music, consistent +with Photos doing less (no metadata-matching round trip, no mode toggle). + +--- + +## 7. What Photos deliberately does not do + +- **No TMDB/MusicBrainz-equivalent matching service.** There is nothing to + match a photo *to* — it already is what it is, per its own folder and + filename. §2.3. +- **No mode toggle, no `localStorage` view preference.** One album-grid + view. §2.3. +- **No GPS surfaced anywhere in the application.** §2.4. +- **No recursive "album of albums" browsing UI beyond the flat landing + list.** Every qualifying directory is one card; there is no folder-tree + affordance to build or maintain. §2.5. +- **No RAW / HEIC support in v1.** The node's indexer today classifies + `.jpg/.jpeg/.png/.gif/.webp/.svg/.bmp/.tiff` as `image` + (`indexer.py:58`) — Pillow reads all of those natively. HEIC (the default + format on recent iPhones) needs an extra native dependency + (`pillow-heif`) not currently in the tree; RAW formats need a different + library family entirely (`rawpy`/LibRaw). Both are real gaps for a + photo-focused audience and are listed as open items (§10), not silently + assumed away. +- **No thumbnail preloading of the next/previous lightbox image.** A + nice-to-have for a snappier feel on a slow connection; not required for + a working v1. §10. + +--- + +## 8. Security — per adversary + +| Claim | Passive hub | Active hub | Malicious node operator | Another member | +|---|---|---|---|---| +| Thumbnail/original delivery | ✅ unchanged transport | ✅ unchanged transport | sees it already (holds the plaintext file) | same GEK-proofed MNP channel as files/streaming — no new authorization surface | +| EXIF extraction | — | — | already has the plaintext file, could read EXIF manually — no new exposure | reads only what the node chooses to surface (`taken_at`/`camera`), never GPS (§2.4) — a strictly narrower surface than downloading the original, which any member with file access could already do | +| New outbound traffic | **none** — Photos makes zero third-party network calls, unlike Videos/Music | **none** | — | — | +| Stale cache after file deletion | — | — | pruned on the same index-deletion event Videos/Music already use — no new gap to introduce | — | +| Root-set change (`photo_roots`) | ✅ signed, admin-challenged | ✅ signed, admin-challenged | the operator's own instruction | cannot forge — same `_verify_admin_sig` path as `apps_enabled`/`video_root` | + +**The claim this design supports:** Photos adds no new authorization +boundary and, unlike Videos/Music, no new *category* of exposure either — +there is no credential to hold, no third party to leak metadata to, and the +one genuinely new piece of client-visible data (EXIF) is deliberately +narrowed to exclude the one field (GPS) that would matter. + +**The claim it must not make:** that GPS "isn't in the file" — it is, for +most phone photos, in the original bytes any member with file access can +already download. What this design controls is only what the *Photos +application itself* computes and surfaces, not what the underlying file +contains. + +--- + +## 9. Filesystem/Windows + +Nothing new beyond what `desktop-client-v1.md` §6.8/§7.5 already +establishes. Pillow ships with its own codecs for every format §7's table +lists and needs no external `ffmpeg`-style binary the way Videos' thumbnail +path does — if anything, Photos has *fewer* platform-dependent moving parts +than Videos, not more. + +--- + +## 10. Open items + +| # | Item | Why it is not decided here | +|---|---|---| +| P1 | HEIC/RAW support | Needs a real dependency decision (`pillow-heif`, `rawpy`/LibRaw) and a licensing/build check, not just a config constant | +| P2 | A fuller EXIF info panel (exposure, ISO, focal length, lens) beyond `taken_at`/`camera` | Product/UX call — the ask said "éventuellement", and the minimal set already answers it; extending is cheap once the plumbing (§4.2's pattern) exists | +| P3 | Lightbox next/previous image preloading | Perf nicety, not required for a working v1 | +| P4 | Wrap-around vs. stop-at-ends for next/previous at album boundaries | UI choice, mirror whatever `video-player.js`'s own controls already do for consistency | +| P5 | Album cover selection (always "first photo" vs. an operator/member choice) | Product call; "first photo, stable" is a reasonable, zero-config default and is what this document assumes | + +## 11. Acceptance before shipping + +1. Orientation correction verified against a real EXIF-rotated phone photo + (§2.4) — a thumbnail generated from a "sideways" source file renders + upright. Covered by `test_photo_enrichment.py`. +2. Cache pruning on file deletion actually fires for photo thumbnails, same + acceptance step `mediacenter.md` §11 already required for video + thumbnails — not just argued, covered by a test. +3. `taken_at`/`camera` come back empty (not an error) for a file with no + EXIF block at all (a screenshot, a scanned/edited image with metadata + stripped) — the ordinary case for a lot of real libraries, must degrade + the same way "no TMDB match" already does for Videos. +4. GPS fields are confirmed absent from every response/index field a client + ever receives — not just "not intentionally added" (§2.4's claim), a + grep-based test over `index_entry_wire` and any new response shape, the + same discipline `test_hub_address_seam.py`/`test_task_lifetime.py` + already apply elsewhere in this codebase to a property that must never + silently regress. +5. Live smoke test against a real, messy photo library (scattered roots, + nested subfolders, a mix of phone photos with orientation tags and old + scans with none) before calling this done — per this project's own + repeated lesson (`CLAUDE.md`) that a source-reading test is weak + evidence and launching the real thing finds what it cannot. |