summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-25 11:46:17 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-25 11:46:17 +0200
commit2fcdd07d1e5d331ad02b723f1c45603a0989c264 (patch)
treef606f01f5492648824876efe4c8a431d9b3a59d6 /packages
parentd427118bd91d67f1a041e5daf267aebcd34ca9d7 (diff)
downloadmeshbay-2fcdd07d1e5d331ad02b723f1c45603a0989c264.tar.gz
feat: add Photos group app
A new group application (docs/apps.md's plug-in mechanism), following the plan in docs/photos.md. Unlike Videos/Music: several photo roots per group instead of one (photo_roots is a set, one signed op replaces it whole), a single album-grid view with no third-party matching step, and per-photo info read from the file's own EXIF at index time — no metadata service, no credential, no outbound network call at all. Protocol (meshbay-common, MNP 0.10 -> 0.11, additive): `taken_at`/`camera` on IndexEntry; `photo_roots`/`photo_roots_ack`; `OP_PHOTO_ROOTS`. Node: roster.py stores photo_roots as a group_settings entry (JSON list, same shape as enabled_apps); ops.py/webrtc_server.py validate and sign the whole set in one op, same pattern as apps_enabled; a new PhotoEnricher (indexer/enrich_photo.py) runs Pillow in its own small bounded pool, separate from the video/audio pools, producing a resized thumbnail plus the two EXIF fields — never GPS, checked by a grep-based regression test. Client: photos-app.js — one album card per directory containing images, a per-album photo grid, and a lightbox with next/previous (keyboard and buttons), zoom in/out/fit/100% starting from the actual on-screen fit percentage, and a "zip this album" button reusing files-app.js's own zip mechanism (lifted into file-utils.js's downloadDirectory so both call the same implementation). group-settings.js gets an add/remove multi-root picker, distinct from Videos/Music's single-value one. Bugs found and fixed before this ever shipped, worth keeping the story of: - enrich_photo.py read width/height from the raw image *before* applying EXIF orientation correction, and read DateTimeOriginal off the plain 0th-IFD Exif object — a real camera stores it in the Exif sub-IFD, which Pillow only exposes via get_ifd(Exif). A flat, hand-built EXIF dict round-trips through Pillow either way, which is exactly what would have hidden both bugs; the regression test builds EXIF with piexif instead, matching what real hardware produces. - photos-app.js's album grouping stripped a trailing path segment from entry.path under the assumption it still carried a filename — it doesn't (files-app.js's own convention: e.path is already the containing directory), so every album collapsed one level into its parent. Found live against a real multi-folder library. - transport.js's ADMIN_OP_TYPES allowlist (already the fix for an identical bug on video_root/apps_enabled, see 4783d81) was missing photo_roots: its admin_challenge matched no pending request and was silently dropped, so saving a photo root just timed out after 30s with no error. - daemon.py pruned a thumbnail when its file left the index (root removed or reconfigured) but never forgot the content hash was "already attempted" — the same bytes reappearing under a renamed/relocated root (an operator's real workflow) were then permanently skipped, forever, with nothing to indicate why. Discarding the attempt alongside the cache entry on prune is what makes pruning actually reversible. - packages/meshbay-client's app:// protocol handler served every file with no Cache-Control header, so Chromium was free to serve a stale cached copy indefinitely — none of several `npm run sync-ui` + reload cycles during development actually picked up the new code until the renderer's disk cache was cleared by hand. Now sends Cache-Control: no-store. - the lightbox's zoomed image used flex centering (align-items/ justify-content: center) combined with overflow: auto — a well-known trap where the browser centers overflowing content by shifting it, and the leading half of that overflow (here, the top of a zoomed photo) sits outside what the scrollport can actually reach. Reported live as "unusable". Fixed by switching to top/left alignment once zoomed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiZG4AuSnxHohQMpwTHTyL
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-client/src/main.js10
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py8
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py5
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apps.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js91
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js89
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js114
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/icon.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js30
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js370
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css277
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js29
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py8
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py1
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py26
-rw-r--r--packages/meshbay-node/pyproject.toml7
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py119
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py160
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py24
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py25
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py76
-rw-r--r--packages/meshbay-node/tests/test_enrich_photo.py162
34 files changed, 1812 insertions, 105 deletions
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"