aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js2
-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
-rw-r--r--packages/meshbay-node/tests/test_audio_root_gates_enrichment.py2
-rw-r--r--packages/meshbay-node/tests/test_video_root_gates_enrichment.py2
7 files changed, 307 insertions, 296 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
index 1dbecbb..764331a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
@@ -54,7 +54,7 @@ function foldKey(s) {
// Same shape as video-app.js's underVideoRoot: an unset root means "show
// nothing" (docs/MESHBAY_DESIGN.md §9.8 — the node itself runs no
// tag/cover enrichment for this group before a root is chosen either,
-// daemon.py's _enrich_new_audio_entries), not "the whole shared tree" —
+// enrichment.py's _enrich_new_audio_entries), not "the whole shared tree" —
// falling back to that would just show files nothing has enriched.
function underAudioRoot(entry, directories) {
const dirs = directories || [];
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
index d4ec01a..5f7e9ea 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -55,7 +55,7 @@ function yearOf(dateStr) {
// Nothing shows until an operator has actually chosen a root in Settings
// (§5.6/V-whatever this is now): the node itself runs no TMDB/thumbnail
-// work for this group before that either (daemon.py's
+// work for this group before that either (enrichment.py's
// _enrich_new_video_entries), so falling back to "the whole index" here
// would just show files nothing has enriched.
function underVideoRoot(entry, directories) {
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))
diff --git a/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py b/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py
index b8c4dd6..3446be9 100644
--- a/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py
+++ b/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py
@@ -1,7 +1,7 @@
"""
Music-app enrichment (tag/cover extraction, docs/MESHBAY_DESIGN.md §9.8) only
ever runs for a group that has an audio_root configured, and only for files
-under it — see daemon.py's _enrich_new_audio_entries. Same reasoning as
+under it — see enrichment.py's _enrich_new_audio_entries. Same reasoning as
Videos' video_root gate (test_video_root_gates_enrichment.py), added later:
the original "no root, whole shared tree" call turned out wrong against a
real messy library, where everything under every shared folder got mixed
diff --git a/packages/meshbay-node/tests/test_video_root_gates_enrichment.py b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py
index c39876a..0e5e631 100644
--- a/packages/meshbay-node/tests/test_video_root_gates_enrichment.py
+++ b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py
@@ -1,7 +1,7 @@
"""
Videos-app enrichment (ffprobe/thumbnailing/TMDB, docs/MESHBAY_DESIGN.md §6.5)
only ever runs for a group that has a video_root configured, and only for
-files under it — see daemon.py's _enrich_new_video_entries. Burning TMDB's
+files under it — see enrichment.py's _enrich_new_video_entries. Burning TMDB's
rate limit and the node's CPU on an operator's whole shared index before
they have chosen which folder is actually their media library would be
real, ongoing cost for files never meant to be in the Videos app at all.