diff options
37 files changed, 2301 insertions, 110 deletions
@@ -703,7 +703,7 @@ SFR residential Fedora 44 → meshbay.org OVH VPS: | WebRTC signaling (hub) | `meshbay_hub.api.signaling` | Phase 9.2 — SDP/ICE relay | | Browser transport client | `static/transport.js` | Phase 9.4 — WebRTC DataChannel | | Web SPA | `static/app.js` | Phase 9.6 — Preact + preact-router. Routing and every page except a group's — see `docs/apps.md` for the group UI's file layout (2026-08-23 split) | -| Group applications (adding one) | `docs/apps.md` | Chat/Files/Videos today (see `docs/mediacenter.md` for Videos: poster grid, TMDB metadata, season tabs, manual match correction), Music planned (see `docs/musicbay.md`: album grid, MusicBrainz metadata, no new streaming path, persistent shell-level player), Photos planned. Props contract, enablement mechanism, checklist | +| Group applications (adding one) | `docs/apps.md` | Chat/Files/Videos/Music today (see `docs/mediacenter.md` for Videos: poster grid, TMDB metadata, season tabs, manual match correction; `docs/musicbay.md` for Music: album grid, MusicBrainz metadata, no new streaming path, persistent shell-level player), Photos designed but not built (see `docs/photos.md`: several photo roots per group, one album-grid view, EXIF read locally with no third party). Props contract, enablement mechanism, checklist | | File download (large) | `static/file-utils.js` | `downloadEntry`/`_openDownloadTarget`, File System Access API (`showSaveFilePicker`) — stream to disk | | Background tasks (node) | `meshbay_node.transport.webrtc_server` | `_spawn()` — the only way to start one; a bare `ensure_future` can be collected | | Stream handover (node) | `meshbay_node.transport.webrtc_server` | `_replace_stream` + `shutdown_tasks` — one viewer, one film, and the slot comes back when they leave | diff --git a/docs/apps.md b/docs/apps.md index a68a6e6..1cd6339 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -190,11 +190,14 @@ the only node-side touches, and both are allow-lists, not new wire messages. app can reuse the same node-side machinery (thumbnail cache, chunk-path delivery) without re-deciding any of this. - **Videos, Music, Photos themselves.** Videos is now built (`video-app.js`, - `docs/mediacenter.md`). Music is **planned, not built** — see - `docs/musicbay.md` — and reuses Videos' node-side thumbnail/chunk-delivery + `docs/mediacenter.md`). Music is now built (`music-app.js`, + `docs/musicbay.md`) and reuses Videos' node-side thumbnail/chunk-delivery machinery, with no new streaming path (a track is small enough to - download-then-play, unlike a film). Photos remains deliberately out of - scope — see `meshbay-draft-v6.md` §2.7. + download-then-play, unlike a film). Photos is **designed, not built** — + see `docs/photos.md` — and reuses the same `thumb_hash`/chunk-delivery + machinery again; unlike Videos/Music it needs several root folders per + group rather than one, has a single album-grid view with no third-party + matching step, and reads EXIF locally on the node instead. - **The offline/loopback settings path.** `member_upload` can be toggled two ways: over a live MNP connection, or (Electron only) via the node's local HTTP API when MNP isn't connected (`platform.node.call('PUT', .../member- 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. diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 2bdf550..0517eba 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -114,6 +114,16 @@ function registerUiProtocol() { 'Content-Type': contentType(target), 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff', + // Without this, a response carrying no cache header at all is a + // response Chromium is free to reuse by heuristic freshness — the + // same trap CLAUDE.md already records for the hub-served SPA + // (`Cache-Control: no-cache` only binds a browser that asks). + // Here every file is read fresh from disk on each request + // already (sync-ui during development, a fresh install + // otherwise), so nothing is ever served from the renderer's own + // HTTP cache instead — a plain reload is enough after `npm run + // sync-ui`, not just a full app restart. + 'Cache-Control': 'no-store', }, }); } catch { diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index b37de8e..0ca0005 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -43,5 +43,11 @@ __version__ = "0.7.0" # wrong, and Music now requires one before showing/enriching anything, # exactly like Videos. Additive at the protocol level: an older client # never sends the op and never expects the field. -MNP_VERSION = "0.10" +# 0.11: added `taken_at`/`camera` to `IndexEntry` (best-effort, from a photo's +# own EXIF block) and `photo_roots`/`photo_roots_ack`, for the Photos group +# app (docs/photos.md). Unlike `video_root`/`audio_root`, `photo_roots` is a +# *set*, replaced whole in one signed op — a photo library is routinely +# scattered across several folders, not one. Additive: an older client +# never sends the op and never expects either field. +MNP_VERSION = "0.11" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index d09b95e..19dfe8e 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -96,6 +96,11 @@ OP_MUSICBRAINZ_ENABLED = "musicbrainz_enabled" # OP_VIDEO_ROOT above, added later once a real messy library showed the # "no root, whole shared tree" simplification didn't hold up. OP_AUDIO_ROOT = "audio_root" +# Which folder(s) are the Photos app's entry points for this group — a +# *set*, unlike OP_VIDEO_ROOT/OP_AUDIO_ROOT above: a photo library is +# routinely scattered across several folders (docs/photos.md §2.1). The +# whole set is signed and replaced in one op, same shape as OP_APPS_ENABLED. +OP_PHOTO_ROOTS = "photo_roots" OP_ROOT_ADD = "root_add" OP_ROOT_REMOVE = "root_remove" OP_GROUP_ATTACH = "group_attach" diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 24fea8f..dd377e7 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -123,6 +123,11 @@ class MNP: # shape as VIDEO_ROOT above. AUDIO_ROOT = "audio_root" # operator → node: which folder is the Music entry point AUDIO_ROOT_ACK = "audio_root_ack" + # Which folder(s) are the Photos app's entry points for this group — a + # *set*, unlike VIDEO_ROOT/AUDIO_ROOT above, since a photo library is + # routinely scattered across several unrelated folders (docs/photos.md §2.1). + PHOTO_ROOTS = "photo_roots" # operator → node: the whole root set, replaced + PHOTO_ROOTS_ACK = "photo_roots_ack" # Device linking. A new device files a request bound to a code it displays; # an already-pinned device of the same account approves it. Neither the hub # nor the node can produce the countersignature. @@ -174,14 +179,16 @@ class IndexEntry: thumb_hash: str | None = None # blake3 of thumbnail uploader_id: str | None = None # user_id of who uploaded (None = pre-existing on disk) uploader_pk: str | None = None # Ed25519 public key of uploader (base64 raw 32 bytes) - width: int | None = None # pixels, video only - height: int | None = None # pixels, video only + width: int | None = None # pixels, video/image + height: int | None = None # pixels, video/image display_title: str | None = None # parsed or cleaned-filename title, Videos app season: int | None = None # parsed season number, Videos app episode: int | None = None # parsed episode number, Videos app artist: str | None = None # tag or parsed, Music app album: str | None = None # tag or parsed, Music app track_no: int | None = None # tag or parsed, Music app + taken_at: int | None = None # unix timestamp, EXIF DateTimeOriginal — Photos app + camera: str | None = None # "Make Model", when both present — Photos app def index_entry_wire(e: IndexEntry) -> dict: @@ -201,6 +208,7 @@ def index_entry_wire(e: IndexEntry) -> dict: "display_title": e.display_title, "season": e.season, "episode": e.episode, "artist": e.artist, "album": e.album, "track_no": e.track_no, + "taken_at": e.taken_at, "camera": e.camera, } diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index f6736e2..8aff952 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -34,7 +34,7 @@ _ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js", # to what the browser must fetch. "icon.js", "file-utils.js", "hub-client.js", "apps.js", "chat-app.js", "files-app.js", "video-player.js", "video-app.js", - "music-app.js", "music-player.js", + "music-app.js", "music-player.js", "photos-app.js", "group-settings.js", "group-page.js") diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js index 08dc353..47b5bba 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/apps.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js @@ -2,6 +2,7 @@ import { ChatPanel } from './chat-app.js'; import { FilesPanel } from './files-app.js'; import { VideoApp } from './video-app.js'; import { MusicApp } from './music-app.js'; +import { PhotosApp } from './photos-app.js'; /** * Every group "application", in tab order. @@ -20,6 +21,7 @@ const APPS = [ { key: 'files', icon: 'folder', labelKey: 'group.tab_files', Component: FilesPanel }, { key: 'video', icon: 'video', labelKey: 'group.tab_video', Component: VideoApp }, { key: 'music', icon: 'music', labelKey: 'group.tab_music', Component: MusicApp }, + { key: 'photo', icon: 'image', labelKey: 'group.tab_photos', Component: PhotosApp }, ]; /** The registry filtered to what this group has enabled, in registry order. */ diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js index a005e2e..b44d105 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -1,5 +1,7 @@ import * as downloads from './downloads.js'; import * as platform from './platform.js'; +import { t } from './i18n.js'; +import { ZipStream, entriesUnder } from './zipstream.js'; const FILE_ICONS = { video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}', @@ -200,8 +202,97 @@ async function downloadEntry(transfers, transport, gek, entry) { }); } +/** + * Download a directory as a zip, written straight to disk. + * + * An archive of a group directory is routinely tens of gigabytes, so it is + * never held anywhere: each file is fetched chunk by chunk, decrypted, and + * handed to the zip writer, which hands it to the file the browser opened. + * Peak memory is one chunk plus one small record per file. + * + * Without the File System Access API there is nowhere to stream to, and the + * only alternative is to build the whole thing in memory — so that path is + * offered but says what it costs first. + * + * Lifted out of files-app.js (docs/photos.md §3) so photos-app.js's own + * "zip this album" button calls the same implementation rather than a + * second one — nothing here is Files-specific once `entries`/`transport`/ + * `gek`/`setError` are passed in, the same shared-context shape every app + * already receives (apps.md §2). + */ +async function downloadDirectory(transfers, transport, gek, entries, dir, { setError }) { + if (!transport || !transport.connected) return; + + const files = entriesUnder(entries, dir); + if (!files.length) { + setError(t('group.zip_empty')); + return; + } + const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); + const suggested = (dir.split('/').pop() || 'files') + '.zip'; + + // totalBytes decides how this is delivered, but it is not the archive's + // size — headers and the central directory come on top — so it is not + // announced as a Content-Length that the download would then miss. + const target = await _openDownloadTarget(suggested, totalBytes, { + types: [{ description: 'ZIP archive', + accept: { 'application/zip': ['.zip'] } }], + }, 0); + if (target === false) return; + if (!target && !confirm(t('group.zip_no_stream', { + size: formatSize(totalBytes), name: suggested, + }))) { + return; + } + const zipOpenRef = { url: null }; + + transfers.start({ + kind: 'download', name: (target && target.name) || suggested, + total: totalBytes, transport, + open: target + ? (target.open || null) + : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); }, + run: async ({ signal, onProgress }) => { + const writable = target ? target.writable : null; + const parts = writable ? null : []; + let written = 0; + try { + const zip = new ZipStream(async (bytes) => { + if (writable) await writable.write(bytes); + else parts.push(bytes.slice()); + }); + + for (const { entry, name } of files) { + await zip.begin(name, entry.size, + new Date((entry.added_at || 0) * 1000)); + // A zero-byte file has no chunk to ask for; the header and an empty + // descriptor are the whole entry. + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + if (totalChunks > 0) await pipelinedDownload( + transport, gek, entry.id, totalChunks, + (bytes) => { written += bytes; onProgress(written, totalBytes); }, + // pipelinedDownload writes in order, which the archive needs. + { write: (plaintext) => zip.write(plaintext) }, signal); + await zip.end(); + } + await zip.finish(); + if (writable) await writable.close(); + else { + const blob = new Blob(parts, { type: 'application/zip' }); + _saveBlob(blob, suggested); + zipOpenRef.url = URL.createObjectURL(blob); + } + } catch (err) { + if (writable) await writable.abort().catch(() => {}); + throw err; + } + }, + }); +} + export { FILE_ICONS, formatSize, formatDate, PREVIEWABLE_TEXT, canPreview, CHUNK_SIZE, _openDownloadTarget, _saveBlob, _b64ToU8, pipelinedDownload, downloadEntry, + downloadDirectory, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index c09ef40..16ab9f6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -3,11 +3,11 @@ import { } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { Icon } from './icon.js'; -import { ZipStream, entriesUnder } from './zipstream.js'; +import { entriesUnder } from './zipstream.js'; import { transfers } from './transfers.js'; import { FILE_ICONS, formatSize, formatDate, canPreview, CHUNK_SIZE, - _openDownloadTarget, _saveBlob, pipelinedDownload, downloadEntry, + pipelinedDownload, downloadEntry, downloadDirectory as sharedDownloadDirectory, } from './file-utils.js'; // ── Files ──────────────────────────────────────────────────────────────────── @@ -91,88 +91,13 @@ function FilesPanel({ } }, [currentPath]); - /** - * Download a directory as a zip, written straight to disk. - * - * An archive of a group directory is routinely tens of gigabytes, so it is - * never held anywhere: each file is fetched chunk by chunk, decrypted, and - * handed to the zip writer, which hands it to the file the browser opened. - * Peak memory is one chunk plus one small record per file. - * - * Without the File System Access API there is nowhere to stream to, and the - * only alternative is to build the whole thing in memory — so that path is - * offered but says what it costs first. - */ + // The implementation lives in file-utils.js (docs/photos.md §3) so + // photos-app.js's own "zip this album" button can call the same code + // rather than a second one. const downloadDirectory = useCallback(async (dir) => { const transport = transportRef.current; - if (!transport || !transport.connected) return; - - const files = entriesUnder(entries, dir); - if (!files.length) { - setError(t('group.zip_empty')); - return; - } - const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); - const suggested = (dir.split('/').pop() || 'files') + '.zip'; - - // totalBytes decides how this is delivered, but it is not the archive's - // size — headers and the central directory come on top — so it is not - // announced as a Content-Length that the download would then miss. - const target = await _openDownloadTarget(suggested, totalBytes, { - types: [{ description: 'ZIP archive', - accept: { 'application/zip': ['.zip'] } }], - }, 0); - if (target === false) return; - if (!target && !confirm(t('group.zip_no_stream', { - size: formatSize(totalBytes), name: suggested, - }))) { - return; - } - const gek = gekRef.current; - const zipOpenRef = { url: null }; - - transfers.start({ - kind: 'download', name: (target && target.name) || suggested, - total: totalBytes, transport, - open: target - ? (target.open || null) - : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); }, - run: async ({ signal, onProgress }) => { - const writable = target ? target.writable : null; - const parts = writable ? null : []; - let written = 0; - try { - const zip = new ZipStream(async (bytes) => { - if (writable) await writable.write(bytes); - else parts.push(bytes.slice()); - }); - - for (const { entry, name } of files) { - await zip.begin(name, entry.size, - new Date((entry.added_at || 0) * 1000)); - // A zero-byte file has no chunk to ask for; the header and an empty - // descriptor are the whole entry. - const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); - if (totalChunks > 0) await pipelinedDownload( - transport, gek, entry.id, totalChunks, - (bytes) => { written += bytes; onProgress(written, totalBytes); }, - // pipelinedDownload writes in order, which the archive needs. - { write: (plaintext) => zip.write(plaintext) }, signal); - await zip.end(); - } - await zip.finish(); - if (writable) await writable.close(); - else { - const blob = new Blob(parts, { type: 'application/zip' }); - _saveBlob(blob, suggested); - zipOpenRef.url = URL.createObjectURL(blob); - } - } catch (err) { - if (writable) await writable.abort().catch(() => {}); - throw err; - } - }, - }); + await sharedDownloadDirectory( + transfers, transport, gekRef.current, entries, dir, { setError }); }, [entries]); const deleteDirectory = useCallback(async (dir) => { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index ab9dcc5..cdbe612 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -89,6 +89,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, const [videoRoot, setVideoRoot] = useState(''); // Same shape — the Music app's own entry point. const [audioRoot, setAudioRoot] = useState(''); + // The Photos app's entry points — a *list*, unlike videoRoot/audioRoot + // above (docs/photos.md §2.1: a photo library is routinely scattered + // across several folders). Empty means nothing configured yet. + const [photoRoots, setPhotoRoots] = useState([]); // MusicBrainz on/off (per-group) + whether a contact string is configured // (node-wide) — docs/musicbay.md §3.2, same shape as tmdbConfig above. const [musicbrainzConfig, setMusicbrainzConfig] = useState(null); @@ -241,6 +245,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, }); setVideoRoot(ack.video_root || ''); setAudioRoot(ack.audio_root || ''); + setPhotoRoots(ack.photo_roots || []); setMusicbrainzConfig({ enabled: ack.musicbrainz_enabled !== false, contactConfigured: !!ack.musicbrainz_contact_configured, @@ -259,6 +264,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, transport.onTmdbEnabled = (enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled })); transport.onVideoRoot = (path) => setVideoRoot(path); transport.onAudioRoot = (path) => setAudioRoot(path); + transport.onPhotoRoots = (roots) => setPhotoRoots(roots); transport.onMusicbrainzConfig = (cfg) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), ...cfg })); transport.onMusicbrainzEnabled = (enabled) => @@ -472,6 +478,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, onRefreshIndex: refreshIndex, onActivity: touchActivity, videoRoot, onVideoRoot: (path) => setVideoRoot(path), audioRoot, onAudioRoot: (path) => setAudioRoot(path), + photoRoots, onPhotoRoots: (roots) => setPhotoRoots(roots), tmdbConfig, musicbrainzConfig, onPlayQueue, }; @@ -598,6 +605,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, onVideoRoot=${(path) => setVideoRoot(path)} audioRoot=${audioRoot} onAudioRoot=${(path) => setAudioRoot(path)} + photoRoots=${photoRoots} + onPhotoRoots=${(roots) => setPhotoRoots(roots)} onRefreshIndex=${refreshIndex} onLeft=${onLeft} onPaired=${() => setOperatorPaired(true)} /> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index a4db4cb..60b0184 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -101,6 +101,79 @@ function RootFolderRow({ `; } +/** + * Which folder(s) are the Photos app's entry points for this group — a + * *set*, unlike RootFolderRow's single value above (docs/photos.md §2.1: a + * photo library is routinely scattered across several folders). An + * add/remove list rather than a `<select>`: pick a folder to add from the + * same `rootFolderOptions` the Videos/Music pickers use, list what is + * already configured with a remove button each, and one Save signs the + * whole resulting set in one op (same shape as the app-enable checkboxes + * below — several changes staged, one signature). + */ +function PhotoRootsRow({ folders, value, busy, msg, onSave }) { + const [draft, setDraft] = useState(value || []); + useEffect(() => { setDraft(value || []); }, [value]); + const [addSelection, setAddSelection] = useState(''); + + const available = folders.filter((p) => !draft.includes(p)); + const addRoot = () => { + if (!addSelection || draft.includes(addSelection)) return; + setDraft((prev) => [...prev, addSelection].sort()); + setAddSelection(''); + }; + const removeRoot = (path) => setDraft((prev) => prev.filter((p) => p !== path)); + + const unchanged = draft.length === (value || []).length + && draft.every((p) => (value || []).includes(p)); + + return html` + <div class="settings-root-row"> + <div class="settings-root-row-title"> + <${Icon} name="image" /> + <h4>${t('settings_node.photo_roots_title')}</h4> + </div> + <p class="settings-hint">${t('settings_node.photo_roots_hint')}</p> + ${draft.length === 0 && html` + <p class="settings-hint">${t('settings_node.photo_roots_none')}</p> + `} + ${draft.length > 0 && html` + <ul class="settings-root-list"> + ${draft.map((p) => html` + <li key=${p} class="settings-root-list-item"> + <span>${' '.repeat(p.split('/').length - 1)}${p.split('/').pop()}</span> + <button class="link-btn" disabled=${busy} onClick=${() => removeRoot(p)} + title=${t('settings_node.photo_roots_remove')}> + <${Icon} name="close" /></button> + </li> + `)} + </ul> + `} + <div class="settings-row"> + <label class="settings-label"> + <select value=${addSelection} disabled=${busy || available.length === 0} + onChange=${(e) => setAddSelection(e.target.value)}> + <option value="">${t('settings_node.photo_roots_add_placeholder')}</option> + ${available.map((p) => html` + <option key=${p} value=${p}> + ${' '.repeat(p.split('/').length - 1)}${p.split('/').pop()} + </option> + `)} + </select> + </label> + <button class="btn btn-small btn-secondary" disabled=${busy || !addSelection} + onClick=${addRoot}>${t('settings_node.photo_roots_add')}</button> + </div> + <button class="btn btn-small btn-secondary" style="margin-top:8px" + disabled=${busy || unchanged} onClick=${() => onSave(draft)}> + ${busy ? t('settings_node.scan_saving') : t('settings_node.photo_roots_save')} + </button> + ${msg && html`<p class=${msg.ok ? 'success-msg' : 'error-msg'} style="margin-top:8px"> + ${msg.text}</p>`} + </div> + `; +} + // ── Members Panel ──────────────────────────────────────────────────────── /** @@ -120,7 +193,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, tmdbConfig, onTmdbConfig, onTmdbEnabled, musicbrainzConfig, onMusicbrainzConfig, onMusicbrainzEnabled, entries, nodeDirs, videoRoot, onVideoRoot, - audioRoot, onAudioRoot, onRefreshIndex, + audioRoot, onAudioRoot, + photoRoots, onPhotoRoots, onRefreshIndex, onPaired, onLeft }) { const [members, setMembers] = useState([]); const [adminId, setAdminId] = useState(''); @@ -636,6 +710,36 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } }, [transportRef, onAudioRoot, audioRootDraft, audioRoot]); + // Photos app's own entry points — a set (docs/photos.md §2.1), unlike + // videoRoot/audioRoot above. No "removing a root is destructive" confirm + // dialog: 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. + const [photoRootsBusy, setPhotoRootsBusy] = useState(false); + const [photoRootsMsg, setPhotoRootsMsg] = useState(null); + + const savePhotoRoots = useCallback(async (nextRoots) => { + const transport = transportRef && transportRef.current; + setPhotoRootsMsg(null); + setPhotoRootsBusy(true); + try { + if (!transport || !transport.connected) { + throw new Error('Not connected to the node'); + } + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.setPhotoRoots(nextRoots, signFn); + if (onPhotoRoots) onPhotoRoots(nextRoots); + setPhotoRootsMsg({ text: t('settings_node.scan_saved'), ok: true }); + } catch (err) { + setPhotoRootsMsg({ text: err.message, ok: false }); + } finally { + setPhotoRootsBusy(false); + } + }, [transportRef, onPhotoRoots]); + const [removing, setRemoving] = useState(''); /** @@ -957,7 +1061,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, this group at all (daemon.py's _enrich_new_video_entries). */ isNodeAdmin && connected && ((nodeDetected && nodeRoots.length > 0) - || activeApps.includes('video') || activeApps.includes('music')) && html` + || activeApps.includes('video') || activeApps.includes('music') + || activeApps.includes('photo')) && html` <${CollapsibleSection} titleKey="settings_node.directories_title"> <p class="settings-hint">${t('settings_node.directories_hint')}</p> @@ -977,6 +1082,11 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, busy=${audioRootBusy} msg=${audioRootMsg} onSave=${saveAudioRoot} noneKey="settings_node.audio_root_none" saveKey="settings_node.audio_root_save" /> `} + ${activeApps.includes('photo') && html` + <${PhotoRootsRow} + folders=${rootFolderOptions} value=${photoRoots} + busy=${photoRootsBusy} msg=${photoRootsMsg} onSave=${savePhotoRoots} /> + `} ${/* Roots management (Electron-only, when node is local) — folded into the same Directories section as the two root pickers above. */ nodeDetected && nodeRoots.length > 0 && html` diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js index 0ecbd70..029c669 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js @@ -82,6 +82,15 @@ const ICON_PATHS = { 'M7 23l-4-4 4-4', 'M21 13v2a4 4 0 0 1-4 4H3'], volume: ['M11 5L6 9H2v6h4l5 4z', 'M15.54 8.46a5 5 0 0 1 0 7.07', 'M19.07 4.93a10 10 0 0 1 0 14.14'], + image: ['M5 3.5h14a1.5 1.5 0 0 1 1.5 1.5v14a1.5 1.5 0 0 1-1.5 1.5H5a1.5 1.5 0 0 1-1.5-1.5V5a1.5 1.5 0 0 1 1.5-1.5z', + 'M7 9.5a1.5 1.5 0 1 0 3 0 1.5 1.5 0 0 0-3 0', + 'M20.5 15l-5-5-9.5 9.5'], + 'zoom-in': ['M11 4.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13', 'M15.8 15.8L20.5 20.5', + 'M11 8v6', 'M8 11h6'], + 'zoom-out': ['M11 4.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13', 'M15.8 15.8L20.5 20.5', + 'M8 11h6'], + frame: ['M4 9V5a1 1 0 0 1 1-1h4', 'M15 4h4a1 1 0 0 1 1 1v4', + 'M20 15v4a1 1 0 0 1-1 1h-4', 'M9 20H5a1 1 0 0 1-1-1v-4'], }; // The M of the wordmark is a picture; the rest is text. Resolved from this diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 83b5c1f..1e367fb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -78,6 +78,7 @@ export default { 'group.tab_chat': 'Chat', 'group.tab_video': 'Videos', 'group.tab_music': 'Musik', + 'group.tab_photos': 'Fotos', 'group.tab_members': 'Mitglieder', 'group.tab_settings': "Einstellungen", 'members.danger_leave_hint': "Sie verlieren den Zugriff auf die Dateien und den Chat dieser Gruppe.", @@ -205,6 +206,24 @@ export default { 'music.player_queue': 'Aktuelle Wiedergabeliste', 'music.queue_title': 'Wird wiedergegeben', + // Photos + 'photo.empty': 'Keine Fotos gefunden.', + 'photo.no_roots_configured': 'Für diese Gruppe sind noch keine Fotos-Stammordner festgelegt — ein Operator kann welche in den Einstellungen auswählen.', + 'photo.root_album': '(oberste Ebene)', + 'photo.n_photos': { + one: '{n} Foto', + other: '{n} Fotos', + }, + 'photo.back': 'Zurück', + 'photo.prev': 'Vorherige (←)', + 'photo.next': 'Nächste (→)', + 'photo.zip_album': 'Album als ZIP herunterladen', + 'photo.zoom_in': 'Vergrößern', + 'photo.zoom_out': 'Verkleinern', + 'photo.zoom_fit_title': 'An Fenster anpassen', + 'photo.zoom_fit_label': 'Anpassen', + 'photo.zoom_100': 'Originalgröße (100 %)', + // LAN-Cast 'cast.start': 'Auf Gerät übertragen', 'cast.stop': 'Übertragung beenden', @@ -651,8 +670,15 @@ export default { 'settings_node.audio_root_none': '— keiner ausgewählt —', 'settings_node.audio_root_save': 'Speichern', 'settings_node.audio_root_change_confirm': 'Das Ändern des Musik-Stammordners ersetzt, was jedes Mitglied im Musik-Tab sieht. Fortfahren?', + 'settings_node.photo_roots_title': 'Fotos-Stammordner', + 'settings_node.photo_roots_hint': 'Welche Ordner die Fotos-App als Einstiegspunkte für diese Gruppe nutzt — eine Fotobibliothek ist oft auf mehrere Ordner verteilt, daher können mehrere ausgewählt werden. In Fotos wird nichts angezeigt, bis mindestens einer hinzugefügt wurde.', + 'settings_node.photo_roots_none': '— keiner ausgewählt —', + 'settings_node.photo_roots_add_placeholder': 'Ordner hinzufügen…', + 'settings_node.photo_roots_add': 'Hinzufügen', + 'settings_node.photo_roots_remove': 'Entfernen', + 'settings_node.photo_roots_save': 'Speichern', 'settings_node.directories_title': 'Verzeichnisse', - 'settings_node.directories_hint': 'Freigegebene Ordner und welchen davon die Videos- und Musik-Apps als eigenen Einstiegspunkt nutzen.', + 'settings_node.directories_hint': 'Freigegebene Ordner und welchen davon die Videos-, Musik- und Fotos-Apps als eigene(n) Einstiegspunkt(e) nutzen.', // Create-group wizard 'wizard.title': 'Gruppe erstellen', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 1101eb2..5aedd7a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -79,6 +79,7 @@ export default { 'group.tab_chat': 'Chat', 'group.tab_video': 'Videos', 'group.tab_music': 'Music', + 'group.tab_photos': 'Photos', 'group.tab_members': 'Members', 'group.tab_settings': "Settings", 'members.danger_leave_hint': "You will lose access to this group's files and chat.", @@ -203,6 +204,24 @@ export default { 'music.player_queue': 'Current queue', 'music.queue_title': 'Playing now', + // Photos + 'photo.empty': 'No photos found.', + 'photo.no_roots_configured': 'No Photos root folders are set for this group yet — an operator can choose some in Settings.', + 'photo.root_album': '(top level)', + 'photo.n_photos': { + one: '{n} photo', + other: '{n} photos', + }, + 'photo.back': 'Back', + 'photo.prev': 'Previous (←)', + 'photo.next': 'Next (→)', + 'photo.zip_album': 'Download album as zip', + 'photo.zoom_in': 'Zoom in', + 'photo.zoom_out': 'Zoom out', + 'photo.zoom_fit_title': 'Fit to window', + 'photo.zoom_fit_label': 'Fit', + 'photo.zoom_100': 'Actual size (100%)', + // LAN cast 'cast.start': 'Cast to device', 'cast.stop': 'Stop casting', @@ -476,8 +495,15 @@ export default { 'settings_node.audio_root_none': '— none chosen —', 'settings_node.audio_root_save': 'Save', 'settings_node.audio_root_change_confirm': 'Changing the Music root replaces what every member sees in the Music tab. Continue?', + 'settings_node.photo_roots_title': 'Photos root folders', + 'settings_node.photo_roots_hint': 'Which folder(s) the Photos app treats as entry points for this group — a photo library is often scattered across several folders, so more than one may be chosen. Nothing shows in Photos until at least one is added.', + 'settings_node.photo_roots_none': '— none chosen —', + 'settings_node.photo_roots_add_placeholder': 'Add a folder…', + 'settings_node.photo_roots_add': 'Add', + 'settings_node.photo_roots_remove': 'Remove', + 'settings_node.photo_roots_save': 'Save', 'settings_node.directories_title': 'Directories', - 'settings_node.directories_hint': 'Shared folders, and which of them the Videos and Music apps use as their own entry point.', + 'settings_node.directories_hint': 'Shared folders, and which of them the Videos, Music and Photos apps use as their own entry point(s).', // Members 'members.col_role': 'Role', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index d9fd76f..46a4638 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -76,6 +76,7 @@ export default { 'group.tab_chat': 'Chat', 'group.tab_video': 'Vídeos', 'group.tab_music': 'Música', + 'group.tab_photos': 'Fotos', 'group.tab_members': 'Miembros', 'group.tab_settings': "Ajustes", 'members.danger_leave_hint': "Perderá el acceso a los archivos y al chat de este grupo.", @@ -203,6 +204,24 @@ export default { 'music.player_queue': 'Cola actual', 'music.queue_title': 'Reproduciendo ahora', + // Photos + 'photo.empty': 'No se encontraron fotos.', + 'photo.no_roots_configured': 'Aún no se han definido carpetas raíz de Fotos para este grupo — un operador puede elegir algunas en Configuración.', + 'photo.root_album': '(nivel superior)', + 'photo.n_photos': { + one: '{n} foto', + other: '{n} fotos', + }, + 'photo.back': 'Atrás', + 'photo.prev': 'Anterior (←)', + 'photo.next': 'Siguiente (→)', + 'photo.zip_album': 'Descargar álbum como zip', + 'photo.zoom_in': 'Acercar', + 'photo.zoom_out': 'Alejar', + 'photo.zoom_fit_title': 'Ajustar a la ventana', + 'photo.zoom_fit_label': 'Ajustar', + 'photo.zoom_100': 'Tamaño real (100%)', + // LAN cast 'cast.start': 'Enviar a dispositivo', 'cast.stop': 'Detener envío', @@ -646,8 +665,15 @@ export default { 'settings_node.audio_root_none': '— ninguna elegida —', 'settings_node.audio_root_save': 'Guardar', 'settings_node.audio_root_change_confirm': 'Cambiar la raíz de Música reemplaza lo que ve cada miembro en la pestaña Música. ¿Continuar?', + 'settings_node.photo_roots_title': 'Carpetas raíz de Fotos', + 'settings_node.photo_roots_hint': 'Qué carpeta(s) trata la app Fotos como puntos de entrada para este grupo — una fototeca suele estar repartida en varias carpetas, así que se pueden elegir varias. No se muestra nada en Fotos hasta que se añada al menos una.', + 'settings_node.photo_roots_none': '— ninguna elegida —', + 'settings_node.photo_roots_add_placeholder': 'Añadir una carpeta…', + 'settings_node.photo_roots_add': 'Añadir', + 'settings_node.photo_roots_remove': 'Quitar', + 'settings_node.photo_roots_save': 'Guardar', 'settings_node.directories_title': 'Directorios', - 'settings_node.directories_hint': 'Carpetas compartidas, y cuál de ellas usan las apps de Vídeos y Música como su propio punto de entrada.', + 'settings_node.directories_hint': 'Carpetas compartidas, y cuál de ellas usan las apps de Vídeos, Música y Fotos como su(s) propio(s) punto(s) de entrada.', // Create group wizard 'wizard.title': 'Crear grupo', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 7e0b13f..5cd8894 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -77,6 +77,7 @@ export default { 'group.tab_chat': 'Discussion', 'group.tab_video': 'Vidéos', 'group.tab_music': 'Musique', + 'group.tab_photos': 'Photos', 'group.tab_members': 'Membres', 'group.tab_settings': "Paramètres", 'members.danger_leave_hint': "Vous perdrez l’accès aux fichiers et à la discussion de ce groupe.", @@ -204,6 +205,24 @@ export default { 'music.player_queue': 'File en cours', 'music.queue_title': 'En cours de lecture', + // Photos + 'photo.empty': 'Aucune photo trouvée.', + 'photo.no_roots_configured': "Aucun dossier racine des Photos n'est encore défini pour ce groupe — un opérateur peut en choisir dans les Paramètres.", + 'photo.root_album': '(niveau racine)', + 'photo.n_photos': { + one: '{n} photo', + other: '{n} photos', + }, + 'photo.back': 'Retour', + 'photo.prev': 'Précédent (←)', + 'photo.next': 'Suivant (→)', + 'photo.zip_album': "Télécharger l'album en zip", + 'photo.zoom_in': 'Zoomer', + 'photo.zoom_out': 'Dézoomer', + 'photo.zoom_fit_title': 'Ajuster à la fenêtre', + 'photo.zoom_fit_label': 'Ajusté', + 'photo.zoom_100': 'Taille réelle (100 %)', + // LAN cast 'cast.start': 'Diffuser sur un appareil', 'cast.stop': 'Arrêter la diffusion', @@ -662,8 +681,15 @@ export default { 'settings_node.audio_root_none': '— aucun choisi —', 'settings_node.audio_root_save': 'Enregistrer', 'settings_node.audio_root_change_confirm': 'Changer la racine de la Musique remplace ce que chaque membre voit dans l\'onglet Musique. Continuer ?', + 'settings_node.photo_roots_title': 'Dossiers racines des Photos', + 'settings_node.photo_roots_hint': 'Quel(s) dossier(s) l\'application Photos traite comme points d\'entrée pour ce groupe — une photothèque est souvent répartie sur plusieurs dossiers, donc plusieurs peuvent être choisis. Rien ne s\'affiche dans Photos tant qu\'aucun n\'est ajouté.', + 'settings_node.photo_roots_none': '— aucun choisi —', + 'settings_node.photo_roots_add_placeholder': 'Ajouter un dossier…', + 'settings_node.photo_roots_add': 'Ajouter', + 'settings_node.photo_roots_remove': 'Retirer', + 'settings_node.photo_roots_save': 'Enregistrer', 'settings_node.directories_title': 'Répertoires', - 'settings_node.directories_hint': 'Dossiers partagés, et lequel d\'entre eux les applications Vidéos et Musique utilisent comme leur propre point d\'entrée.', + 'settings_node.directories_hint': 'Dossiers partagés, et lequel d\'entre eux les applications Vidéos, Musique et Photos utilisent comme leur(s) propre(s) point(s) d\'entrée.', // Create group wizard 'wizard.title': 'Créer un groupe', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 7d2fada..26e4e8f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -77,6 +77,7 @@ export default { 'group.tab_chat': 'Chat', 'group.tab_video': 'Video', 'group.tab_music': 'Musica', + 'group.tab_photos': 'Foto', 'group.tab_members': 'Membri', 'group.tab_settings': "Impostazioni", 'members.danger_leave_hint': "Perderai l’accesso ai file e alla chat di questo gruppo.", @@ -204,6 +205,24 @@ export default { 'music.player_queue': 'Coda attuale', 'music.queue_title': 'In riproduzione', + // Photos + 'photo.empty': 'Nessuna foto trovata.', + 'photo.no_roots_configured': 'Per questo gruppo non sono ancora impostate cartelle radice di Foto — un operatore può sceglierne alcune nelle Impostazioni.', + 'photo.root_album': '(livello superiore)', + 'photo.n_photos': { + one: '{n} foto', + other: '{n} foto', + }, + 'photo.back': 'Indietro', + 'photo.prev': 'Precedente (←)', + 'photo.next': 'Successiva (→)', + 'photo.zip_album': "Scarica l'album come zip", + 'photo.zoom_in': 'Ingrandisci', + 'photo.zoom_out': 'Riduci', + 'photo.zoom_fit_title': 'Adatta alla finestra', + 'photo.zoom_fit_label': 'Adatta', + 'photo.zoom_100': 'Dimensione reale (100%)', + // LAN cast 'cast.start': 'Trasmetti al dispositivo', 'cast.stop': 'Interrompi trasmissione', @@ -660,8 +679,15 @@ export default { 'settings_node.audio_root_none': '— nessuna scelta —', 'settings_node.audio_root_save': 'Salva', 'settings_node.audio_root_change_confirm': 'Cambiare la radice di Musica sostituisce ciò che ogni membro vede nella scheda Musica. Continuare?', + 'settings_node.photo_roots_title': 'Cartelle radice di Foto', + 'settings_node.photo_roots_hint': "Quali cartelle l'app Foto considera come punti di ingresso per questo gruppo — una libreria fotografica è spesso distribuita su più cartelle, quindi se ne possono scegliere diverse. In Foto non viene mostrato nulla finché non ne viene aggiunta almeno una.", + 'settings_node.photo_roots_none': '— nessuna scelta —', + 'settings_node.photo_roots_add_placeholder': 'Aggiungi una cartella…', + 'settings_node.photo_roots_add': 'Aggiungi', + 'settings_node.photo_roots_remove': 'Rimuovi', + 'settings_node.photo_roots_save': 'Salva', 'settings_node.directories_title': 'Directory', - 'settings_node.directories_hint': 'Cartelle condivise, e quale di esse le app Video e Musica usano come proprio punto di ingresso.', + 'settings_node.directories_hint': 'Cartelle condivise, e quale di esse le app Video, Musica e Foto usano come proprio/i punto/i di ingresso.', // Create-group wizard 'wizard.title': 'Crea gruppo', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 38fb19b..903a902 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -75,6 +75,7 @@ export default { 'group.tab_chat': 'チャット', 'group.tab_video': '動画', 'group.tab_music': '音楽', + 'group.tab_photos': '写真', 'group.tab_members': 'メンバー', 'group.tab_settings': "設定", 'members.danger_leave_hint': "このグループのファイルとチャットにアクセスできなくなります。", @@ -201,6 +202,24 @@ export default { 'music.player_queue': '再生中のキュー', 'music.queue_title': '再生中', + // Photos + 'photo.empty': '写真が見つかりません。', + 'photo.no_roots_configured': 'このグループにはまだ写真のルートフォルダが設定されていません — 操作者が設定画面でいくつか選択できます。', + 'photo.root_album': '(トップレベル)', + 'photo.n_photos': { + one: '{n}枚', + other: '{n}枚', + }, + 'photo.back': '戻る', + 'photo.prev': '前へ(←)', + 'photo.next': '次へ(→)', + 'photo.zip_album': 'アルバムをzipでダウンロード', + 'photo.zoom_in': '拡大', + 'photo.zoom_out': '縮小', + 'photo.zoom_fit_title': 'ウィンドウに合わせる', + 'photo.zoom_fit_label': '合わせる', + 'photo.zoom_100': '実寸(100%)', + // LAN cast 'cast.start': 'デバイスにキャスト', 'cast.stop': 'キャストを停止', @@ -644,8 +663,15 @@ export default { 'settings_node.audio_root_none': '— 未選択 —', 'settings_node.audio_root_save': '保存', 'settings_node.audio_root_change_confirm': '音楽のルートフォルダを変更すると、全メンバーの音楽タブの表示内容が変わります。続行しますか?', + 'settings_node.photo_roots_title': '写真のルートフォルダ', + 'settings_node.photo_roots_hint': 'このグループで写真アプリの起点とするフォルダです。写真ライブラリは複数のフォルダに分かれていることが多いため、複数選択できます。少なくとも1つ追加されるまで、写真には何も表示されません。', + 'settings_node.photo_roots_none': '— 未選択 —', + 'settings_node.photo_roots_add_placeholder': 'フォルダを追加…', + 'settings_node.photo_roots_add': '追加', + 'settings_node.photo_roots_remove': '削除', + 'settings_node.photo_roots_save': '保存', 'settings_node.directories_title': 'ディレクトリ', - 'settings_node.directories_hint': '共有フォルダと、動画アプリ・音楽アプリがそれぞれの起点として使用するフォルダです。', + 'settings_node.directories_hint': '共有フォルダと、動画・音楽・写真の各アプリがそれぞれの起点として使用するフォルダです。', // Wizard 'wizard.title': 'グループを作成', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index fdfcb89..217aead 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -78,6 +78,7 @@ export default { 'group.tab_chat': 'Chat', 'group.tab_video': "Video's", 'group.tab_music': 'Muziek', + 'group.tab_photos': "Foto's", 'group.tab_members': 'Leden', 'group.tab_settings': "Instellingen", 'members.danger_leave_hint': "U verliest de toegang tot de bestanden en de chat van deze groep.", @@ -205,6 +206,24 @@ export default { 'music.player_queue': 'Huidige wachtrij', 'music.queue_title': 'Nu aan het afspelen', + // Photos + 'photo.empty': "Geen foto's gevonden.", + 'photo.no_roots_configured': "Er zijn nog geen hoofdmappen voor Foto's ingesteld voor deze groep — een operator kan er enkele kiezen bij Instellingen.", + 'photo.root_album': '(hoofdniveau)', + 'photo.n_photos': { + one: '{n} foto', + other: "{n} foto's", + }, + 'photo.back': 'Terug', + 'photo.prev': 'Vorige (←)', + 'photo.next': 'Volgende (→)', + 'photo.zip_album': 'Album downloaden als zip', + 'photo.zoom_in': 'Inzoomen', + 'photo.zoom_out': 'Uitzoomen', + 'photo.zoom_fit_title': 'Passend maken', + 'photo.zoom_fit_label': 'Passend', + 'photo.zoom_100': 'Werkelijke grootte (100%)', + // LAN cast 'cast.start': 'Naar apparaat casten', 'cast.stop': 'Casten stoppen', @@ -662,8 +681,15 @@ export default { 'settings_node.audio_root_none': '— geen gekozen —', 'settings_node.audio_root_save': 'Opslaan', 'settings_node.audio_root_change_confirm': 'Het wijzigen van de hoofdmap voor Muziek vervangt wat elk lid ziet in het tabblad Muziek. Doorgaan?', + 'settings_node.photo_roots_title': "Hoofdmappen voor Foto's", + 'settings_node.photo_roots_hint': "Welke map(pen) de Foto's-app als startpunt gebruikt voor deze groep — een fotobibliotheek is vaak over meerdere mappen verspreid, dus er kunnen er meerdere gekozen worden. Er wordt niets getoond in Foto's totdat er minstens één is toegevoegd.", + 'settings_node.photo_roots_none': '— geen gekozen —', + 'settings_node.photo_roots_add_placeholder': 'Map toevoegen…', + 'settings_node.photo_roots_add': 'Toevoegen', + 'settings_node.photo_roots_remove': 'Verwijderen', + 'settings_node.photo_roots_save': 'Opslaan', 'settings_node.directories_title': 'Mappen', - 'settings_node.directories_hint': 'Gedeelde mappen, en welke daarvan de Video\'s- en Muziek-apps als eigen startpunt gebruiken.', + 'settings_node.directories_hint': 'Gedeelde mappen, en welke daarvan de Video\'s-, Muziek- en Foto\'s-apps als eigen startpunt(en) gebruiken.', // Create group wizard 'wizard.title': 'Groep aanmaken', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 8e7f636..25d67f9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -82,6 +82,7 @@ export default { 'group.tab_chat': 'Czat', 'group.tab_video': 'Wideo', 'group.tab_music': 'Muzyka', + 'group.tab_photos': 'Zdjęcia', 'group.tab_members': 'Członkowie', 'group.tab_settings': "Ustawienia", 'members.danger_leave_hint': "Utracisz dostęp do plików i czatu tej grupy.", @@ -214,6 +215,26 @@ export default { 'music.player_queue': 'Aktualna kolejka', 'music.queue_title': 'Teraz odtwarzane', + // Photos + 'photo.empty': 'Nie znaleziono żadnych zdjęć.', + 'photo.no_roots_configured': 'Dla tej grupy nie wybrano jeszcze katalogów głównych Zdjęć — operator może wybrać kilka w Ustawieniach.', + 'photo.root_album': '(poziom główny)', + 'photo.n_photos': { + one: '{n} zdjęcie', + few: '{n} zdjęcia', + many: '{n} zdjęć', + other: '{n} zdjęcia', + }, + 'photo.back': 'Wstecz', + 'photo.prev': 'Poprzednie (←)', + 'photo.next': 'Następne (→)', + 'photo.zip_album': 'Pobierz album jako zip', + 'photo.zoom_in': 'Powiększ', + 'photo.zoom_out': 'Pomniejsz', + 'photo.zoom_fit_title': 'Dopasuj do okna', + 'photo.zoom_fit_label': 'Dopasuj', + 'photo.zoom_100': 'Rzeczywisty rozmiar (100%)', + // LAN cast 'cast.start': 'Przesyłaj na urządzenie', 'cast.stop': 'Zatrzymaj przesyłanie', @@ -687,8 +708,15 @@ export default { 'settings_node.audio_root_none': '— nie wybrano —', 'settings_node.audio_root_save': 'Zapisz', 'settings_node.audio_root_change_confirm': 'Zmiana katalogu głównego Muzyki zastępuje to, co widzi każdy członek w karcie Muzyka. Kontynuować?', + 'settings_node.photo_roots_title': 'Katalogi główne Zdjęć', + 'settings_node.photo_roots_hint': 'Które katalogi aplikacja Zdjęcia traktuje jako punkty wejścia dla tej grupy — biblioteka zdjęć jest często rozproszona w wielu katalogach, więc można wybrać kilka. W Zdjęciach nic się nie wyświetla, dopóki nie zostanie dodany co najmniej jeden.', + 'settings_node.photo_roots_none': '— nie wybrano —', + 'settings_node.photo_roots_add_placeholder': 'Dodaj katalog…', + 'settings_node.photo_roots_add': 'Dodaj', + 'settings_node.photo_roots_remove': 'Usuń', + 'settings_node.photo_roots_save': 'Zapisz', 'settings_node.directories_title': 'Katalogi', - 'settings_node.directories_hint': 'Katalogi udostępnione oraz to, który z nich aplikacje Wideo i Muzyka traktują jako własny punkt wejścia.', + 'settings_node.directories_hint': 'Katalogi udostępnione oraz to, który z nich aplikacje Wideo, Muzyka i Zdjęcia traktują jako własny punkt (punkty) wejścia.', // Create-group wizard 'wizard.title': 'Utwórz grupę', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 97786f4..c32ff63 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -78,6 +78,7 @@ export default { 'group.tab_chat': 'Conversa', 'group.tab_video': 'Vídeos', 'group.tab_music': 'Música', + 'group.tab_photos': 'Fotos', 'group.tab_members': 'Membros', 'group.tab_settings': "Configurações", 'members.danger_leave_hint': "Você perderá o acesso aos arquivos e ao chat deste grupo.", @@ -205,6 +206,24 @@ export default { 'music.player_queue': 'Fila atual', 'music.queue_title': 'Tocando agora', + // Photos + 'photo.empty': 'Nenhuma foto encontrada.', + 'photo.no_roots_configured': 'Ainda não há pastas raiz de Fotos definidas para este grupo — um operador pode escolher algumas em Configurações.', + 'photo.root_album': '(nível superior)', + 'photo.n_photos': { + one: '{n} foto', + other: '{n} fotos', + }, + 'photo.back': 'Voltar', + 'photo.prev': 'Anterior (←)', + 'photo.next': 'Próxima (→)', + 'photo.zip_album': 'Baixar álbum como zip', + 'photo.zoom_in': 'Aumentar zoom', + 'photo.zoom_out': 'Diminuir zoom', + 'photo.zoom_fit_title': 'Ajustar à janela', + 'photo.zoom_fit_label': 'Ajustar', + 'photo.zoom_100': 'Tamanho real (100%)', + // LAN cast 'cast.start': 'Transmitir para dispositivo', 'cast.stop': 'Parar transmissão', @@ -647,8 +666,15 @@ export default { 'settings_node.audio_root_none': '— nenhuma escolhida —', 'settings_node.audio_root_save': 'Salvar', 'settings_node.audio_root_change_confirm': 'Alterar a raiz de Música substitui o que cada membro vê na aba Música. Continuar?', + 'settings_node.photo_roots_title': 'Pastas raiz de Fotos', + 'settings_node.photo_roots_hint': 'Quais pastas o app Fotos trata como pontos de entrada para este grupo — uma biblioteca de fotos costuma estar espalhada em várias pastas, então mais de uma pode ser escolhida. Nada é exibido em Fotos até que ao menos uma seja adicionada.', + 'settings_node.photo_roots_none': '— nenhuma escolhida —', + 'settings_node.photo_roots_add_placeholder': 'Adicionar uma pasta…', + 'settings_node.photo_roots_add': 'Adicionar', + 'settings_node.photo_roots_remove': 'Remover', + 'settings_node.photo_roots_save': 'Salvar', 'settings_node.directories_title': 'Diretórios', - 'settings_node.directories_hint': 'Pastas compartilhadas, e qual delas os apps Vídeos e Música tratam como seu próprio ponto de entrada.', + 'settings_node.directories_hint': 'Pastas compartilhadas, e qual delas os apps Vídeos, Música e Fotos tratam como seu(s) próprio(s) ponto(s) de entrada.', // Create group wizard 'wizard.title': 'Criar grupo', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index a26062d..ee0efdf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -75,6 +75,7 @@ export default { 'group.tab_chat': '聊天', 'group.tab_video': '视频', 'group.tab_music': '音乐', + 'group.tab_photos': '照片', 'group.tab_members': '成员', 'group.tab_settings': "设置", 'members.danger_leave_hint': "您将无法再访问该群组的文件和聊天。", @@ -198,6 +199,24 @@ export default { 'music.player_queue': '当前队列', 'music.queue_title': '正在播放', + // Photos + 'photo.empty': '未找到照片。', + 'photo.no_roots_configured': '此群组尚未设置照片根目录 — 操作员可以在设置中选择一些。', + 'photo.root_album': '(顶层)', + 'photo.n_photos': { + one: '{n} 张照片', + other: '{n} 张照片', + }, + 'photo.back': '返回', + 'photo.prev': '上一张(←)', + 'photo.next': '下一张(→)', + 'photo.zip_album': '将相册下载为 zip', + 'photo.zoom_in': '放大', + 'photo.zoom_out': '缩小', + 'photo.zoom_fit_title': '适应窗口', + 'photo.zoom_fit_label': '适应', + 'photo.zoom_100': '实际大小(100%)', + // LAN cast 'cast.start': '投射到设备', 'cast.stop': '停止投射', @@ -630,8 +649,15 @@ export default { 'settings_node.audio_root_none': '— 未选择 —', 'settings_node.audio_root_save': '保存', 'settings_node.audio_root_change_confirm': '更改音乐根目录会替换每位成员在“音乐”标签页中看到的内容。是否继续?', + 'settings_node.photo_roots_title': '照片根目录', + 'settings_node.photo_roots_hint': '该群组“照片”应用的入口文件夹 — 照片库通常分散在多个文件夹中,因此可以选择多个。在添加至少一个之前,“照片”中不会显示任何内容。', + 'settings_node.photo_roots_none': '— 未选择 —', + 'settings_node.photo_roots_add_placeholder': '添加文件夹…', + 'settings_node.photo_roots_add': '添加', + 'settings_node.photo_roots_remove': '移除', + 'settings_node.photo_roots_save': '保存', 'settings_node.directories_title': '目录', - 'settings_node.directories_hint': '共享文件夹,以及“视频”和“音乐”应用各自使用哪个作为入口。', + 'settings_node.directories_hint': '共享文件夹,以及“视频”“音乐”和“照片”应用各自使用哪个(些)作为入口。', // Create group wizard 'wizard.title': '创建群组', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js new file mode 100644 index 0000000..26785e7 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js @@ -0,0 +1,370 @@ +import { + html, useState, useEffect, useMemo, useCallback, useRef, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { + formatSize, CHUNK_SIZE, pipelinedDownload, downloadDirectory, +} from './file-utils.js'; +import { transfers } from './transfers.js'; +import { MediaThumb, LazyTile } from './video-app.js'; + +// ── Photos ─────────────────────────────────────────────────────────────────── +// +// docs/photos.md. Unlike Videos/Music: several root folders per group +// (photoRoots is a list, §2.1), one album-grid view with no mode toggle and +// no third-party matching step (§2.3), and per-photo info read from the +// file's own EXIF at index time rather than fetched live. Every directory +// containing at least one image under a configured root is one album card; +// opening one shows its photos in a grid with a lightbox (next/previous, +// keyboard arrows, EXIF info when present) and a "zip this album" button +// that reuses Files' own zip mechanism unchanged (file-utils.js's +// downloadDirectory, lifted out of files-app.js for exactly this reuse). + +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; + // e.path is already the file's containing directory, not the full + // path+filename (files-app.js's own convention, also relied on by + // zipstream.js's entriesUnder) — it must not be stripped a second time, + // or every album collapses one level up into its parent (found live: + // a "backup" root with several subfolders showed as a single "backup" + // album holding everything, because this line was extracting the + // dirname of a value that was already a dirname). + const dir = e.path || ''; + if (!byDir.has(dir)) byDir.set(dir, []); + byDir.get(dir).push(e); + } + return [...byDir.entries()] + .map(([dir, photos]) => ({ + dir, photos: photos.sort((a, b) => a.name.localeCompare(b.name)), + })) + .sort((a, b) => a.dir.localeCompare(b.dir)); +} + +// Underscores replaced with spaces for display only — this never touches +// the folder on disk or anything sent to the node, purely how the name +// reads in the grid/heading (a raw "mariage_joce" reads worse than +// "mariage joce" for something meant to look like an album, not a filename). +function albumTitle(dir) { + return dir ? dir.split('/').pop().replace(/_/g, ' ') : t('photo.root_album'); +} + +function formatTakenAt(ts) { + if (!ts) return ''; + return new Date(ts * 1000).toLocaleString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', + }); +} + +// A single year when every dated photo in the album agrees, a range when +// they don't (an album spanning New Year's Eve, or just a loosely-sorted +// folder) — never guessed for photos with no EXIF date at all, which just +// don't count toward it. +function albumYearLabel(photos) { + const years = [...new Set( + photos.filter((p) => p.taken_at).map((p) => new Date(p.taken_at * 1000).getFullYear()), + )].sort((a, b) => a - b); + if (years.length === 0) return ''; + if (years.length === 1) return String(years[0]); + return `${years[0]}–${years[years.length - 1]}`; +} + +// ── landing grid: one card per album (directory containing images) ───────── + +function AlbumCard({ album, transportRef, gekRef, onOpen }) { + // A photo whose own thumbnail is already ready, over blindly photos[0] — + // that specific file's enrichment may not have finished yet even though + // a sibling's has (same fallback video-app.js's PosterGrid already uses + // picking a show's representative episode). + const cover = album.photos.find((p) => p.thumb_hash) || album.photos[0]; + const year = albumYearLabel(album.photos); + return html` + <div class="photo-album-card" onClick=${onOpen}> + <${MediaThumb} thumbHash=${cover.thumb_hash} alt=${albumTitle(album.dir)} + cls="photo-album-cover" transportRef=${transportRef} gekRef=${gekRef} + emptyIcon="image" /> + <div class="photo-album-info"> + <div class="photo-album-title">${albumTitle(album.dir)}</div> + <div class="photo-album-sub"> + ${year}${year ? ' · ' : ''}${t('photo.n_photos', { n: album.photos.length })} + </div> + </div> + </div> + `; +} + +function AlbumLanding({ albums, transportRef, gekRef, onOpen }) { + return html` + <div class="photo-album-grid"> + ${albums.map((a) => html` + <${LazyTile} key=${a.dir} cls="photo-album-tile-slot"> + <${AlbumCard} album=${a} transportRef=${transportRef} gekRef=${gekRef} + onOpen=${() => onOpen(a.dir)} /> + </${LazyTile}> + `)} + </div> + `; +} + +// ── open album: grid of its own photos ────────────────────────────────────── + +function PhotoTile({ entry, transportRef, gekRef, onOpen }) { + return html` + <div class="photo-tile" onClick=${onOpen}> + <${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name} + cls="photo-tile-thumb" transportRef=${transportRef} gekRef=${gekRef} + emptyIcon="image" /> + </div> + `; +} + +// ── lightbox: full image, next/previous, per-photo info, zoom ────────────── +// +// Cached per session by file id, same shape as video-app.js's +// _thumbBlobCache — clicking back and forth between two photos decrypts +// each once, not once per visit. +const _fullBlobCache = new Map(); + +// Zoom is only ever meaningful for the lightbox's own full-resolution +// image — nowhere else in the app shows one, so there is nothing to gate +// this behind beyond the component itself only ever being mounted for a +// photo. +const ZOOM_STEP = 25; +const ZOOM_MIN = 25; +const ZOOM_MAX = 400; + +function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { + const entry = photos[index]; + const [blobUrl, setBlobUrl] = useState(() => _fullBlobCache.get(entry.id) || null); + const [loading, setLoading] = useState(!_fullBlobCache.has(entry.id)); + // null = "fit to window" (the default, object-fit: contain); a number is + // an explicit percentage of the image's own natural size, read off the + // loaded <img> itself rather than trusted from EXIF — accurate whether or + // not enrichment ever ran, and already EXIF-orientation-corrected the + // same way the browser renders the <img> itself. + const [zoomPercent, setZoomPercent] = useState(null); + const [naturalSize, setNaturalSize] = useState(null); + const slotRef = useRef(null); + + useEffect(() => { + const cached = _fullBlobCache.get(entry.id); + if (cached) { setBlobUrl(cached); setLoading(false); return; } + setBlobUrl(null); + setLoading(true); + let cancelled = false; + (async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) { setLoading(false); return; } + try { + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + const chunks = await pipelinedDownload( + transport, gekRef.current, entry.id, totalChunks); + if (cancelled) return; + const url = URL.createObjectURL(new Blob(chunks)); + _fullBlobCache.set(entry.id, url); + setBlobUrl(url); + } catch { + /* leave the placeholder — a transient fetch failure isn't fatal, next/close still work */ + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [entry.id]); + + // Every photo opens fit-to-window, same as any other viewer — a zoom + // level chosen for one picture saying nothing about the next. + useEffect(() => { setZoomPercent(null); setNaturalSize(null); }, [entry.id]); + + useEffect(() => { + const onKey = (e) => { + if (e.key === 'Escape') onClose(); + else if (e.key === 'ArrowLeft' && index > 0) onNav(-1); + else if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(1); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose, onNav, index, photos.length]); + + const handleImgLoad = (e) => { + setNaturalSize({ w: e.target.naturalWidth, h: e.target.naturalHeight }); + }; + + // The percentage "fit to window" actually renders at, so the first zoom + // step moves from *there* rather than silently snapping to 100% first — + // object-fit: contain never upscales past the image's own natural size + // (nothing here sets width/height:100% to force it to), so fit is never + // above 100% either. + const fitPercent = () => { + if (!naturalSize || !slotRef.current) return 100; + const rect = slotRef.current.getBoundingClientRect(); + return Math.min(1, rect.width / naturalSize.w, rect.height / naturalSize.h) * 100; + }; + const zoomIn = () => setZoomPercent( + (z) => Math.min(ZOOM_MAX, Math.round(z ?? fitPercent()) + ZOOM_STEP)); + const zoomOut = () => setZoomPercent( + (z) => Math.max(ZOOM_MIN, Math.round(z ?? fitPercent()) - ZOOM_STEP)); + const zoomFit = () => setZoomPercent(null); + const zoomActual = () => setZoomPercent(100); + + const zoomed = zoomPercent != null; + const imgStyle = zoomed && naturalSize + ? `width:${Math.round(naturalSize.w * zoomPercent / 100)}px; ` + + `height:${Math.round(naturalSize.h * zoomPercent / 100)}px;` + : ''; + + return html` + <div class="video-overlay photo-lightbox" onClick=${(e) => { + if (e.target.classList.contains('photo-lightbox')) onClose(); + }}> + <div class="video-top-bar"> + <span class="video-title">${entry.name}</span> + <div class="photo-zoom-controls"> + <button class="photo-icon-btn" disabled=${!blobUrl || zoomPercent === ZOOM_MIN} + onClick=${zoomOut} title=${t('photo.zoom_out')}> + <${Icon} name="zoom-out" cls="photo-icon-btn-icon" /></button> + <span class="photo-zoom-percent"> + ${zoomed ? `${zoomPercent}%` : t('photo.zoom_fit_label')}</span> + <button class="photo-icon-btn" disabled=${!blobUrl || zoomPercent === ZOOM_MAX} + onClick=${zoomIn} title=${t('photo.zoom_in')}> + <${Icon} name="zoom-in" cls="photo-icon-btn-icon" /></button> + <button class="photo-icon-btn ${!zoomed ? 'active' : ''}" disabled=${!blobUrl} + onClick=${zoomFit} title=${t('photo.zoom_fit_title')}> + <${Icon} name="frame" cls="photo-icon-btn-icon" /></button> + <button class="photo-icon-btn photo-icon-btn-text ${zoomPercent === 100 ? 'active' : ''}" + disabled=${!blobUrl} onClick=${zoomActual} title=${t('photo.zoom_100')}>100%</button> + </div> + <button class="video-close" onClick=${onClose} title=${t('video.close')}> + <${Icon} name="close" /></button> + </div> + <div class="photo-lightbox-body"> + <button class="photo-nav photo-nav-prev" disabled=${index === 0} + onClick=${() => onNav(-1)} title=${t('photo.prev')}> + <${Icon} name="chevron" cls="photo-nav-icon photo-nav-prev-icon" /></button> + <div ref=${slotRef} class="photo-lightbox-image-slot ${zoomed ? 'zoomed' : ''}"> + ${loading && html`<span class="spinner"></span>`} + ${blobUrl && html`<img class="photo-lightbox-image ${zoomed ? 'zoomed' : ''}" + style=${imgStyle} src=${blobUrl} alt=${entry.name} onLoad=${handleImgLoad} />`} + </div> + <button class="photo-nav photo-nav-next" disabled=${index === photos.length - 1} + onClick=${() => onNav(1)} title=${t('photo.next')}> + <${Icon} name="chevron" cls="photo-nav-icon photo-nav-next-icon" /></button> + </div> + <div class="photo-lightbox-info"> + ${entry.width && entry.height && html`<span>${entry.width}×${entry.height}</span>`} + <span>${formatSize(entry.size)}</span> + ${entry.taken_at && html`<span>${formatTakenAt(entry.taken_at)}</span>`} + ${entry.camera && html`<span>${entry.camera}</span>`} + <span class="photo-lightbox-count">${index + 1} / ${photos.length}</span> + </div> + </div> + `; +} + +function AlbumView({ album, entries, transportRef, gekRef, setError, onBack }) { + const [lightboxIndex, setLightboxIndex] = useState(null); + + const zip = useCallback(async () => { + const transport = transportRef.current; + await downloadDirectory( + transfers, transport, gekRef.current, entries, album.dir, { setError }); + }, [entries, album.dir]); + + const navigate = useCallback((delta) => { + setLightboxIndex((i) => { + const next = i + delta; + return next >= 0 && next < album.photos.length ? next : i; + }); + }, [album.photos.length]); + + const year = albumYearLabel(album.photos); + + return html` + <div class="photo-album-bar"> + <div class="photo-album-heading"> + <button class="photo-icon-btn" onClick=${onBack} title=${t('photo.back')}> + <${Icon} name="chevron" cls="photo-icon-btn-icon photo-back-icon" /></button> + <div class="photo-album-heading-text"> + <span class="photo-album-heading-title">${albumTitle(album.dir)}</span> + ${year && html`<span class="photo-album-heading-year">${year}</span>`} + </div> + </div> + <button class="photo-icon-btn" onClick=${zip} title=${t('photo.zip_album')}> + <${Icon} name="archive" cls="photo-icon-btn-icon" /></button> + </div> + <div class="photo-grid"> + ${album.photos.map((e, i) => html` + <${LazyTile} key=${e.id} cls="photo-tile-slot"> + <${PhotoTile} entry=${e} transportRef=${transportRef} gekRef=${gekRef} + onOpen=${() => setLightboxIndex(i)} /> + </${LazyTile}> + `)} + </div> + ${lightboxIndex !== null && html` + <${Lightbox} photos=${album.photos} index=${lightboxIndex} + transportRef=${transportRef} gekRef=${gekRef} + onClose=${() => setLightboxIndex(null)} onNav=${navigate} /> + `} + `; +} + +// ── shell ──────────────────────────────────────────────────────────────────── + +function PhotosApp({ + groupId, transportRef, gekRef, status, entries, photoRoots, setError, +}) { + const [openDir, setOpenDir] = useState(null); + const [filter, setFilter] = useState(''); + + useEffect(() => { setOpenDir(null); setFilter(''); }, [groupId]); + + const albums = useMemo( + () => groupPhotoAlbums(entries, photoRoots), [entries, photoRoots]); + + const needle = filter.trim().toLowerCase(); + const filteredAlbums = useMemo(() => (!needle ? albums : albums.filter( + (a) => albumTitle(a.dir).toLowerCase().includes(needle))), [albums, needle]); + + const openAlbum = openDir != null ? albums.find((a) => a.dir === openDir) : null; + + return html` + ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html` + <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p> + `} + ${status === 'offline' && html` + <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p> + `} + ${status === 'connected' && (!photoRoots || photoRoots.length === 0) && html` + <p class="page-message">${t('photo.no_roots_configured')}</p> + `} + ${status === 'connected' && photoRoots && photoRoots.length > 0 && !openAlbum && html` + <div class="photo-toolbar"> + <div class="tb-search"> + <${Icon} name="search" /> + <input type="text" placeholder="${t('group.filter')}" + value=${filter} onInput=${(e) => setFilter(e.target.value)} /> + </div> + </div> + ${filteredAlbums.length === 0 && html` + <p class="page-message">${needle ? t('group.empty_filter') : t('photo.empty')}</p> + `} + <${AlbumLanding} albums=${filteredAlbums} + transportRef=${transportRef} gekRef=${gekRef} + onOpen=${(dir) => setOpenDir(dir)} /> + `} + ${status === 'connected' && openAlbum && html` + <${AlbumView} album=${openAlbum} entries=${entries} + transportRef=${transportRef} gekRef=${gekRef} setError=${setError} + onBack=${() => setOpenDir(null)} /> + `} + `; +} + +export { PhotosApp }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index b332f6f..9a4fb5a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -1125,6 +1125,20 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } margin: 0; } +/* Photos app's root add/remove list (docs/photos.md §2.2) — a set, unlike + the Videos/Music single-value picker above. */ +.settings-root-list { list-style: none; margin: 4px 0 8px; padding: 0; } +.settings-root-list-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 0; + font-size: 0.88em; +} +.settings-root-list-item .link-btn { padding: 2px; } +.settings-root-list-item .icon { width: 14px; height: 14px; } + .settings-select { padding: 6px 10px; border: 1px solid var(--border); @@ -3098,3 +3112,266 @@ a.transfer-name { .music-player-seek { order: 4; flex-basis: 100%; } .music-player-volume { display: none; } } + +/* ── Photos app (photos-app.js, docs/photos.md) ───────────────────────────── */ + +.photo-toolbar { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 14px; +} +.photo-toolbar .tb-search { margin-left: auto; } + +/* Open-album title bar: name + year on the left, two big monochrome + icon-only actions (back, zip) on the right — same row, same size as a + group tab's own icon (.tab-icon, 22px) so they read as "app-level" + controls rather than small inline buttons. */ +.photo-album-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} +.photo-album-heading { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.photo-album-heading-text { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 0; +} +.photo-album-heading-title { + font-weight: 600; + font-size: 1.15em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.photo-album-heading-year { font-size: 0.85em; color: var(--text-dim); flex-shrink: 0; } + +.photo-icon-btn { + background: none; + border: none; + cursor: pointer; + color: var(--text-secondary); + padding: 7px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; +} +.photo-icon-btn:hover { color: var(--text); background: var(--bg-surface); } +.photo-icon-btn-icon { width: 22px; height: 22px; } + +/* .tb-btn's own chevron is drawn pointing down; rotated here to read as + "back" (left) without a second icon in icon.js. */ +.photo-back-icon { transform: rotate(90deg); } + +/* Landing view — one card per album (a directory containing images) */ + +.photo-album-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} +.photo-album-tile-slot { min-height: 190px; } + +.photo-album-card { + cursor: pointer; + border-radius: 8px; + overflow: hidden; + background: var(--bg-raised); + border: 1px solid var(--border); + transition: border-color 0.12s, transform 0.12s; +} +.photo-album-card:hover { border-color: var(--accent); transform: translateY(-2px); } + +.photo-album-cover { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; + background: var(--bg-surface); +} +/* MediaThumb (video-app.js) always adds its own .video-thumb-empty class + for the placeholder state, whatever `cls` is passed — that global rule + already centers the icon; only the square sizing above is Photos-specific. */ +.photo-album-cover.video-thumb-empty .icon, +.photo-tile-thumb.video-thumb-empty .icon { width: 24px; height: 24px; } + +.photo-album-info { padding: 8px 10px; } +.photo-album-title { + font-size: 0.86em; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.photo-album-sub { font-size: 0.75em; color: var(--text-dim); margin-top: 2px; } + +/* Open-album view — a grid of the album's own photos */ + +.photo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 8px; +} +.photo-tile-slot { min-height: 110px; } +.photo-tile { + cursor: pointer; + border-radius: 6px; + overflow: hidden; + background: var(--bg-raised); + border: 1px solid var(--border); +} +.photo-tile:hover { border-color: var(--accent); } +.photo-tile-thumb { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; + background: var(--bg-surface); +} + +/* Lightbox — sits inside the shared .video-overlay/.video-top-bar/.video-title/ + .video-close, same as music-app.js's detail modal reuses them. */ + +/* Scoped to .photo-lightbox specifically — .video-top-bar itself stays + transparent everywhere else that reuses it (VideoPlayer, the video/music + detail modals), where nothing zooms underneath it. */ +.photo-lightbox .video-top-bar { + background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), transparent); + padding-bottom: 24px; +} + +.photo-lightbox-body { + /* Bounded strictly between the top bar and the info strip, rather than + the full-viewport height it used to take — a zoomed-in image, larger + than the window, painted straight through that space (nothing ever + reserved it) and sat visually behind the zoom controls, which had only + a faint translucent highlight of their own to read against a bright, + busy photo. Positioning the image area below the bar's own real, + opaque strip (below) is what actually keeps them apart, not z-index — + the buttons were never behind the image in stacking order, just hard + to see in front of it. */ + position: absolute; + top: 60px; + bottom: 44px; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 0 8px; +} +.photo-nav { + background: rgba(255, 255, 255, 0.1); + border: none; + color: #e2e8f0; + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} +.photo-nav:hover { background: rgba(255, 255, 255, 0.22); } +.photo-nav:disabled { opacity: 0.3; cursor: default; } +.photo-nav-icon { width: 20px; height: 20px; } +.photo-nav-prev-icon { transform: rotate(90deg); } +.photo-nav-next-icon { transform: rotate(-90deg); } + +.photo-lightbox-image-slot { + flex: 1; + min-width: 0; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + /* A zoomed-in image is routinely larger than the viewport — this is what + makes it pannable instead of just clipped. Harmless when the image + fits (fit-to-window, or zoomed below 100%): no scrollbar appears. */ + overflow: auto; +} +/* flex centering (align-items/justify-content: center) plus overflow:auto + is a well-known trap: the browser centers overflowing content by + shifting it, but the "leading" half of that overflow — here, the top of + a zoomed-in photo — sits outside the range the scrollport actually + exposes, so it can never be scrolled into view at all. Reported live as + "unusable" (the top of the photo was gone, with no way to reach it). + Switching to top/left alignment for the zoomed state fixes exactly that: + the top-left corner is always where the image starts, and scrolling + down/right reaches the rest — the same convention most image viewers + use once you're past fit-to-window anyway. */ +.photo-lightbox-image-slot.zoomed { + align-items: flex-start; + justify-content: flex-start; +} +.photo-lightbox-image { + /* Relative to the now-properly-bounded slot (.photo-lightbox-body's + top/bottom insets), not a guessed viewport fraction — correct at any + window size without re-tuning a magic number. */ + max-width: 100%; + max-height: 100%; + object-fit: contain; +} +/* An explicit pixel size (photos-app.js's imgStyle) replaces the + fit-to-window constraints above — object-fit has nothing left to do + once both dimensions are set directly. */ +.photo-lightbox-image.zoomed { + max-width: none; + max-height: none; + object-fit: initial; + display: block; +} + +/* Zoom controls sit in the dark lightbox chrome (.video-top-bar), not the + app's light Settings/toolbar chrome — .photo-icon-btn's own colors + (meant for the light chrome elsewhere in Photos) are overridden here to + match .video-close's existing treatment instead. */ +.photo-zoom-controls { display: flex; align-items: center; gap: 2px; } +.photo-zoom-controls .photo-icon-btn { + color: #e2e8f0; + padding: 5px; + border-radius: 4px; +} +.photo-zoom-controls .photo-icon-btn:hover { background: rgba(255, 255, 255, 0.15); } +.photo-zoom-controls .photo-icon-btn.active { background: rgba(255, 255, 255, 0.18); } +.photo-zoom-controls .photo-icon-btn:disabled { opacity: 0.35; cursor: default; } +.photo-zoom-controls .photo-icon-btn:disabled:hover { background: none; } +.photo-zoom-controls .photo-icon-btn-icon { width: 18px; height: 18px; } +.photo-icon-btn-text { font-size: 0.72em; font-weight: 600; padding: 5px 8px; } +.photo-zoom-percent { + font-size: 0.75em; + color: #94a3b8; + min-width: 38px; + text-align: center; + flex-shrink: 0; +} + +.photo-lightbox-info { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + flex-wrap: wrap; + padding: 10px 20px; + color: #cbd5e1; + font-size: 0.78em; + background: linear-gradient(to top, rgba(0, 0, 0, 0.55), transparent); +} +.photo-lightbox-count { color: #94a3b8; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 7528a92..065ccc0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -74,6 +74,7 @@ function _aborted() { // one. const ADMIN_OP_TYPES = new Set([ 'tmdb_override', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root', + 'photo_roots', 'musicbrainz_config', 'musicbrainz_enabled', 'file_delete', 'dir_delete', 'member_upload', 'apps_enabled', 'set_scan_settings', 'member_revoke', 'root_add', 'root_remove', 'member_unpin', 'gek_rotate', 'group_attach', @@ -133,6 +134,7 @@ class MeshBayTransport { set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; } set onVideoRoot(fn) { this._onVideoRoot = fn; } set onAudioRoot(fn) { this._onAudioRoot = fn; } + set onPhotoRoots(fn) { this._onPhotoRoots = fn; } set onMusicbrainzConfig(fn) { this._onMusicbrainzConfig = fn; } set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; } set onIndexProgress(fn) { this._onIndexProgress = fn; } @@ -645,6 +647,27 @@ class MeshBayTransport { } /** + * Which folder(s) the Photos app treats as its entry points for this + * group (docs/photos.md §2.1). Unlike setVideoRoot/setAudioRoot, `roots` + * is a whole set, replaced in one signed op — same shape as + * setAppsEnabled. The client normalizes the same way the node does + * (webrtc_server.py's `_do_photo_roots`: trim slashes, drop empties, + * dedupe, sort) so the subject built here matches byte-for-byte what the + * node signs the challenge against. + */ + async setPhotoRoots(roots, signFn) { + const clean = [...new Set( + (roots || []).map((r) => (r || '').replace(/^\/+|\/+$/g, '')).filter(Boolean), + )].sort(); + const msg = await this._sendAndWait({ type: 'photo_roots', v: '0.11', roots: clean }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'photo_roots', clean.join(','), signFn); + } + return msg; + } + + /** * MusicBrainz metadata for one track's path (Music app, docs/musicbay.md * §4.3) — same shape as fetchMediaMeta, minus a season/episode concept: * album-level (release), resolved from the track's own artist/album @@ -1645,6 +1668,12 @@ class MeshBayTransport { this._onAudioRoot(msg.path || ''); } + // Same shape: the operator replaced the Photos app's whole root set + // for this group (docs/photos.md §2.1). + if (msg.type === 'photo_roots_ack' && this._onPhotoRoots) { + this._onPhotoRoots(msg.roots || []); + } + // Node-wide, like tmdb_config_ack above — no token equivalent to hide, // only whether a contact string is configured (docs/musicbay.md §3.2). if (msg.type === 'musicbrainz_config_ack' && this._onMusicbrainzConfig) { diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py index 80c3b05..32d3e11 100644 --- a/packages/meshbay-hub/tests/test_downloads.py +++ b/packages/meshbay-hub/tests/test_downloads.py @@ -145,9 +145,11 @@ def test_a_length_is_only_promised_when_it_is_known(tmp_path): src = SW.read_text() assert "if (entry.size > 0)" in src - # The zip-directory download is Files' own, moved to files-app.js in the - # group-page refactor. - app = (STATIC / "files-app.js").read_text() + # The zip-directory download started in files-app.js (group-page refactor) + # and was lifted into file-utils.js's downloadDirectory (docs/photos.md + # §3) so photos-app.js's own "zip this album" button calls the same + # implementation rather than a second one. + app = (STATIC / "file-utils.js").read_text() zip_call = app[app.index("const target = await _openDownloadTarget(suggested"):] zip_call = zip_call[:zip_call.index(");") + 2] assert zip_call.rstrip().endswith(", 0);"), ( diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py index 48a9376..2114b45 100644 --- a/packages/meshbay-hub/tests/test_hook_ordering.py +++ b/packages/meshbay-hub/tests/test_hook_ordering.py @@ -35,6 +35,7 @@ APP = STATIC / "app.js" STATIC_FILES = [ "app.js", "group-page.js", "chat-app.js", "files-app.js", "video-player.js", "video-app.js", "music-app.js", "music-player.js", + "photos-app.js", "group-settings.js", ] diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index 98ebd66..6c178a6 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -30,6 +30,7 @@ GROUP_PAGE = STATIC / "group-page.js" SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js", STATIC / "video-player.js", STATIC / "video-app.js", STATIC / "music-app.js", STATIC / "music-player.js", + STATIC / "photos-app.js", STATIC / "group-settings.js"] pytestmark = pytest.mark.skipif( @@ -119,6 +120,31 @@ def test_the_view_only_follows_new_messages_when_already_at_the_bottom(chat): "scrolling unconditionally fights someone reading back through history") +def test_every_authorize_admin_op_call_is_registered_in_admin_op_types(transport): + """ + Found live (docs/photos.md's photo_roots): `setPhotoRoots` called + `_authorizeAdminOp(msg, 'photo_roots', ...)` like every other admin op, + but `photo_roots` was never added to `ADMIN_OP_TYPES` — so its initial + request was never keyed `admin:photo_roots`, the node's `admin_challenge` + reply matched no pending request (_dispatch's own keyed block, which + `return`s unconditionally whether or not it found a match), and the + request sat until its 30 s timeout with no error and no admin prompt. + `ADMIN_OP_TYPES`'s own comment already narrates this exact bug once, + for `audio_root`/`apps_enabled` — this pins it so a third op cannot + reintroduce it silently. + """ + set_body = transport[transport.index("const ADMIN_OP_TYPES = new Set(["):] + set_body = set_body[:set_body.index("]);")] + registered = set(re.findall(r"'([a-z_]+)'", set_body)) + + called = set(re.findall(r"_authorizeAdminOp\(\s*\w+,\s*'([a-z_]+)'", transport)) + assert called, "the extraction pattern itself found nothing — check it against transport.js" + missing = called - registered + assert not missing, ( + f"{sorted(missing)} call _authorizeAdminOp but are missing from ADMIN_OP_TYPES — " + "their admin_challenge will silently time out instead of ever reaching the user") + + def test_the_bottom_is_reached_by_scrollTop_not_a_sentinel(chat): """scrollIntoView on a zero-height marker stops short of the true bottom. diff --git a/packages/meshbay-node/pyproject.toml b/packages/meshbay-node/pyproject.toml index f8747d2..ea36d2d 100644 --- a/packages/meshbay-node/pyproject.toml +++ b/packages/meshbay-node/pyproject.toml @@ -20,10 +20,15 @@ dependencies = [ "aiosqlite>=0.20", # async SQLite for chat, audit, bundle stores "guessit>=4.4", # filename parsing for the Videos app "mutagen>=1.47", # ID3/Vorbis tag + embedded cover reading for the Music app + "Pillow>=10", # thumbnail generation + EXIF reading for the Photos app ] [project.optional-dependencies] -dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.6"] +# piexif: builds realistic EXIF (nested Exif/GPS sub-IFDs, matching real +# camera output) for test_enrich_photo.py — Pillow itself is lenient enough +# to round-trip a flat, non-standard EXIF dict, which would have hidden the +# get_ifd(Exif) bug enrich_photo.py's DateTimeOriginal read had. +dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.6", "piexif>=1.1"] [project.scripts] meshbay-node = "meshbay_node.daemon:main" diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 667e1eb..cb3626b 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -48,6 +48,7 @@ from meshbay_node.hub_client import HubClient, HubConfig from meshbay_node.indexer import DirectoryIndexer, IndexCache, GroupIndex from meshbay_node.indexer.enrich import Enricher from meshbay_node.indexer.enrich_audio import AudioEnricher +from meshbay_node.indexer.enrich_photo import PhotoEnricher from meshbay_node.media_cache import MediaCache from meshbay_node.tmdb import TmdbClient from meshbay_node.musicbrainz import MusicBrainzClient @@ -79,6 +80,16 @@ def _under_audio_root(path: str, audio_root: str) -> bool: return path == audio_root or path.startswith(audio_root + "/") +def _under_any_photo_root(path: str, photo_roots: list[str]) -> bool: + """ + Mirrors photos-app.js's underAnyPhotoRoot. Unlike video/audio's single + root, photo_roots is a list (docs/photos.md §2.1) — a match against any + one of them is enough. + """ + path = path or "" + return any(path == r or path.startswith(r + "/") for r in photo_roots) + + # ── Argon2id calibration ────────────────────────────────────────────────────── def calibrate_argon2(target_ms: int = 500) -> None: @@ -154,6 +165,7 @@ class NodeDaemon: self._tmdb_client: TmdbClient | None = None self._audio_enricher: AudioEnricher | None = None self._musicbrainz_client: MusicBrainzClient | None = None + self._photo_enricher: PhotoEnricher | None = None # A file id attempted at most once per daemon run, success or # failure — a persistently unprobeable file (corrupt, still being # written) does not get re-queued on every coalesced broadcast. A @@ -358,6 +370,12 @@ class NodeDaemon: # Same shape, Music app's own entry point. "audio_root": await self._roster.audio_root( group_cfg.id) if self._roster else "", + # Photos app's entry points — a *list*, unlike video_root/ + # audio_root above (docs/photos.md §2.1: a photo library + # is routinely scattered across several folders). Empty + # list means nothing configured yet. + "photo_roots": await self._roster.photo_roots( + group_cfg.id) if self._roster else [], # Whether TMDB lookups run for this group at all — # per-group (2026-08-24, used to be node-wide), same # "read once, kept current in place by the signed op" @@ -417,6 +435,11 @@ class NodeDaemon: self._musicbrainz_client = MusicBrainzClient(roster=self._roster) musicbrainz_contact = await self._roster.musicbrainz_contact() self._state["musicbrainz_contact_configured"] = bool(musicbrainz_contact) + + # 6d. Photos app (docs/photos.md) — same media_cache.db, its own + # enricher (Pillow, not ffmpeg/mutagen). No credential, no + # third-party client to construct: EXIF is read locally. + self._photo_enricher = PhotoEnricher(self._media_cache) log.info("Media cache opened: %s", media_cache_db) # 5. Denylist @@ -560,6 +583,7 @@ class NodeDaemon: self._state["reload_fn"] = self._reload_config self._state["enrich_video_root_fn"] = self._enrich_video_root_now self._state["enrich_audio_root_fn"] = self._enrich_audio_root_now + self._state["enrich_photo_roots_fn"] = self._enrich_photo_roots_now # Rotating a key has to reach every transport holding a copy of it, # and clearing the denylist has to reach the one the handshake # consults — so both are published rather than reachable only @@ -771,6 +795,9 @@ class NodeDaemon: "audio_root": ( await self._roster.audio_root(group_cfg.id) if self._roster else ""), + "photo_roots": ( + await self._roster.photo_roots(group_cfg.id) + if self._roster else []), "tmdb_enabled": ( await self._roster.tmdb_enabled(group_cfg.id) if self._roster else True), @@ -1013,6 +1040,9 @@ class NodeDaemon: # exactly like video_root above (added later — musicbay.md's # original "no root, whole shared tree" call didn't hold up). asyncio.ensure_future(self._enrich_new_audio_entries(indexer, new_entries)) + # Photos app (docs/photos.md §5): same shape, gated on photo_roots + # (a list, not a single string — §2.1). + asyncio.ensure_future(self._enrich_new_photo_entries(indexer, new_entries)) # A rename/move changes the very filename (or season folder) that # §3.3/§3.4's title-parse read display_title/season/episode from, @@ -1027,16 +1057,31 @@ class NodeDaemon: self._reenrich_renamed_video_entries(indexer, delta.updates, previous)) asyncio.ensure_future( self._reenrich_renamed_audio_entries(indexer, delta.updates, previous)) + asyncio.ensure_future( + self._reenrich_renamed_photo_entries(indexer, delta.updates, previous)) - # Videos/Music apps: a file that leaves the index also loses its - # thumbnail/cover and file->tmdb/file->mbid mapping — the "real + # Videos/Music/Photos apps: a file that leaves the index also loses + # its thumbnail/cover and file->tmdb/file->mbid mapping — the "real # deletion obligation" docs/mediacenter.md §2/§8 calls out # explicitly rather than leaving implicit (docs/musicbay.md §6 # follows the same rule). tmdb_meta/mbid_meta rows are left alone # (§2: shared across files). + # + # Found live (docs/photos.md): a root removed and a new one added + # for the identical content (an operator renaming/relocating a + # shared folder) pruned the thumbnail here — correctly, the content + # is gone from *this* root — but left the hash in + # `_enriched_attempted`, which is never otherwise cleared. The same + # bytes reappearing under the new root's path were then permanently + # skipped: "already attempted" was true forever, for a thumbnail + # that no longer existed. Discarding the attempt alongside the + # cache entry is what makes pruning actually reversible — the next + # sweep re-enriches it exactly as if it were new, which content + # that is content-addressed and simply moved effectively is. if delta is not None and delta.deletions and self._media_cache: for file_id in delta.deletions: asyncio.ensure_future(self._media_cache.prune_file(file_id)) + self._enriched_attempted.discard((indexer.group_id, file_id)) # 11.5 — Push to connected WebRTC peers in this group if self._webrtc: @@ -1235,6 +1280,76 @@ class NodeDaemon: self._enriched_attempted.discard((indexer.group_id, entry.id)) await self._enrich_new_audio_entries(indexer, updates) + async def _enrich_new_photo_entries(self, indexer: DirectoryIndexer, entries: list) -> None: + """ + Photos app (docs/photos.md §5): fire (never await further) thumbnail/ + EXIF enrichment for unattempted image entries under any of the + group's configured photo_roots. Same gate shape as + `_enrich_new_video_entries`/`_enrich_new_audio_entries` — no root + configured yet means no work, since thumbnailing every image in a + whole shared tree before the operator has chosen which folders are + actually photo albums would burn CPU on files never meant to be in + the Photos app at all. `_enriched_attempted` is shared with the + video/audio paths — content-addressed ids never collide across them. + """ + if not self._photo_enricher or not self._roster: + return + photo_roots = await self._roster.photo_roots(indexer.group_id) + if not photo_roots: + return + for entry in entries: + if entry.type != "image" or (indexer.group_id, entry.id) in self._enriched_attempted: + continue + if not _under_any_photo_root(entry.path, photo_roots): + continue + file_path = entry_abs_path(indexer.roots, entry) + if not file_path or not file_path.exists(): + continue + self._enriched_attempted.add((indexer.group_id, entry.id)) + + async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None: + await self._on_enriched(_indexer, file_id, fields) + + self._photo_enricher.spawn(entry, file_path, on_done) + + async def _enrich_photo_roots_now(self, group_id: str) -> None: + """ + Photos app: sweep a group's existing index right after its + photo_roots set changes (ops.set_photo_roots). Mirrors + `_enrich_video_root_now`/`_enrich_audio_root_now` — the ordinary + path above only ever looks at entries new since the last broadcast, + so a folder that already had photos in it before it was added to + photo_roots would otherwise never get enriched at all. Also covers + a root being *removed*: nothing un-enriches on removal (the cache + entry is harmless, just unused — docs/photos.md's cache is + disposable), so re-sweeping the new set is enough. + """ + indexer = self._state.get("indexers", {}).get(group_id) + if not indexer: + return + await self._enrich_new_photo_entries(indexer, list(indexer.index.entries)) + + async def _reenrich_renamed_photo_entries( + self, indexer: DirectoryIndexer, updates: list, previous: GroupIndex, + ) -> None: + """ + Photos app equivalent of `_reenrich_renamed_video_entries` — a + rename changes nothing about the image's own bytes (thumbnail, EXIF + fields are content-derived, not name-derived), so this exists only + for consistency/symmetry with Videos/Music and to catch the case of + a file moving *into* a newly-covered photo_roots subtree via a + rename rather than a fresh add. Re-running enrichment on an + unchanged file is redundant work, not a correctness issue. + """ + for entry in updates: + if entry.type != "image": + continue + old = previous.get_entry(entry.id) + if old is None or (old.name == entry.name and old.path == entry.path): + continue + self._enriched_attempted.discard((indexer.group_id, entry.id)) + await self._enrich_new_photo_entries(indexer, updates) + async def _on_enriched(self, indexer: DirectoryIndexer, file_id: str, fields: dict) -> None: """ Merge enrichment fields into the live index and re-trigger a diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py new file mode 100644 index 0000000..44f9ebb --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py @@ -0,0 +1,160 @@ +""" +Index-time enrichment for the Photos group app: a resized thumbnail and a +minimal, best-effort info set (`taken_at`, `camera`) read from the image's +own EXIF block, for a newly-added image IndexEntry. + +Deliberately small — docs/photos.md §2.4 is explicit that this app does not +build a full EXIF-viewer panel. Two fields only, both best-effort (missing +EXIF is the ordinary case for a screenshot or a re-saved/edited image, not +an error). GPS is never read here, on purpose: it is a location disclosure +the instant it is surfaced to every group member, and nothing in this +module extracts, caches, or hands it to a caller. + +Runs through its own small bounded worker pool, separate from the video +(ffmpeg) and audio (mutagen) enrichment pools — mirrors enrich.py exactly, +per docs/photos.md §5's "never shared with either" rule, even though +Pillow's own work is comparatively cheap: a burst of hundreds of newly +shared photos should not peg every CPU core at once. +""" + +import asyncio +import datetime +import io +import logging +from pathlib import Path +from typing import Awaitable, Callable + +import blake3 +from PIL import ExifTags, Image, ImageOps + +from meshbay_common.protocol import IndexEntry +from meshbay_node.media_cache import MediaCache + +log = logging.getLogger(__name__) + +DEFAULT_MAX_CONCURRENT = 2 +ENRICH_TIMEOUT_SECS = 20 +THUMB_LONG_EDGE = 480 +THUMB_JPEG_QUALITY = 85 + +# Reverse-lookup: EXIF tag id -> name, built once (Image.getexif() returns a +# dict keyed by numeric tag id, not by name). +_EXIF_TAG_NAMES = {v: k for k, v in ExifTags.TAGS.items()} +# Make/Model are 0th-IFD (TIFF) tags, present directly on Image.getexif(). +_TAG_MAKE = _EXIF_TAG_NAMES.get("Make") +_TAG_MODEL = _EXIF_TAG_NAMES.get("Model") +# DateTimeOriginal is an Exif-SubIFD tag, not the 0th IFD — a real camera's +# JPEG (verified against piexif-built EXIF, matching what real hardware +# produces) never has it directly on getexif(); it is only reachable via +# getexif().get_ifd(ExifTags.IFD.Exif). Reading it off the plain top-level +# dict, as an earlier version of this module did, silently returned None +# for every real photo while Make/Model kept working — found before this +# ever ran against a real file, by testing with a properly structured EXIF +# block instead of a flat one Pillow itself is lenient enough to round-trip. +_TAG_DATETIME_ORIGINAL = _EXIF_TAG_NAMES.get("DateTimeOriginal") + + +def _parse_exif_datetime(value: str) -> int | None: + """EXIF's own format: "YYYY:MM:DD HH:MM:SS", local time, no timezone.""" + try: + dt = datetime.datetime.strptime(value.strip(), "%Y:%m:%d %H:%M:%S") + return int(dt.timestamp()) + except (ValueError, TypeError): + return None + + +def _read_image(file_path: Path) -> tuple[bytes, int, int, int | None, str | None]: + """ + Runs in a worker thread (Pillow is synchronous, and decoding a large + photo is real CPU work — same reason enrich.py's own ancestor/sibling + scans go through `asyncio.to_thread`). + + Returns (thumbnail_jpeg_bytes, width, height, taken_at, camera) for the + *original* image's own dimensions — the thumbnail is a separate, resized + copy, never what width/height describe. + """ + with Image.open(file_path) as img: + taken_at = None + camera = None + try: + exif = img.getexif() + if exif: + if _TAG_DATETIME_ORIGINAL is not None: + sub_ifd = exif.get_ifd(ExifTags.IFD.Exif) + raw = sub_ifd.get(_TAG_DATETIME_ORIGINAL) + if raw: + taken_at = _parse_exif_datetime(str(raw)) + make = exif.get(_TAG_MAKE) if _TAG_MAKE is not None else None + model = exif.get(_TAG_MODEL) if _TAG_MODEL is not None else None + make = (make or "").strip() if isinstance(make, str) else None + model = (model or "").strip() if isinstance(model, str) else None + if make or model: + camera = " ".join(p for p in (make, model) if p) + except Exception as e: + # A malformed EXIF block is a real, observed case (a corrupted + # tag, a non-standard camera) — best-effort, never fatal to the + # thumbnail itself. + log.debug("EXIF read failed for %s: %s", file_path, e) + + # Applies (and then clears) the EXIF Orientation tag before reading + # width/height and resizing — otherwise a phone photo stored + # "sideways" reports its raw, pre-rotation dimensions (swapped from + # what it actually displays as) and produces a sideways thumbnail + # (docs/photos.md §2.4). Never reads Orientation itself as a + # client-visible field; this is display correction only, and + # width/height must describe the *displayed* image, matching what + # the lightbox and the info panel show. + oriented = ImageOps.exif_transpose(img) + width, height = oriented.size + oriented.thumbnail((THUMB_LONG_EDGE, THUMB_LONG_EDGE), Image.LANCZOS) + if oriented.mode not in ("RGB", "L"): + oriented = oriented.convert("RGB") + buf = io.BytesIO() + oriented.save(buf, format="JPEG", quality=THUMB_JPEG_QUALITY) + return buf.getvalue(), width, height, taken_at, camera + + +class PhotoEnricher: + """Owns the node's bounded Photos index-time enrichment pool.""" + + def __init__(self, media_cache: MediaCache, max_concurrent: int = DEFAULT_MAX_CONCURRENT): + self._media_cache = media_cache + self._sem = asyncio.Semaphore(max_concurrent) + self._tasks: set[asyncio.Task] = set() + + def spawn( + self, entry: IndexEntry, file_path: Path, + on_done: Callable[[str, dict], Awaitable[None]], + ) -> asyncio.Task: + """Fire-and-forget, same contract as enrich.py's Enricher.spawn.""" + task = asyncio.ensure_future(self._run(entry, file_path, on_done)) + self._tasks.add(task) + + def _cleanup(t: asyncio.Task) -> None: + self._tasks.discard(t) + if not t.cancelled() and t.exception(): + log.error("Photo enrichment failed for %s: %s", entry.id[:12], t.exception(), + exc_info=t.exception()) + task.add_done_callback(_cleanup) + return task + + async def _run( + self, entry: IndexEntry, file_path: Path, + on_done: Callable[[str, dict], Awaitable[None]], + ) -> None: + async with self._sem: + fields: dict = {} + try: + thumb, width, height, taken_at, camera = await asyncio.wait_for( + asyncio.to_thread(_read_image, file_path), timeout=ENRICH_TIMEOUT_SECS) + fields["width"] = width + fields["height"] = height + fields["taken_at"] = taken_at + fields["camera"] = camera + thumb_hash = blake3.blake3(thumb).hexdigest() + await self._media_cache.put_thumb(thumb_hash, entry.id, thumb) + fields["thumb_hash"] = thumb_hash + except Exception as e: + log.warning("Photo enrichment failed for %s: %s", file_path, e) + + await on_done(entry.id, fields) diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index 1da6928..c9375b3 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -863,6 +863,30 @@ async def set_audio_root(state: dict, group_id: str, path: str) -> dict: return {"path": path, "group_id": group_id} +async def set_photo_roots(state: dict, group_id: str, roots: list[str]) -> dict: + """ + Which folder(s) are the Photos app's entry points for this group. Unlike + `set_video_root`/`set_audio_root`, the whole *set* is replaced in one + call (docs/photos.md §2.1) — signed once, same shape as + `set_enabled_apps`, rather than one op per root added/removed. + + Always fires a sweep, even to an empty list: a root just added needs its + existing contents enriched (nothing else re-visits already-indexed + entries), and a root just removed leaves its cache entries harmlessly + unused rather than needing any cleanup — re-sweeping the new set costs + nothing when it's empty. + """ + roster = _roster(state) + ctx = _group_ctx(state, group_id) + await roster.set_photo_roots(group_id, roots, set_by=state.get("node_user_id", "")) + ctx["photo_roots"] = roots + log.info("Photo roots for group %s: %s", group_id[:8], ", ".join(sorted(roots)) or "(none)") + enrich_fn = state.get("enrich_photo_roots_fn") + if enrich_fn: + asyncio.ensure_future(enrich_fn(group_id)) + return {"roots": roots, "group_id": group_id} + + # ── Scan settings ──────────────────────────────────────────────────────────── async def set_scan_settings(state: dict, group_id: str, reconcile_interval_secs: float, diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 91e18cd..d6dc769 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -664,6 +664,31 @@ class Roster: await self.set_setting(group_id, self.SETTING_AUDIO_ROOT, path or "", set_by) return path or "" + # Which folder(s) are the Photos app's entry points for this group — + # a *set*, unlike video_root/audio_root above: a photo library is + # routinely scattered across several unrelated folders (docs/photos.md + # §2.1), so there is no single natural root to pick. Stored the same way + # `enabled_apps` already is (json.dumps(sorted(...))). Empty/unset means + # nothing configured yet — same "show nothing until an operator has + # chosen" discipline video_root/audio_root already established, not + # "the whole group index". + 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 + # Whether TMDB lookups run for this group at all — per-group, unlike the # token/language above: one node process can share a real media library # group and several test/demo groups, and outbound TMDB traffic (and API diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 6d21175..b4db051 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -73,6 +73,7 @@ from meshbay_common.adminop import ( OP_MUSICBRAINZ_CONFIG, OP_MUSICBRAINZ_ENABLED, OP_AUDIO_ROOT, + OP_PHOTO_ROOTS, OP_ROOT_ADD, OP_ROOT_REMOVE, OP_GROUP_ATTACH, @@ -401,6 +402,8 @@ class WebRTCPeerSession: self._do_video_root(msg) elif mtype == MNP.AUDIO_ROOT: self._do_audio_root(msg) + elif mtype == MNP.PHOTO_ROOTS: + self._do_photo_roots(msg) elif mtype == MNP.MEDIA_META_REQ: self._spawn(self._do_media_meta_request(msg)) elif mtype == MNP.SEASON_META_REQ: @@ -692,6 +695,11 @@ class WebRTCPeerSession: # group — same shape as video_root above, "" means unset (the # Music tab shows nothing yet). "audio_root": self._group_ctx().get("audio_root") or "", + # Which folder(s) the Photos app treats as its entry points for + # this group — a *list*, unlike video_root/audio_root above + # (docs/photos.md §2.1). Empty means unset (the Photos tab shows + # nothing yet). + "photo_roots": list(self._group_ctx().get("photo_roots") or []), # So a client that connects mid-scan shows the indexing state # immediately, instead of waiting for the next periodic # INDEX_PROGRESS push. Never a path or filename — see @@ -1655,7 +1663,7 @@ class WebRTCPeerSession: # network calls (TMDB, MusicBrainz) once enabled, so an operator opts a # group in explicitly rather than getting it for free # (docs/mediacenter.md §5.6, docs/musicbay.md §4.4). - ALLOWED_APPS = frozenset({"chat", "files", "video", "music"}) + ALLOWED_APPS = frozenset({"chat", "files", "video", "music", "photo"}) def _do_apps_enabled(self, msg: dict) -> None: """ @@ -1901,6 +1909,69 @@ class WebRTCPeerSession: except Exception: pass + def _do_photo_roots(self, msg: dict) -> None: + """ + Which folder(s) the Photos app treats as its entry points for this + group (docs/photos.md §2.1) — a *set*, replaced whole in one signed + op, same shape as apps_enabled rather than one op per root the way + video_root/audio_root are single values. + + An empty list is always accepted (nothing configured yet, today's + "Photos shows nothing" state). Every non-empty path must resolve to + a real, currently-readable directory, and no root may be nested + inside another in the same submitted set — both checked, and + refused, before a signature is ever asked for, same principle as + video_root's path check and apps_enabled's "empty set refused up + front". + """ + roots = msg.get("roots") + if not isinstance(roots, list) or not all(isinstance(r, str) for r in roots): + self._send({"type": "error", "detail": "Missing or invalid 'roots'"}) + return + roots = sorted({r.strip("/") for r in roots if r.strip("/")}) + ctx = self._group_ctx() + for path in roots: + resolved = ctx["roots"].resolve(path) if ctx.get("roots") else None + if not resolved or not resolved.is_dir(): + self._send({"type": "error", + "detail": f"Not a directory in this group: {path}"}) + return + # Case-insensitive nesting check (§6.8) — a root may not be a folder + # itself sitting inside another root in the same set. + folded = [r.casefold() for r in roots] + for i, a in enumerate(folded): + for j, b in enumerate(folded): + if i != j and (a == b or a.startswith(b + "/")): + self._send({"type": "error", + "detail": f"Root nested inside another: {roots[i]}"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge(OP_PHOTO_ROOTS, ",".join(roots)) + + async def _admin_exec_photo_roots( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + roots = pending["subject"].split(",") if pending["subject"] else [] + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"photo_roots:{pending['subject']}") + return + try: + await self._run_op(ops.set_photo_roots, self._group_id or "", roots) + except ops.OpError as e: + self._send({"type": "error", "detail": e.message}) + return + self._audit("photo_roots", pending["subject"]) + + notice = {"type": MNP.PHOTO_ROOTS_ACK, "v": MNP_VERSION, "roots": roots} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + def _do_musicbrainz_config(self, msg: dict) -> None: """ Set (or clear) the node-wide MusicBrainz User-Agent contact string @@ -3603,6 +3674,9 @@ class WebRTCPeerSession: elif pending["op"] == OP_AUDIO_ROOT: self._spawn( self._admin_exec_audio_root(pending, transcript, sig_bytes)) + elif pending["op"] == OP_PHOTO_ROOTS: + self._spawn( + self._admin_exec_photo_roots(pending, transcript, sig_bytes)) elif pending["op"] == OP_ROOT_ADD: self._spawn( self._admin_exec_root_add(pending, transcript, sig_bytes)) diff --git a/packages/meshbay-node/tests/test_enrich_photo.py b/packages/meshbay-node/tests/test_enrich_photo.py new file mode 100644 index 0000000..e0c1b73 --- /dev/null +++ b/packages/meshbay-node/tests/test_enrich_photo.py @@ -0,0 +1,162 @@ +"""Tests for indexer/enrich_photo.py — the Photos app's thumbnail/EXIF pass.""" + +import asyncio +import io +from pathlib import Path + +import piexif +import pytest +from PIL import Image + +from meshbay_common.protocol import IndexEntry +from meshbay_node.indexer.enrich_photo import PhotoEnricher +from meshbay_node.media_cache import MediaCache + + +@pytest.fixture +async def media_cache(tmp_path): + c = MediaCache(db_path=tmp_path / "media_cache.db") + await c.open() + yield c + await c.close() + + +def _save_jpeg(path: Path, size=(300, 200), color="red", exif_bytes: bytes | None = None): + img = Image.new("RGB", size, color) + kwargs = {"format": "JPEG"} + if exif_bytes is not None: + kwargs["exif"] = exif_bytes + img.save(path, **kwargs) + + +async def _run(enricher: PhotoEnricher, entry: IndexEntry, path: Path): + done = asyncio.get_event_loop().create_future() + + async def on_done(file_id, fields): + done.set_result((file_id, fields)) + + enricher.spawn(entry, path, on_done) + return await asyncio.wait_for(done, timeout=15) + + +@pytest.mark.asyncio +async def test_enricher_populates_dimensions_and_stores_thumbnail(tmp_path, media_cache): + img = tmp_path / "plain.jpg" + _save_jpeg(img, size=(300, 200)) + entry = IndexEntry(id="fileid1", name=img.name, path=img.name, + size=img.stat().st_size, type="image", added_at=0) + + enricher = PhotoEnricher(media_cache) + file_id, fields = await _run(enricher, entry, img) + + assert file_id == "fileid1" + assert fields["width"] == 300 + assert fields["height"] == 200 + assert fields.get("thumb_hash") + stored = await media_cache.get_thumb(fields["thumb_hash"]) + assert stored is not None and len(stored) > 0 + # Actually decodes as a downsized JPEG, not just non-empty bytes. + thumb = Image.open(io.BytesIO(stored)) + assert thumb.format == "JPEG" + assert max(thumb.size) <= 480 + + +@pytest.mark.asyncio +async def test_enricher_no_exif_degrades_gracefully(tmp_path, media_cache): + """A screenshot or a re-saved image with no EXIF block at all is the + ordinary case, not an error — must not raise and must leave taken_at/ + camera unset rather than guessing.""" + img = tmp_path / "no_exif.jpg" + _save_jpeg(img) + entry = IndexEntry(id="fileid2", name=img.name, path=img.name, + size=img.stat().st_size, type="image", added_at=0) + + enricher = PhotoEnricher(media_cache) + _, fields = await _run(enricher, entry, img) + + assert fields.get("taken_at") is None + assert fields.get("camera") is None + assert fields.get("thumb_hash") + + +@pytest.mark.asyncio +async def test_enricher_reads_taken_at_from_realistic_camera_exif(tmp_path, media_cache): + """ + Built with piexif rather than a flat Image.Exif dict: a real camera + stores DateTimeOriginal in the Exif sub-IFD (tag 0x8769), not the 0th + IFD — Pillow's own getexif() only sees the 0th IFD directly. A flat + dict (`img.getexif()[36867] = ...`) round-trips inside Pillow without + ever exercising that distinction, which is exactly what let an earlier + version of enrich_photo.py read `img.getexif().get(36867)` and always + get None against a real photo while Make/Model (genuine 0th-IFD tags) + kept working — found only once this test built EXIF the way real + hardware does. + """ + img = tmp_path / "camera.jpg" + exif_dict = { + "0th": {piexif.ImageIFD.Make: b"Acme", piexif.ImageIFD.Model: b"Camera X"}, + "Exif": {piexif.ExifIFD.DateTimeOriginal: b"2024:01:02 03:04:05"}, + "GPS": {}, "1st": {}, "thumbnail": None, + } + _save_jpeg(img, exif_bytes=piexif.dump(exif_dict)) + entry = IndexEntry(id="fileid3", name=img.name, path=img.name, + size=img.stat().st_size, type="image", added_at=0) + + enricher = PhotoEnricher(media_cache) + _, fields = await _run(enricher, entry, img) + + assert fields["camera"] == "Acme Camera X" + assert fields["taken_at"] is not None + # 2024-01-02 03:04:05 UTC-ish, tolerant of local-time parsing: same day. + import datetime + dt = datetime.datetime.fromtimestamp(fields["taken_at"]) + assert (dt.year, dt.month, dt.day) == (2024, 1, 2) + + +@pytest.mark.asyncio +async def test_enricher_corrects_orientation(tmp_path, media_cache): + """ + A phone photo is routinely stored "sideways" with an EXIF Orientation + tag telling viewers how to rotate it — width/height, and the thumbnail + itself, must describe the *displayed* image, not the raw stored one. + Orientation 6 stores a 300x200 frame that displays as 200x300. + """ + img = tmp_path / "rotated.jpg" + raw = Image.new("RGB", (300, 200), "blue") + exif = raw.getexif() + exif[274] = 6 # Orientation + buf = io.BytesIO() + raw.save(buf, format="JPEG", exif=exif) + img.write_bytes(buf.getvalue()) + + entry = IndexEntry(id="fileid4", name=img.name, path=img.name, + size=img.stat().st_size, type="image", added_at=0) + + enricher = PhotoEnricher(media_cache) + _, fields = await _run(enricher, entry, img) + + assert (fields["width"], fields["height"]) == (200, 300), ( + "width/height must reflect the EXIF-corrected orientation, not the raw stored frame") + thumb_bytes = await media_cache.get_thumb(fields["thumb_hash"]) + thumb = Image.open(io.BytesIO(thumb_bytes)) + assert thumb.size[0] < thumb.size[1], "the stored thumbnail itself must be portrait, not sideways" + + +def test_gps_is_never_read_by_this_module(): + """ + docs/photos.md §2.4/§11: GPS must never be extracted, cached, or handed + to a caller — a location disclosure the instant it is surfaced to every + group member. Grep-based, 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. + + Checks for the actual extraction API (the GPS sub-IFD constant, its + numeric tag ids in either byte order, and the GPS tag-name table) rather + than the bare word "GPS" — this module's own docstrings and comments say + GPS *on purpose*, explaining why it is never read; that prose is not + what this test is guarding against. + """ + src = (Path(__file__).resolve().parents[1] / "src" / "meshbay_node" + / "indexer" / "enrich_photo.py").read_text() + for needle in ("GPSInfo", "GPSTAGS", "0x8825", "34853"): + assert needle not in src, f"found {needle!r} — GPS extraction must never be added here" |