summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
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"