aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-06 18:16:57 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-06 18:16:57 +0200
commit85a2ec47b7ad334208a3dbb091fadccc7631785c (patch)
treea75ff6c12b8229d2dc8082bb36ebc5b3de73b706 /packages/meshbay-node/src/meshbay_node
parent4e6d6573003b06dd58268602d50a98988d4ce3d0 (diff)
downloadmeshbay-85a2ec47b7ad334208a3dbb091fadccc7631785c.tar.gz
feat(node): Phase 2 server side — one directory setting for every app
`video_root` (a string), `audio_root` (a string) and `photo_roots` (a list) said the same thing three ways: three roster accessors, three ops, three MNP messages, three admin-op subjects. They become `set_app_directories(app_key, paths)` and its single-directory wrapper, stored under `<app>_directories` and keyed by the app's registry name — so an application can be added without touching this layer, which is the whole claim of the plugin architecture. The three old names still work. Their MNP messages are handled, and the roster falls back to the old key when the new one is unset, so a node upgraded into this keeps working with no migration step — the plan called for a script, and a script nobody runs on the machine where it matters is worse than a fallback. Two things are new rather than moved: The paths are validated. The setters this replaces accepted anything, so a typo — or a path left behind when a root was removed — was stored happily and then matched no entry, leaving an app showing an empty tab with nothing to distinguish "misconfigured" from "no files yet". Deliberately not `RootSet.resolve()`: that also refuses a currently-unavailable root, and an operator must be able to point an app at a library on a drive they ejected. The legacy scalar is derived, never stored. `video_root` still rides on the handshake ack for MNP 1.0 clients; kept as a second stored value it would drift from the list within one run, which reads as "it works after a restart". Also here: chat's own two settings (a directory, which must be on a read-write root because it is a destination rather than a view, and a link-preview switch gating the unfurl path — checked before the cache, or turning it off would still serve every preview already fetched), the `app_directories`, `chat_directory` and `chat_link_preview` MNP messages, the plural `<app>_directories` on the handshake ack, and `music` as the app's one identifier where storage said `audio` and the registry said `music`. The Music enricher now resolves a boundary per configured directory rather than one for the group: with several, a single boundary is wrong for all but one of them, and for Music that is the difference between reading a folder as an artist and reading it as a release. Suite: 11 failures, all pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py151
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py201
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py134
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py190
4 files changed, 528 insertions, 148 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():
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index 13a7250..f7f6190 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -1205,76 +1205,171 @@ async def set_musicbrainz_enabled(state: dict, group_id: str, enabled: bool) ->
return {"enabled": enabled, "group_id": group_id}
-async def set_video_root(state: dict, group_id: str, path: str) -> dict:
+# ── App directories ──────────────────────────────────────────────────────────
+
+def _validate_app_dirs(state: dict, group_id: str, paths: list[str], *,
+ require_writable: bool) -> list[str]:
"""
- Which folder (possibly a subfolder of a shared root) is the Videos app's
- entry point for this group. Same shape as set_enabled_apps: lives on the
- node (roster.db), takes effect without a restart, signed by the operator.
- `path=""` clears it — the Videos tab then asks for one to be chosen
- before anything (including TMDB enrichment, docs/mediacenter.md §5.2)
- runs, rather than defaulting to the whole shared index.
+ Every path an app is pointed at must live inside one of the group's roots.
+
+ The per-app setters this replaces validated nothing: a typo, or a path left
+ behind by a root that was removed, was stored and then quietly matched no
+ entry — an app showing an empty tab with no way to tell "misconfigured"
+ from "no files yet". Refusing at the point of setting is the only moment
+ the operator is present to be told.
- A non-empty path fires (never awaits) a sweep of whatever that folder
- already contains: the ordinary per-change enrichment path only ever
- looks at files new since the last broadcast, so anything already sitting
- in a folder before it became the video_root would otherwise never be
- picked up.
+ Not `RootSet.resolve()`, deliberately: that also refuses a directory whose
+ root is currently *unavailable*, and an operator must be able to configure
+ a library on a drive they have unplugged. What is checked here is the
+ shape — inside a named root, no traversal — which does not change with
+ what happens to be mounted.
"""
- roster = _roster(state)
- ctx = _group_ctx(state, group_id)
- await roster.set_video_root(group_id, path, set_by=state.get("node_user_id", ""))
- ctx["video_root"] = path
- log.info("Videos root for group %s: %r", group_id[:8], path)
- if path:
- enrich_fn = state.get("enrich_video_root_fn")
- if enrich_fn:
- asyncio.ensure_future(enrich_fn(group_id))
- return {"path": path, "group_id": group_id}
+ roots: RootSet | None = _group_ctx(state, group_id).get("roots")
+ if roots is None:
+ raise OpError("Group has no roots", status=503)
+
+ clean: list[str] = []
+ for raw in paths:
+ path = str(raw or "").strip().strip("/")
+ if not path:
+ continue
+ if ".." in path.split("/"):
+ raise OpError(f"{path!r} is not a directory inside this group",
+ status=400)
+ found = roots.split(path)
+ if found is None:
+ raise OpError(
+ f"{path!r} is not inside any of this group's shared "
+ f"directories", status=400,
+ extra={"available": roots.names})
+ root, _tail = found
+ if require_writable and not root.writable:
+ raise OpError(
+ f"{root.name!r} is read-only, and this setting needs a "
+ f"directory that accepts uploads", status=400)
+ clean.append(path)
+ return sorted(set(clean))
-async def set_audio_root(state: dict, group_id: str, path: str) -> dict:
+async def set_app_directories(state: dict, group_id: str, app_key: str,
+ paths: list[str], *,
+ require_writable: bool = False) -> dict:
"""
- Same shape as set_video_root above — the Music app's own entry point,
- added later (docs/musicbay.md's original "no root, works over the
- whole shared tree" simplification didn't hold up against a real messy
- library). `path=""` clears it — the Music tab then asks for one to be
- chosen before anything (including tag/cover enrichment) runs, rather
- than defaulting to the whole shared index.
+ Which folder(s) inside the group's shared roots an application works over.
+
+ One function for every app, keyed by the app's own name: adding an
+ application is a registry entry and a settings component, not another
+ near-identical op here. It replaces `set_video_root`, `set_audio_root` and
+ `set_photo_roots`, which differed only in the key they wrote and whether
+ they took a string or a list.
+
+ Empty means nothing configured, which every app reads as "show nothing
+ until an operator has chosen" — never "the whole group index". Pointing an
+ app at the whole library is a decision, not a default nobody made.
+
+ A change always fires (never awaits) a sweep of what the new directories
+ already contain: the ordinary per-change enrichment path only looks at
+ entries new since the last broadcast, so files already sitting in a folder
+ when it was chosen would otherwise never be picked up.
"""
roster = _roster(state)
ctx = _group_ctx(state, group_id)
- await roster.set_audio_root(group_id, path, set_by=state.get("node_user_id", ""))
- ctx["audio_root"] = path
- log.info("Music root for group %s: %r", group_id[:8], path)
- if path:
- enrich_fn = state.get("enrich_audio_root_fn")
- if enrich_fn:
- asyncio.ensure_future(enrich_fn(group_id))
- return {"path": path, "group_id": group_id}
+ clean = _validate_app_dirs(state, group_id, paths,
+ require_writable=require_writable)
+ await roster.set_app_directories(group_id, app_key, clean,
+ set_by=state.get("node_user_id", ""))
+ ctx[f"{app_key}_directories"] = clean
+ # The scalar the handshake ack still publishes for MNP 1.0 clients is
+ # derived, and has to be re-derived here: leaving it behind would make the
+ # ack disagree with the list within a single run, and only until a restart
+ # — the shape of bug that reads as "it works after a restart".
+ from meshbay_node.roster import Roster
+ alias = Roster.ctx_alias(app_key, clean)
+ if alias:
+ ctx[alias[0]] = alias[1]
+ log.info("%s directories for group %s: %s", app_key, group_id[:8],
+ ", ".join(clean) or "(none)")
+
+ enrich = (state.get("enrich_app_dirs_fns") or {}).get(app_key)
+ if enrich:
+ asyncio.ensure_future(enrich(group_id))
+ return {"app": app_key, "directories": clean, "group_id": group_id}
+
+
+async def set_app_directory(state: dict, group_id: str, app_key: str,
+ path: str, *,
+ require_writable: bool = False) -> dict:
+ """
+ The single-directory form, for an app that only ever wants one.
+
+ Stored as a one-element list like every other app, because two storage
+ shapes for one idea is what made `video_root` (scalar) and `photo_roots`
+ (list) need separate ops, separate MNP messages and separate widgets to
+ say the same thing. `path=""` clears it.
+ """
+ result = await set_app_directories(
+ state, group_id, app_key, [path] if path else [],
+ require_writable=require_writable)
+ dirs = result["directories"]
+ return {**result, "path": dirs[0] if dirs else ""}
+
+
+# The per-app wrappers MNP still names. They exist so an MNP 1.0 client's
+# `video_root` / `audio_root` / `photo_roots` messages keep working; nothing
+# new should be added here — a new app calls the generic pair above.
+
+async def set_video_root(state: dict, group_id: str, path: str) -> dict:
+ result = await set_app_directory(state, group_id, "video", path)
+ return {"path": result["path"], "group_id": group_id}
+
+
+async def set_audio_root(state: dict, group_id: str, path: str) -> dict:
+ # "music", not "audio": the app's registry key is what identifies it
+ # everywhere, and `audio_root` is only the name the setting used to have.
+ result = await set_app_directory(state, group_id, "music", path)
+ return {"path": result["path"], "group_id": group_id}
async def set_photo_roots(state: dict, group_id: str, roots: list[str]) -> dict:
+ result = await set_app_directories(state, group_id, "photo", roots)
+ return {"roots": result["directories"], "group_id": group_id}
+
+
+# ── Chat ─────────────────────────────────────────────────────────────────────
+
+async def set_chat_directory(state: dict, group_id: str, path: str) -> dict:
"""
- Which folder(s) are the Photos app's entry points for this group. Unlike
- `set_video_root`/`set_audio_root`, the whole *set* is replaced in one
- call (docs/photos.md §2.1) — signed once, same shape as
- `set_enabled_apps`, rather than one op per root added/removed.
+ Where chat attachments are written.
- Always fires a sweep, even to an empty list: a root just added needs its
- existing contents enriched (nothing else re-visits already-indexed
- entries), and a root just removed leaves its cache entries harmlessly
- unused rather than needing any cleanup — re-sweeping the new set costs
- nothing when it's empty.
+ `require_writable`, unlike every other app directory: this one is a
+ *destination*, not a view. Pointing it at a read-only root would produce an
+ attachment button that fails at the moment somebody uses it, which is the
+ failure mode the RO/RW model exists to move earlier.
+ """
+ return await set_app_directory(state, group_id, "chat", path,
+ require_writable=True)
+
+
+async def set_chat_link_preview(state: dict, group_id: str,
+ enabled: bool) -> dict:
+ """
+ Whether the node fetches a page's title and image when a member posts a
+ link.
+
+ Outbound third-party traffic on the operator's connection, caused by a
+ message they did not write and pointing at a URL they did not choose — so
+ it is theirs to switch off, on the same reasoning as the per-group TMDB
+ switch. Absent means on, because that is what the node did before this
+ existed.
"""
roster = _roster(state)
ctx = _group_ctx(state, group_id)
- await roster.set_photo_roots(group_id, roots, set_by=state.get("node_user_id", ""))
- ctx["photo_roots"] = roots
- log.info("Photo roots for group %s: %s", group_id[:8], ", ".join(sorted(roots)) or "(none)")
- enrich_fn = state.get("enrich_photo_roots_fn")
- if enrich_fn:
- asyncio.ensure_future(enrich_fn(group_id))
- return {"roots": roots, "group_id": group_id}
+ await roster.set_chat_link_preview(group_id, enabled,
+ set_by=state.get("node_user_id", ""))
+ ctx["chat_link_preview"] = enabled
+ log.info("Chat link previews for group %s: %s", group_id[:8],
+ "on" if enabled else "off")
+ return {"enabled": enabled, "group_id": group_id}
# ── Scan settings ────────────────────────────────────────────────────────────
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index 5f38acd..af87f92 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -661,58 +661,110 @@ class Roster:
await self.set_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_LANGUAGE,
language, set_by)
- # Which folder is the Videos app's entry point for this group — per-group
- # (unlike the token/language above), since different groups share
- # different trees. Empty/unset means the whole group index, exactly as
- # today.
- SETTING_VIDEO_ROOT = "video_root"
-
- async def video_root(self, group_id: str) -> str:
- return await self.get_setting(group_id, self.SETTING_VIDEO_ROOT, "") or ""
+ # ── App directories ─────────────────────────────────────────────────────
+ #
+ # Which folder(s) inside the group's shared roots each application uses as
+ # its entry point. One storage shape for every app, keyed by the app's own
+ # name, so adding an application needs no change here at all — that is the
+ # whole point of the plugin architecture (docs/refactor-groups.md §1.6).
+ #
+ # Always a JSON list, even for an app that only ever wants one directory.
+ # Two shapes for one idea is how `video_root` (scalar) and `photo_roots`
+ # (list) ended up needing separate ops, separate MNP messages and separate
+ # settings widgets to say the same thing.
+ #
+ # Empty/unset means nothing configured yet, and every app reads that as
+ # "show nothing until an operator has chosen" rather than "the whole group
+ # index" — the discipline video_root established, kept.
+ SETTING_APP_DIRS_SUFFIX = "_directories"
- async def set_video_root(self, group_id: str, path: str, set_by: str = "") -> str:
- await self.set_setting(group_id, self.SETTING_VIDEO_ROOT, path or "", set_by)
- return path or ""
+ # What each app's directories used to be stored under, before they were
+ # one shape. Read as a fallback so an existing node keeps working with no
+ # migration step: the legacy key is never written again, and the first
+ # save through the new path leaves it behind.
+ # Keyed by the *registry* name the app is known by everywhere else
+ # (apps.js, ALLOWED_APPS, enabled_apps) — which for Music is "music", while
+ # its old setting was called `audio_root`. One identifier per app, and the
+ # place the two names meet is this table and nowhere else.
+ LEGACY_DIR_KEYS = {
+ "video": ("video_root", "scalar"),
+ "music": ("audio_root", "scalar"),
+ "photo": ("photo_roots", "list"),
+ }
- # Same shape as SETTING_VIDEO_ROOT — the Music app's own entry point,
- # added later (docs/musicbay.md's original "no root, works over the
- # whole shared tree" simplification turned out not to hold up against a
- # real messy library: the operator asked for the same scoping Videos
- # already had). Empty/unset means Music shows nothing yet, exactly like
- # an unset video_root — see daemon.py's enrichment gate.
- SETTING_AUDIO_ROOT = "audio_root"
+ # The name each app's directories are *also* published under, for readers
+ # that predate the list — the handshake ack's `video_root`, and the group
+ # context the ack builds from. Derived from the list, never stored beside
+ # it, so the two cannot disagree; the shape says how to derive it.
+ CTX_ALIASES = {
+ "video": ("video_root", "scalar"),
+ "music": ("audio_root", "scalar"),
+ "photo": ("photo_roots", "list"),
+ "chat": ("chat_directory", "scalar"),
+ }
- async def audio_root(self, group_id: str) -> str:
- return await self.get_setting(group_id, self.SETTING_AUDIO_ROOT, "") or ""
+ @classmethod
+ def app_dirs_key(cls, app_key: str) -> str:
+ return f"{app_key}{cls.SETTING_APP_DIRS_SUFFIX}"
- async def set_audio_root(self, group_id: str, path: str, set_by: str = "") -> str:
- await self.set_setting(group_id, self.SETTING_AUDIO_ROOT, path or "", set_by)
- return path or ""
+ @classmethod
+ def ctx_alias(cls, app_key: str, directories: list[str]) -> tuple[str, object] | None:
+ """The (name, value) an app's directories are also published under."""
+ alias = cls.CTX_ALIASES.get(app_key)
+ if not alias:
+ return None
+ name, shape = alias
+ if shape == "list":
+ return name, list(directories)
+ return name, (directories[0] if directories else "")
- # Which folder(s) are the Photos app's entry points for this group —
- # a *set*, unlike video_root/audio_root above: a photo library is
- # routinely scattered across several unrelated folders (docs/photos.md
- # §2.1), so there is no single natural root to pick. Stored the same way
- # `enabled_apps` already is (json.dumps(sorted(...))). Empty/unset means
- # nothing configured yet — same "show nothing until an operator has
- # chosen" discipline video_root/audio_root already established, not
- # "the whole group index".
- SETTING_PHOTO_ROOTS = "photo_roots"
+ async def app_directories(self, group_id: str, app_key: str) -> list[str]:
+ value = await self.get_setting(group_id, self.app_dirs_key(app_key))
+ if value is not None:
+ try:
+ return [str(p) for p in json.loads(value)]
+ except (ValueError, TypeError):
+ return []
- async def photo_roots(self, group_id: str) -> list[str]:
- value = await self.get_setting(group_id, self.SETTING_PHOTO_ROOTS)
- if value is None:
+ legacy = self.LEGACY_DIR_KEYS.get(app_key)
+ if not legacy:
return []
+ key, shape = legacy
+ raw = await self.get_setting(group_id, key)
+ if raw is None:
+ return []
+ if shape == "scalar":
+ return [raw] if raw else []
try:
- return list(json.loads(value))
+ return [str(p) for p in json.loads(raw)]
except (ValueError, TypeError):
return []
- async def set_photo_roots(self, group_id: str, roots: list[str],
- set_by: str = "") -> list[str]:
- await self.set_setting(group_id, self.SETTING_PHOTO_ROOTS,
- json.dumps(sorted(roots)), set_by)
- return roots
+ async def set_app_directories(self, group_id: str, app_key: str,
+ paths: list[str], set_by: str = "") -> list[str]:
+ clean = sorted({str(p).strip("/") for p in paths if str(p).strip("/")})
+ await self.set_setting(group_id, self.app_dirs_key(app_key),
+ json.dumps(clean), set_by)
+ return clean
+
+ # ── Chat ────────────────────────────────────────────────────────────────
+
+ # Whether the node fetches a page's title/preview when a member posts a
+ # link. Outbound third-party traffic on the operator's connection, from a
+ # message they did not write, so it is theirs to switch off — the same
+ # reasoning as the per-group TMDB switch. Unset means on, because that is
+ # what the node did before this existed.
+ SETTING_CHAT_LINK_PREVIEW = "chat_link_preview"
+
+ async def chat_link_preview(self, group_id: str) -> bool:
+ value = await self.get_setting(group_id, self.SETTING_CHAT_LINK_PREVIEW, "1")
+ return value != "0"
+
+ async def set_chat_link_preview(self, group_id: str, enabled: bool,
+ set_by: str = "") -> bool:
+ await self.set_setting(group_id, self.SETTING_CHAT_LINK_PREVIEW,
+ "1" if enabled else "0", set_by)
+ return enabled
# Whether TMDB lookups run for this group at all — per-group, unlike the
# token/language above: one node process can share a real media library
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index b99affc..c4d053e 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -76,6 +76,9 @@ from meshbay_common.adminop import (
OP_MUSICBRAINZ_ENABLED,
OP_AUDIO_ROOT,
OP_PHOTO_ROOTS,
+ OP_APP_DIRECTORIES,
+ OP_CHAT_DIRECTORY,
+ OP_CHAT_LINK_PREVIEW,
OP_ROOT_ADD,
OP_ROOT_REMOVE,
OP_ROOT_UPDATE,
@@ -484,6 +487,12 @@ class WebRTCPeerSession:
self._do_audio_root(msg)
elif mtype == MNP.PHOTO_ROOTS:
self._do_photo_roots(msg)
+ elif mtype == MNP.APP_DIRECTORIES:
+ self._do_app_directories(msg)
+ elif mtype == MNP.CHAT_DIRECTORY:
+ self._do_chat_directory(msg)
+ elif mtype == MNP.CHAT_LINK_PREVIEW:
+ self._do_chat_link_preview(msg)
elif mtype == MNP.MEDIA_META_REQ:
self._spawn(self._do_media_meta_request(msg))
elif mtype == MNP.SEASON_META_REQ:
@@ -802,6 +811,23 @@ class WebRTCPeerSession:
# (docs/photos.md §2.1). Empty means unset (the Photos tab shows
# nothing yet).
"photo_roots": list(self._group_ctx().get("photo_roots") or []),
+ # The same three answers in one shape, plus every other app's —
+ # `<app>_directories`, keyed by the app's registry name, always a
+ # list. The scalars above are derived from these (daemon.py's
+ # `_app_directories_ctx`) and kept for MNP 1.0 clients, which can
+ # represent one folder and never more. A client reading the plural
+ # form gets all of them.
+ **{f"{app}_directories":
+ list(self._group_ctx().get(f"{app}_directories") or [])
+ for app in ("video", "music", "photo", "chat")},
+ # Where chat attachments are written — the singular form, because
+ # Chat genuinely has one destination. "" means the operator has not
+ # chosen, and the paperclip says so.
+ "chat_directory": self._group_ctx().get("chat_directory") or "",
+ # Whether the node unfurls links members post here. Absent means
+ # on, which is what it did before this existed.
+ "chat_link_preview": bool(
+ self._group_ctx().get("chat_link_preview", True)),
# So a client that connects mid-scan shows the indexing state
# immediately, instead of waiting for the next periodic
# INDEX_PROGRESS push. Never a path or filename — see
@@ -2096,6 +2122,150 @@ class WebRTCPeerSession:
except Exception:
pass
+ # ── App directories (generic) ────────────────────────────────────────
+
+ def _do_app_directories(self, msg: dict) -> None:
+ """
+ Which folder(s) an application works over, for any application.
+
+ One handler where there were three near-identical ones (`video_root`,
+ `audio_root`, `photo_roots`) differing only in the key they wrote and
+ whether they carried a string or a list. Those three still exist for
+ clients that speak them; nothing new is added beside them.
+
+ `app` must be one this node knows (`ALLOWED_APPS`) — a client-supplied
+ key is otherwise a way to write arbitrary rows into `group_settings`.
+ The paths are checked by `ops._validate_app_dirs`, which runs after the
+ signature: this is a settings change, not a capability, so refusing
+ early here would be a courtesy rather than the control.
+ """
+ app = str(msg.get("app", "")).strip()
+ dirs = msg.get("directories")
+ if app not in self.ALLOWED_APPS:
+ self._send({"type": "error", "detail": f"Unknown app {app!r}"})
+ return
+ if not isinstance(dirs, list) or not all(isinstance(d, str) for d in dirs):
+ self._send({"type": "error",
+ "detail": "Missing or invalid 'directories'"})
+ return
+ clean = sorted({d.strip("/") for d in dirs if d.strip("/")})
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ # The app is in the subject, not only the paths: an operator shown
+ # "Media/Films" alone cannot tell which application is about to be
+ # pointed at it, and two apps' challenges would be indistinguishable.
+ self._issue_admin_challenge(
+ OP_APP_DIRECTORIES, f"{app}:{','.join(clean)}")
+
+ async def _admin_exec_app_directories(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ app, _, joined = pending["subject"].partition(":")
+ dirs = joined.split(",") if joined else []
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"app_directories:{pending['subject']}")
+ return
+ try:
+ result = await self._run_op(
+ ops.set_app_directories, self._group_id or "", app, dirs)
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("app_directories", pending["subject"])
+ self._broadcast_to_group({"type": MNP.APP_DIRECTORIES_ACK,
+ "v": MNP_VERSION, "app": app,
+ "directories": result["directories"]})
+
+ # ── Chat ─────────────────────────────────────────────────────────────
+
+ def _do_chat_directory(self, msg: dict) -> None:
+ """
+ Where chat attachments are written.
+
+ Unlike every other app directory this one is a destination, so it has
+ to be on a read-write root — checked by `ops.set_chat_directory` after
+ the signature, which is where the refusal actually lives.
+ """
+ path = msg.get("path")
+ if not isinstance(path, str):
+ self._send({"type": "error", "detail": "Missing or invalid 'path'"})
+ return
+ path = path.strip("/")
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ self._issue_admin_challenge(OP_CHAT_DIRECTORY, path)
+
+ async def _admin_exec_chat_directory(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ path = pending["subject"]
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"chat_directory:{path}")
+ return
+ try:
+ await self._run_op(
+ ops.set_chat_directory, self._group_id or "", path)
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("chat_directory", path)
+ self._broadcast_to_group(
+ {"type": MNP.CHAT_DIRECTORY_ACK, "v": MNP_VERSION, "path": path})
+
+ def _do_chat_link_preview(self, msg: dict) -> None:
+ """
+ Whether the node fetches a page's title and image when a member posts
+ a link — outbound traffic on the operator's connection, from a message
+ they did not write, so it is signed like everything else that decides
+ what leaves this machine.
+ """
+ enabled = msg.get("enabled")
+ if not isinstance(enabled, bool):
+ self._send({"type": "error", "detail": "Missing or invalid 'enabled'"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ self._issue_admin_challenge(
+ OP_CHAT_LINK_PREVIEW, "on" if enabled else "off")
+
+ async def _admin_exec_chat_link_preview(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ enabled = pending["subject"] == "on"
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed",
+ f"chat_link_preview:{pending['subject']}")
+ return
+ try:
+ await self._run_op(
+ ops.set_chat_link_preview, self._group_id or "", enabled)
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("chat_link_preview", pending["subject"])
+ self._broadcast_to_group({"type": MNP.CHAT_LINK_PREVIEW_ACK,
+ "v": MNP_VERSION, "enabled": enabled})
+
+ def _broadcast_to_group(self, notice: dict) -> None:
+ """
+ Tell everyone connected to this group about a setting that changed.
+
+ Enforcement never depends on this reaching them — the node is what
+ refuses — but a control that stays on screen until the next
+ reconnection is a control people use.
+ """
+ for _uid, session in list(self._peer_registry().items()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
def _do_musicbrainz_enabled(self, msg: dict) -> None:
"""
Whether MusicBrainz lookups run for this group at all. Per-group
@@ -3734,6 +3904,17 @@ class WebRTCPeerSession:
"""
url = msg.get("url")
key = url if isinstance(url, str) else ""
+
+ # Checked before the cache, not after: the operator turning previews
+ # off has to stop serving the ones already fetched too, or the setting
+ # takes effect only for links nobody has posted yet. Refused as an
+ # ordinary miss — the client shows the bare link, which is exactly what
+ # "no preview" looks like for a page that has none.
+ if not self._group_ctx().get("chat_link_preview", True):
+ self._send({"type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION,
+ "url": key, "ok": False})
+ return
+
cached = _link_preview_cache_get(key)
if cached is not None:
self._send({**cached, "type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION})
@@ -4155,6 +4336,15 @@ class WebRTCPeerSession:
elif pending["op"] == OP_ROOT_REMOVE:
self._spawn(
self._admin_exec_root_remove(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_APP_DIRECTORIES:
+ self._spawn(
+ self._admin_exec_app_directories(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_CHAT_DIRECTORY:
+ self._spawn(
+ self._admin_exec_chat_directory(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_CHAT_LINK_PREVIEW:
+ self._spawn(
+ self._admin_exec_chat_link_preview(pending, transcript, sig_bytes))
elif pending["op"] == OP_ROOT_UPDATE:
self._spawn(
self._admin_exec_root_update(pending, transcript, sig_bytes))