diff options
Diffstat (limited to 'packages/meshbay-node')
| -rw-r--r-- | packages/meshbay-node/pyproject.toml | 7 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 119 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py | 160 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ops.py | 24 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/roster.py | 25 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 76 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_enrich_photo.py | 162 |
7 files changed, 569 insertions, 4 deletions
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" |