diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-24 22:24:52 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-24 22:24:52 +0200 |
| commit | 665fb2004e55b72ea483aa50fea81f6a6fd9c322 (patch) | |
| tree | cc412f776cf993fc5c242750787c9e5c81aa3241 /packages/meshbay-node/src/meshbay_node/daemon.py | |
| parent | 626365668508790dee70ab192a7d6c6f14725bf4 (diff) | |
| download | meshbay-665fb2004e55b72ea483aa50fea81f6a6fd9c322.tar.gz | |
feat(node): add audio_root, gate Music enrichment on it like video_root
musicbay.md's original call — Music needs no root, tag reads are cheap so
just cover the whole shared tree — didn't hold up against a real messy
library: everything under every shared folder got mixed together with no
way to scope Music down to an actual music collection. This adds an
audio_root setting, symmetric to video_root in every respect: signed
operator op (audio_root/audio_root_ack, MNP bumped to 0.10), validated
against a real directory in the group's own roots before a signature is
even asked for, gates tag/cover enrichment exactly like video_root gates
ffprobe/TMDB (nothing runs until it's set, only files under it once it
is), and a set/change fires a one-off sweep of whatever the folder already
contains.
The old trigger — sweep everything the instant "music" joins enabled_apps
— is gone along with the root-less design it belonged to; setting
audio_root is now the trigger, mirroring set_video_root's
enrich_video_root_fn exactly.
Test coverage mirrors the video_root suite: policy (refuse before a
signature round trip, accept/store correctly) and the enrichment gate
itself (nothing without a root, only files under it, sweep on set).
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 74 |
1 files changed, 42 insertions, 32 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index ae4e1f0..0015f1a 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -73,6 +73,12 @@ def _under_video_root(path: str, video_root: str) -> bool: return path == video_root or path.startswith(video_root + "/") +def _under_audio_root(path: str, audio_root: str) -> bool: + """Mirrors music-app.js's underAudioRoot — same shape as _under_video_root.""" + path = path or "" + return path == audio_root or path.startswith(audio_root + "/") + + # ── Argon2id calibration ────────────────────────────────────────────────────── def calibrate_argon2(target_ms: int = 500) -> None: @@ -340,6 +346,9 @@ class NodeDaemon: # group — "" means the whole group index. "video_root": await self._roster.video_root( group_cfg.id) if self._roster else "", + # Same shape, Music app's own entry point. + "audio_root": await self._roster.audio_root( + 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" @@ -541,7 +550,7 @@ class NodeDaemon: self._state["hub"] = hub self._state["reload_fn"] = self._reload_config self._state["enrich_video_root_fn"] = self._enrich_video_root_now - self._state["enrich_music_now_fn"] = self._enrich_music_now + self._state["enrich_audio_root_fn"] = self._enrich_audio_root_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 @@ -750,6 +759,9 @@ class NodeDaemon: "video_root": ( await self._roster.video_root(group_cfg.id) if self._roster else ""), + "audio_root": ( + await self._roster.audio_root(group_cfg.id) + if self._roster else ""), "tmdb_enabled": ( await self._roster.tmdb_enabled(group_cfg.id) if self._roster else True), @@ -988,11 +1000,9 @@ class NodeDaemon: # INDEX_DELTA update (_on_enriched below). new_entries = delta.additions if delta is not None else list(idx.entries) asyncio.ensure_future(self._enrich_new_video_entries(indexer, new_entries)) - # Music app (docs/musicbay.md §6): same shape, gated on the group's - # enabled_apps rather than a root (Music has no video_root analogue — - # see musicbay.md §2.1's note on why that scoping wasn't carried - # over). Tag/cover extraction is local and cheap either way; the gate - # exists to not do it at all for a group that never turned Music on. + # Music app (docs/musicbay.md §6): same shape, gated on audio_root + # 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)) # A rename/move changes the very filename (or season folder) that @@ -1147,49 +1157,49 @@ class NodeDaemon: async def _enrich_new_audio_entries(self, indexer: DirectoryIndexer, entries: list) -> None: """ Music app (docs/musicbay.md §2.1, §6): fire (never await further) - tag/cover enrichment for unattempted audio entries, gated on the - group having "music" in its enabled_apps — there is no video_root - analogue for Music (musicbay.md §2.1 deliberately didn't add one: - tag reads are free/local, unlike ffprobe+ffmpeg thumbnailing, so the - cost this gate protects against is smaller, and most personal MP3 - libraries want their whole shared tree available rather than one - scoped subfolder). `_enriched_attempted` is shared with the video - path — content-addressed ids never collide across the two. + tag/cover enrichment for unattempted audio entries under the + group's configured audio_root — same gate as + `_enrich_new_video_entries` above (musicbay.md's 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 - enabled_apps = await self._roster.enabled_apps(indexer.group_id) - if "music" not in enabled_apps: + audio_root = await self._roster.audio_root(indexer.group_id) + if not audio_root: return + root_boundary = indexer.roots.resolve(audio_root, require_available=False) for entry in entries: if entry.type != "audio" or entry.id in self._enriched_attempted: continue + if not _under_audio_root(entry.path, audio_root): + continue file_path = entry_abs_path(indexer.roots, entry) if not file_path or not file_path.exists(): continue self._enriched_attempted.add(entry.id) - # The entry's own named root, so the ancestor walk - # (enrich_audio._artist_album_from_ancestors) can tell "a - # top-level folder under this root" from "one level deeper" — - # without this boundary it used to read the root's own - # directory name as an artist for every flat top-level folder. - split = indexer.roots.split(entry.path) - root_path = split[0].path if split else None async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None: await self._on_enriched(_indexer, file_id, fields) - self._audio_enricher.spawn(entry, file_path, on_done, root_path) + # `root_boundary` — audio_root itself, not the shared root it + # lives under — so the ancestor walk + # (enrich_audio._artist_album_from_ancestors) treats a flat + # top-level folder right under the *configured* Music root as + # ambiguous (artist-or-release, §2.1), not one level too shallow + # if audio_root is itself a subfolder of a larger shared root. + self._audio_enricher.spawn(entry, file_path, on_done, root_boundary) - async def _enrich_music_now(self, group_id: str) -> None: + async def _enrich_audio_root_now(self, group_id: str) -> None: """ - Music app: sweep a group's existing index right after "music" is - added to its enabled_apps (ops.set_enabled_apps) — the ordinary path - above only ever looks at entries new since the last broadcast, so a - library that was already sitting there before Music was turned on - would otherwise never get enriched. Mirrors - `_enrich_video_root_now`, triggered from a different setting because - Music has no root of its own to key off. + Music app: sweep a group's existing index right after its + audio_root is set or changed (ops.set_audio_root). 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: |