summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/daemon.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py119
1 files changed, 117 insertions, 2 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