aboutsummaryrefslogtreecommitdiffstats
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.py151
1 files changed, 97 insertions, 54 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 4fc07ad..7637b41 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -70,26 +70,36 @@ if WEBRTC_AVAILABLE:
log = logging.getLogger(__name__)
-def _under_video_root(path: str, video_root: str) -> bool:
- """Mirrors video-app.js's underVideoRoot: same folder, or a descendant."""
- path = path or ""
- return path == video_root or path.startswith(video_root + "/")
-
+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.
-def _under_audio_root(path: str, audio_root: str) -> bool:
- """Mirrors music-app.js's underAudioRoot — same shape as _under_video_root."""
+ 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 path == audio_root or path.startswith(audio_root + "/")
+ return any(path == d or path.startswith(d + "/") for d in directories)
-def _under_any_photo_root(path: str, photo_roots: list[str]) -> bool:
+def _owning_directory(path: str, directories: list[str]) -> str | None:
"""
- 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.
+ 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 ""
- return any(path == r or path.startswith(r + "/") for r in photo_roots)
+ 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
# ── Argon2id calibration ──────────────────────────────────────────────────────
@@ -396,19 +406,15 @@ class NodeDaemon:
# which the RootSet above already carries.)
"enabled_apps": await self._roster.enabled_apps(
group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS),
- # Which folder is the Videos app's entry point for this
- # 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 "",
- # 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 [],
+ # Which folder(s) inside the shared roots each app works
+ # over. One shape for every app (roster.py's
+ # app_directories) — an empty list means nothing has been
+ # chosen, which every app reads as "show nothing yet",
+ # never "the whole group index".
+ **(await self._app_directories_ctx(group_cfg.id)),
+ # Whether the node unfurls links members post here.
+ "chat_link_preview": await self._roster.chat_link_preview(
+ group_cfg.id) if self._roster else True,
# 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"
@@ -631,9 +637,14 @@ class NodeDaemon:
self._state["quic_server"] = self._quic_server
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_audio_root_fn"] = self._enrich_audio_root_now
- self._state["enrich_photo_roots_fn"] = self._enrich_photo_roots_now
+ # Keyed by app, so `ops.set_app_directories` finds the right
+ # sweep without knowing which apps exist — an app with nothing to
+ # enrich simply has no entry.
+ self._state["enrich_app_dirs_fns"] = {
+ "video": self._enrich_video_root_now,
+ "music": self._enrich_audio_root_now,
+ "photo": 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
@@ -854,15 +865,10 @@ class NodeDaemon:
"enabled_apps": (
await self._roster.enabled_apps(group_cfg.id)
if self._roster else list(Roster.DEFAULT_APPS)),
- "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 ""),
- "photo_roots": (
- await self._roster.photo_roots(group_cfg.id)
- if self._roster else []),
+ **(await self._app_directories_ctx(group_cfg.id)),
+ "chat_link_preview": (
+ await self._roster.chat_link_preview(group_cfg.id)
+ if self._roster else True),
"tmdb_enabled": (
await self._roster.tmdb_enabled(group_cfg.id)
if self._roster else True),
@@ -1076,6 +1082,36 @@ class NodeDaemon:
spec["ejected"] = True
return RootSet.build(specs)
+ # Every app that keeps directories. Not derived from `enabled_apps`: the
+ # context is read once at load and an app enabled later must not find its
+ # own setting missing. Adding an app adds a name here and nowhere else on
+ # this side.
+ APP_DIR_KEYS = ("video", "music", "photo", "chat")
+
+ async def _app_directories_ctx(self, group_id: str) -> dict:
+ """
+ Each app's configured directories, plus the legacy scalar names the
+ rest of the tree still reads.
+
+ The scalars are derived here rather than stored, so the two can never
+ disagree: `video_root` is the first of `video_directories` and exists
+ for MNP 1.0 clients and for the handful of call sites that predate the
+ list. A group with several video directories reports the first as its
+ `video_root` — which is what an old client can represent, and all it
+ could ever have shown.
+ """
+ dirs = {}
+ for app in self.APP_DIR_KEYS:
+ dirs[f"{app}_directories"] = (
+ await self._roster.app_directories(group_id, app)
+ if self._roster else [])
+ aliases = {}
+ for app in self.APP_DIR_KEYS:
+ alias = Roster.ctx_alias(app, dirs[f"{app}_directories"])
+ if alias:
+ aliases[alias[0]] = alias[1]
+ return {**dirs, **aliases}
+
def _eject_persister(self, group_id: str):
"""`on_root_ejected` bound to one group, for that group's indexer."""
async def persist(root_name: str, ejected: bool) -> None:
@@ -1249,13 +1285,13 @@ class NodeDaemon:
"""
if not self._enricher or not self._roster:
return
- video_root = await self._roster.video_root(indexer.group_id)
- if not video_root:
+ 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_video_root(entry.path, video_root):
+ 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():
@@ -1330,14 +1366,18 @@ class NodeDaemon:
"""
if not self._audio_enricher or not self._roster:
return
- audio_root = await self._roster.audio_root(indexer.group_id)
- if not audio_root:
+ audio_dirs = await self._roster.app_directories(indexer.group_id, "music")
+ if not audio_dirs:
return
- root_boundary = indexer.roots.resolve(audio_root, require_available=False)
+ # 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
- if not _under_audio_root(entry.path, audio_root):
+ 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():
@@ -1347,13 +1387,16 @@ class NodeDaemon:
async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None:
await self._on_enriched(_indexer, file_id, fields)
- # `root_boundary` — audio_root itself, not the shared root it
- # lives under — so the ancestor walk
+ # 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 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)
+ # top-level folder right under the configured Music directory as
+ # ambiguous (artist-or-release, musicbay.md §2.1), 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:
"""
@@ -1403,13 +1446,13 @@ class NodeDaemon:
"""
if not self._photo_enricher or not self._roster:
return
- photo_roots = await self._roster.photo_roots(indexer.group_id)
- if not photo_roots:
+ 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_photo_root(entry.path, photo_roots):
+ 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():