aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
-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
5 files changed, 401 insertions, 3 deletions
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))