summaryrefslogtreecommitdiffstats
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.py295
-rw-r--r--packages/meshbay-node/src/meshbay_node/enrichment.py298
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py2
3 files changed, 303 insertions, 292 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index a81aced..6d808ae 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -31,7 +31,7 @@ import os
import signal
import sys
import time
-from dataclasses import asdict, replace
+from dataclasses import asdict
from pathlib import Path
import uvicorn
@@ -47,6 +47,7 @@ from meshbay_node.bundle_store import BundleStore
from meshbay_node.chat.store import ChatStore
from meshbay_node.cli.dispatch import run, start
from meshbay_node.config import DEFAULT_CONFIG_PATH, Config, load_config
+from meshbay_node.enrichment import EnrichmentMixin
from meshbay_node.hub_client import HubClient, HubConfig
from meshbay_node.indexer import DirectoryIndexer, GroupIndex, IndexCache
from meshbay_node.indexer.enrich import Enricher
@@ -56,7 +57,7 @@ from meshbay_node.keystore import load_or_create_keystore
from meshbay_node.media_cache import MediaCache
from meshbay_node.musicbrainz import MusicBrainzClient
from meshbay_node.platform import chmod_private
-from meshbay_node.roots import RootError, RootSet, entry_abs_path, off_disk
+from meshbay_node.roots import RootError, RootSet, off_disk
from meshbay_node.roster import Roster
from meshbay_node.tmdb import TmdbClient
from meshbay_node.transport import (
@@ -74,38 +75,6 @@ if WEBRTC_AVAILABLE:
log = logging.getLogger(__name__)
-def _under_any_directory(path: str, directories: list[str]) -> bool:
- """
- Whether an entry's folder is one of an app's directories, or inside one.
-
- Mirrors `underAnyDirectory` in the SPA's app modules. One helper for every
- app since they all take a list: Videos and Music used to take a single
- folder and had a function each saying the same thing, which is how the two
- came to differ in what they did with a trailing slash.
- """
- path = path or ""
- return any(path == d or path.startswith(d + "/") for d in directories)
-
-
-def _owning_directory(path: str, directories: list[str]) -> str | None:
- """
- Which of an app's directories an entry belongs to — the deepest match.
-
- Deepest, because directories may nest: with both `Media` and
- `Media/Albums` configured, a file under the second belongs to the second.
- Taking the first match instead would measure it against a boundary one
- level too shallow, which for Music is the difference between reading a
- folder as an artist and reading it as a release.
- """
- path = path or ""
- best: str | None = None
- for d in directories:
- if path == d or path.startswith(d + "/"):
- if best is None or len(d) > len(best):
- best = d
- return best
-
-
# ── Hub WS sender bridge ─────────────────────────────────────────────────────
class _WsSender:
@@ -125,7 +94,7 @@ def _root_shape(roots) -> set[tuple]:
return {(r.name, str(r.path), r.writable, r.removable) for r in roots}
-class NodeDaemon:
+class NodeDaemon(EnrichmentMixin):
def __init__(self, config: Config, config_path: Path = DEFAULT_CONFIG_PATH):
self._config = config
self._config_path = config_path
@@ -1447,262 +1416,6 @@ class NodeDaemon:
endpoint = f"webrtc:{self._config.node.quic_port}"
spawn(self._register_swarm(hashes, endpoint))
- async def _enrich_new_video_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
- """
- Videos app: fire (never await further) enrichment for unattempted
- video entries under the group's configured video_root.
-
- A group with no video_root set yet does not enrich anything — TMDB
- lookups and ffmpeg thumbnailing are real, ongoing per-file cost
- (docs/MESHBAY_DESIGN.md §6.5), and running them over an operator's
- whole shared index before they have chosen which folder is actually their
- media library would burn both TMDB's rate limit and the node's CPU
- on files that were never meant to be in the Videos app at all. Once a
- root is set, `_enrich_video_root_now` (called when it changes)
- separately sweeps whatever it already contains — this path alone only
- ever sees entries new since the last broadcast.
- """
- if not self._enricher or not self._roster:
- return
- video_dirs = await self._roster.app_directories(indexer.group_id, "video")
- if not video_dirs:
- return
- for entry in entries:
- if entry.type != "video" or (indexer.group_id, entry.id) in self._enriched_attempted:
- continue
- if not _under_any_directory(entry.path, video_dirs):
- 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._enricher.spawn(entry, file_path, on_done)
-
- async def _enrich_video_root_now(self, group_id: str) -> None:
- """
- Videos app: sweep a group's existing index for enrichment right
- after its video root is set or changed.
-
- The ordinary path above only ever looks at entries new since the
- last broadcast, so a folder that already had files sitting in it
- before it became the video_root would otherwise never get enriched
- at all — nothing else re-visits already-indexed entries once they
- have been broadcast once.
- """
- indexer = self._state.get("indexers", {}).get(group_id)
- if not indexer:
- return
- await self._enrich_new_video_entries(indexer, list(indexer.index.entries))
-
- async def _reenrich_renamed_video_entries(
- self, indexer: DirectoryIndexer, updates: list, previous: GroupIndex,
- ) -> None:
- """
- Videos app: found live — a French-named episode file, renamed by
- the operator to match its English-named siblings, kept showing as
- its own separate poster-grid card (and its own row in Flat list)
- indefinitely, because `_enriched_attempted` — there specifically to
- stop enrichment's own field-fill from re-triggering itself forever
- (see the caller) — also silently blocked the *new* filename from
- ever being title-parsed at all. `entry.id in self._enriched_attempted`
- is the same content, so simply discarding it here and re-running
- the ordinary enrichment path is enough: a fresh ffprobe/thumbnail
- for an unchanged file is redundant work, not a correctness issue,
- and renames are rare enough that the redundancy is not worth a
- separate "title-parse only" code path.
- """
- for entry in updates:
- if entry.type != "video":
- 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))
- # The rename re-derives the title (the whole point of this
- # method), which can change the correct TMDB match — but
- # file_tmdb is keyed by content hash, unchanged by a rename, so
- # nothing else would ever dislodge the old name's match. A
- # manual "Fix match" correction is kept (clear_file_tmdb skips
- # anything in media_cache.tmdb_override).
- if self._media_cache is not None:
- await self._media_cache.clear_file_tmdb(entry.id)
- await self._enrich_new_video_entries(indexer, updates)
-
- async def _enrich_new_audio_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
- """
- Music app (docs/MESHBAY_DESIGN.md §9.8): fire (never await further)
- tag/cover enrichment for unattempted audio entries under the
- group's configured audio_root — same gate as
- `_enrich_new_video_entries` above (the original "no root, whole
- shared tree" call turned out wrong against a real messy library:
- everything under every shared folder got mixed together with no way
- to scope it down). `_enriched_attempted` is shared with the video
- path — content-addressed ids never collide across the two.
- """
- if not self._audio_enricher or not self._roster:
- return
- audio_dirs = await self._roster.app_directories(indexer.group_id, "music")
- if not audio_dirs:
- return
- # Resolved once per directory, not once per file: a library is
- # thousands of entries and this is a filesystem call each time.
- boundaries = {d: indexer.roots.resolve(d, require_available=False)
- for d in audio_dirs}
- for entry in entries:
- if entry.type != "audio" or (indexer.group_id, entry.id) in self._enriched_attempted:
- continue
- owner = _owning_directory(entry.path, audio_dirs)
- if owner is None:
- 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)
-
- # The boundary is *the configured directory this file is under*,
- # not the shared root it lives in — so the ancestor walk
- # (enrich_audio._artist_album_from_ancestors) treats a flat
- # top-level folder right under the configured Music directory as
- # ambiguous (artist-or-release, docs/MESHBAY_DESIGN.md §9.8),
- # rather than one level too shallow when that directory is
- # itself a subfolder. With several configured, each file is
- # measured against its own:
- # a single shared boundary would be wrong for all but one of them.
- self._audio_enricher.spawn(entry, file_path, on_done,
- boundaries.get(owner))
-
- async def _enrich_audio_root_now(self, group_id: str) -> None:
- """
- Music app: sweep a group's existing index right after its
- audio root is set or changed. Mirrors
- `_enrich_video_root_now` exactly — the ordinary path above only
- ever looks at entries new since the last broadcast, so a folder
- that already had files in it before it became the audio_root would
- otherwise never get enriched at all.
- """
- indexer = self._state.get("indexers", {}).get(group_id)
- if not indexer:
- return
- await self._enrich_new_audio_entries(indexer, list(indexer.index.entries))
-
- async def _reenrich_renamed_audio_entries(
- self, indexer: DirectoryIndexer, updates: list, previous: GroupIndex,
- ) -> None:
- """
- Music app equivalent of `_reenrich_renamed_video_entries` — a rename
- can change the filename-parse fallback (title/track_no) even though
- embedded tags, when present, are unaffected. Re-running the whole
- pass on a rename is redundant work for a tagged file and a real fix
- for an untagged one, and renames are rare enough not to need a
- cheaper, tags-only special case.
- """
- for entry in updates:
- if entry.type != "audio":
- 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_audio_entries(indexer, updates)
-
- async def _enrich_new_photo_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
- """
- Photos app (docs/MESHBAY_DESIGN.md §9.9): 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_dirs = await self._roster.app_directories(indexer.group_id, "photo")
- if not photo_dirs:
- return
- for entry in entries:
- if entry.type != "image" or (indexer.group_id, entry.id) in self._enriched_attempted:
- continue
- if not _under_any_directory(entry.path, photo_dirs):
- 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 change. 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 — the media cache is disposable and
- tied to the index, docs/MESHBAY_DESIGN.md §6.5), 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
- broadcast so they reach connected clients as an INDEX_DELTA update
- (GroupIndex.diff's `updates`, not `additions` — same id, new fields).
-
- Builds a *new* IndexEntry via dataclasses.replace rather than
- mutating the existing one in place: the diff mechanism compares
- against a shallow snapshot of entry *references*, so an in-place
- mutation would silently also change what "previous" looks like,
- and the change would never show up as a diff (see group_index.py's
- diff() docstring).
- """
- idx = indexer.index
- entry = idx.get_entry(file_id)
- if entry is None:
- return # removed from the index while enrichment was in flight
- idx.add_entry(replace(entry, **fields))
- await self._on_index_change(indexer)
-
def _drop_group_sessions(self, group_id: str) -> None:
"""Close live sessions for a revoked group (H4)."""
if not self._webrtc or not group_id:
diff --git a/packages/meshbay-node/src/meshbay_node/enrichment.py b/packages/meshbay-node/src/meshbay_node/enrichment.py
new file mode 100644
index 0000000..66b69b9
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/enrichment.py
@@ -0,0 +1,298 @@
+"""What the node does with a file once it is indexed, per app: the TMDB match and
+thumbnail for Videos, tags and cover for Music, thumbnail and EXIF for Photos.
+NodeDaemon runs it; the enrichers themselves are in indexer/."""
+
+from dataclasses import replace
+
+from meshbay_node.indexer import DirectoryIndexer, GroupIndex
+from meshbay_node.roots import entry_abs_path
+
+
+def _under_any_directory(path: str, directories: list[str]) -> bool:
+ """
+ Whether an entry's folder is one of an app's directories, or inside one.
+
+ Mirrors `underAnyDirectory` in the SPA's app modules. One helper for every
+ app since they all take a list: Videos and Music used to take a single
+ folder and had a function each saying the same thing, which is how the two
+ came to differ in what they did with a trailing slash.
+ """
+ path = path or ""
+ return any(path == d or path.startswith(d + "/") for d in directories)
+
+
+def _owning_directory(path: str, directories: list[str]) -> str | None:
+ """
+ Which of an app's directories an entry belongs to — the deepest match.
+
+ Deepest, because directories may nest: with both `Media` and
+ `Media/Albums` configured, a file under the second belongs to the second.
+ Taking the first match instead would measure it against a boundary one
+ level too shallow, which for Music is the difference between reading a
+ folder as an artist and reading it as a release.
+ """
+ path = path or ""
+ best: str | None = None
+ for d in directories:
+ if path == d or path.startswith(d + "/"):
+ if best is None or len(d) > len(best):
+ best = d
+ return best
+
+
+class EnrichmentMixin:
+ async def _enrich_new_video_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
+ """
+ Videos app: fire (never await further) enrichment for unattempted
+ video entries under the group's configured video_root.
+
+ A group with no video_root set yet does not enrich anything — TMDB
+ lookups and ffmpeg thumbnailing are real, ongoing per-file cost
+ (docs/MESHBAY_DESIGN.md §6.5), and running them over an operator's
+ whole shared index before they have chosen which folder is actually their
+ media library would burn both TMDB's rate limit and the node's CPU
+ on files that were never meant to be in the Videos app at all. Once a
+ root is set, `_enrich_video_root_now` (called when it changes)
+ separately sweeps whatever it already contains — this path alone only
+ ever sees entries new since the last broadcast.
+ """
+ if not self._enricher or not self._roster:
+ return
+ video_dirs = await self._roster.app_directories(indexer.group_id, "video")
+ if not video_dirs:
+ return
+ for entry in entries:
+ if entry.type != "video" or (indexer.group_id, entry.id) in self._enriched_attempted:
+ continue
+ if not _under_any_directory(entry.path, video_dirs):
+ 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._enricher.spawn(entry, file_path, on_done)
+
+ async def _enrich_video_root_now(self, group_id: str) -> None:
+ """
+ Videos app: sweep a group's existing index for enrichment right
+ after its video root is set or changed.
+
+ The ordinary path above only ever looks at entries new since the
+ last broadcast, so a folder that already had files sitting in it
+ before it became the video_root would otherwise never get enriched
+ at all — nothing else re-visits already-indexed entries once they
+ have been broadcast once.
+ """
+ indexer = self._state.get("indexers", {}).get(group_id)
+ if not indexer:
+ return
+ await self._enrich_new_video_entries(indexer, list(indexer.index.entries))
+
+ async def _reenrich_renamed_video_entries(
+ self, indexer: DirectoryIndexer, updates: list, previous: GroupIndex,
+ ) -> None:
+ """
+ Videos app: found live — a French-named episode file, renamed by
+ the operator to match its English-named siblings, kept showing as
+ its own separate poster-grid card (and its own row in Flat list)
+ indefinitely, because `_enriched_attempted` — there specifically to
+ stop enrichment's own field-fill from re-triggering itself forever
+ (see the caller) — also silently blocked the *new* filename from
+ ever being title-parsed at all. `entry.id in self._enriched_attempted`
+ is the same content, so simply discarding it here and re-running
+ the ordinary enrichment path is enough: a fresh ffprobe/thumbnail
+ for an unchanged file is redundant work, not a correctness issue,
+ and renames are rare enough that the redundancy is not worth a
+ separate "title-parse only" code path.
+ """
+ for entry in updates:
+ if entry.type != "video":
+ 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))
+ # The rename re-derives the title (the whole point of this
+ # method), which can change the correct TMDB match — but
+ # file_tmdb is keyed by content hash, unchanged by a rename, so
+ # nothing else would ever dislodge the old name's match. A
+ # manual "Fix match" correction is kept (clear_file_tmdb skips
+ # anything in media_cache.tmdb_override).
+ if self._media_cache is not None:
+ await self._media_cache.clear_file_tmdb(entry.id)
+ await self._enrich_new_video_entries(indexer, updates)
+
+ async def _enrich_new_audio_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
+ """
+ Music app (docs/MESHBAY_DESIGN.md §9.8): fire (never await further)
+ tag/cover enrichment for unattempted audio entries under the
+ group's configured audio_root — same gate as
+ `_enrich_new_video_entries` above (the original "no root, whole
+ shared tree" call turned out wrong against a real messy library:
+ everything under every shared folder got mixed together with no way
+ to scope it down). `_enriched_attempted` is shared with the video
+ path — content-addressed ids never collide across the two.
+ """
+ if not self._audio_enricher or not self._roster:
+ return
+ audio_dirs = await self._roster.app_directories(indexer.group_id, "music")
+ if not audio_dirs:
+ return
+ # Resolved once per directory, not once per file: a library is
+ # thousands of entries and this is a filesystem call each time.
+ boundaries = {d: indexer.roots.resolve(d, require_available=False)
+ for d in audio_dirs}
+ for entry in entries:
+ if entry.type != "audio" or (indexer.group_id, entry.id) in self._enriched_attempted:
+ continue
+ owner = _owning_directory(entry.path, audio_dirs)
+ if owner is None:
+ 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)
+
+ # The boundary is *the configured directory this file is under*,
+ # not the shared root it lives in — so the ancestor walk
+ # (enrich_audio._artist_album_from_ancestors) treats a flat
+ # top-level folder right under the configured Music directory as
+ # ambiguous (artist-or-release, docs/MESHBAY_DESIGN.md §9.8),
+ # rather than one level too shallow when that directory is
+ # itself a subfolder. With several configured, each file is
+ # measured against its own:
+ # a single shared boundary would be wrong for all but one of them.
+ self._audio_enricher.spawn(entry, file_path, on_done,
+ boundaries.get(owner))
+
+ async def _enrich_audio_root_now(self, group_id: str) -> None:
+ """
+ Music app: sweep a group's existing index right after its
+ audio root is set or changed. Mirrors
+ `_enrich_video_root_now` exactly — the ordinary path above only
+ ever looks at entries new since the last broadcast, so a folder
+ that already had files in it before it became the audio_root would
+ otherwise never get enriched at all.
+ """
+ indexer = self._state.get("indexers", {}).get(group_id)
+ if not indexer:
+ return
+ await self._enrich_new_audio_entries(indexer, list(indexer.index.entries))
+
+ async def _reenrich_renamed_audio_entries(
+ self, indexer: DirectoryIndexer, updates: list, previous: GroupIndex,
+ ) -> None:
+ """
+ Music app equivalent of `_reenrich_renamed_video_entries` — a rename
+ can change the filename-parse fallback (title/track_no) even though
+ embedded tags, when present, are unaffected. Re-running the whole
+ pass on a rename is redundant work for a tagged file and a real fix
+ for an untagged one, and renames are rare enough not to need a
+ cheaper, tags-only special case.
+ """
+ for entry in updates:
+ if entry.type != "audio":
+ 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_audio_entries(indexer, updates)
+
+ async def _enrich_new_photo_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
+ """
+ Photos app (docs/MESHBAY_DESIGN.md §9.9): 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_dirs = await self._roster.app_directories(indexer.group_id, "photo")
+ if not photo_dirs:
+ return
+ for entry in entries:
+ if entry.type != "image" or (indexer.group_id, entry.id) in self._enriched_attempted:
+ continue
+ if not _under_any_directory(entry.path, photo_dirs):
+ 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 change. 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 — the media cache is disposable and
+ tied to the index, docs/MESHBAY_DESIGN.md §6.5), 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
+ broadcast so they reach connected clients as an INDEX_DELTA update
+ (GroupIndex.diff's `updates`, not `additions` — same id, new fields).
+
+ Builds a *new* IndexEntry via dataclasses.replace rather than
+ mutating the existing one in place: the diff mechanism compares
+ against a shallow snapshot of entry *references*, so an in-place
+ mutation would silently also change what "previous" looks like,
+ and the change would never show up as a diff (see group_index.py's
+ diff() docstring).
+ """
+ idx = indexer.index
+ entry = idx.get_entry(file_id)
+ if entry is None:
+ return # removed from the index while enrichment was in flight
+ idx.add_entry(replace(entry, **fields))
+ await self._on_index_change(indexer)
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
index dd6b2c0..397f760 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
@@ -442,7 +442,7 @@ class AudioEnricher:
the returned task must be held by the caller for the same reason
`WebRTCPeerSession._spawn` holds streaming tasks (a bare
`ensure_future` can be garbage-collected mid-flight). `root_path` is
- the caller's own root boundary for this entry (daemon.py resolves
+ the caller's own root boundary for this entry (enrichment.py resolves
it via `RootSet.split`) — see `_artist_album_from_ancestors`.
"""
task = asyncio.ensure_future(self._run(entry, file_path, on_done, root_path))