diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 119 |
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 |