summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ops/apps.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-25 01:30:47 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-25 01:30:47 +0200
commit762233772162a05be67432aa551a430b939250de (patch)
tree311a2f72fef30fec6017313c55ed5beefcf0237e /packages/meshbay-node/src/meshbay_node/ops/apps.py
parent320620a18399eb43c4d9056e9fe4c3ffac8fcfd4 (diff)
downloadmeshbay-762233772162a05be67432aa551a430b939250de.tar.gz
refactor(node): split ops.py into the ops package
Each section of ops.py becomes a module of meshbay_node/ops/ (core, node_toml, members, chat, groups, roots, files, settings, apps), cut as text; ops/__init__.py keeps the docstring and re-exports every name, so `ops.<name>` is unchanged for every caller. Logger name unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ops/apps.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/apps.py295
1 files changed, 295 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ops/apps.py b/packages/meshbay-node/src/meshbay_node/ops/apps.py
new file mode 100644
index 0000000..0e4f6dd
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/ops/apps.py
@@ -0,0 +1,295 @@
+"""Per-group application settings: which apps, their directories, TMDB, MusicBrainz, chat."""
+
+from __future__ import annotations
+
+import logging
+
+from meshbay_common.background import spawn
+
+from meshbay_node.ops.core import OpError, _group_ctx, _roster
+from meshbay_node.roots import RootSet
+
+log = logging.getLogger("meshbay_node.ops")
+
+
+# ── Applications ─────────────────────────────────────────────────────────────
+
+async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict:
+ """
+ Which group "applications" (Chat, Files, ...) are shown to members.
+
+ Same shape as other signed ops: lives on the node (roster.db), takes
+ effect without a restart, and is signed by the operator (webrtc_server.py
+ checks the caller's own admin-authority allow-list before this runs).
+ """
+ roster = _roster(state)
+ ctx = _group_ctx(state, group_id)
+ # See the same guard in webrtc/group_ops.py _do_apps_enabled: Files cannot be
+ # turned off, and both writers put it at the front so the two agree.
+ if "files" not in apps:
+ apps = ["files"] + list(apps)
+ await roster.set_enabled_apps(group_id, apps,
+ set_by=state.get("node_user_id", ""))
+ ctx["enabled_apps"] = apps
+ log.info("Enabled apps for group %s: %s", group_id[:8], ",".join(sorted(apps)))
+ return {"apps": apps, "group_id": group_id}
+
+
+# ── TMDB config (Videos app) ─────────────────────────────────────────────────
+
+async def set_tmdb_config(state: dict, token: str | None = None,
+ language: str | None = None) -> dict:
+ """
+ Whether the node uses a custom API token instead of the shipped default,
+ and in what language it queries TMDB (docs/MESHBAY_DESIGN.md §9.7).
+
+ Node-wide (roster.py group_settings, group_id="") rather than per-group
+ like set_enabled_apps: the token and the shared-cache
+ language are one operator's budget and one credential, not a per-group
+ or per-viewer concern. Whether TMDB is used *at all* is the per-group
+ decision set_tmdb_enabled below makes instead. `token=""` explicitly
+ clears a previously-set custom token (reverts to the shipped default);
+ `token=None` leaves whatever was there unchanged. Same discipline for
+ `language`.
+ """
+ roster = _roster(state)
+ await roster.set_tmdb_config(token, language, set_by=state.get("node_user_id", ""))
+ # `token=None` means "leave whatever was there" (§ set_tmdb_config's own
+ # docstring) — so the customized flag only changes when a value (a real
+ # token, or "" to clear one) was actually given.
+ if token is not None:
+ state["tmdb_token_customized"] = bool(token)
+ if language is not None:
+ state["tmdb_language"] = language
+ log.info("TMDB config: custom_token=%s language=%s",
+ bool(token), language or state.get("tmdb_language", ""))
+ return {
+ "token_customized": state.get("tmdb_token_customized", False),
+ "language": state.get("tmdb_language", ""),
+ }
+
+
+async def set_tmdb_enabled(state: dict, group_id: str, enabled: bool) -> dict:
+ """
+ Whether TMDB lookups run for this group at all (docs/MESHBAY_DESIGN.md
+ §9.7) — per-group, unlike set_tmdb_config above: an operator running a
+ real media library alongside test/demo groups on one node wants
+ outbound TMDB traffic (and API quota) spent for the one that needs it,
+ not all of them just because one process serves both.
+ """
+ roster = _roster(state)
+ ctx = _group_ctx(state, group_id)
+ await roster.set_tmdb_enabled(group_id, enabled, set_by=state.get("node_user_id", ""))
+ ctx["tmdb_enabled"] = enabled
+ log.info("TMDB enabled for group %s: %s", group_id[:8], enabled)
+ return {"enabled": enabled, "group_id": group_id}
+
+
+# ── MusicBrainz config (Music app) ───────────────────────────────────────────
+
+# set_musicbrainz_config removed — MusicBrainz contact is now the owner's
+# hub email, resolved at login (daemon.py / musicbrainz.py).
+
+async def set_musicbrainz_enabled(state: dict, group_id: str, enabled: bool) -> dict:
+ """
+ Whether MusicBrainz lookups run for this group at all
+ (docs/MESHBAY_DESIGN.md §9.8) — per-group from the start, same reasoning as
+ set_tmdb_enabled: a real media-library group and a test/demo group on
+ one node need not share the decision to make outbound requests.
+ """
+ roster = _roster(state)
+ ctx = _group_ctx(state, group_id)
+ await roster.set_musicbrainz_enabled(group_id, enabled, set_by=state.get("node_user_id", ""))
+ ctx["musicbrainz_enabled"] = enabled
+ log.info("MusicBrainz enabled for group %s: %s", group_id[:8], enabled)
+ return {"enabled": enabled, "group_id": group_id}
+
+
+# ── App directories ──────────────────────────────────────────────────────────
+
+def _validate_app_dirs(state: dict, group_id: str, paths: list[str], *,
+ require_writable: bool) -> list[str]:
+ """
+ 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.
+
+ 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.
+ """
+ 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_app_directories(state: dict, group_id: str, app_key: str,
+ paths: list[str], *,
+ require_writable: bool = False) -> dict:
+ """
+ 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 — one per app differing only in the key it wrote
+ and whether it 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)
+ 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
+ # An app whose directories are also published under a second name (chat's
+ # single destination) has that name re-derived here: leaving it behind
+ # would make the two disagree 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:
+ spawn(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 ""}
+
+
+# ── Chat ─────────────────────────────────────────────────────────────────────
+
+async def set_chat_directory(state: dict, group_id: str, path: str) -> dict:
+ """
+ Where chat attachments are written.
+
+ `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_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}
+
+
+async def set_search_listed(state: dict, group_id: str, listed: bool) -> dict:
+ """
+ Whether this group's files appear in members' cross-group Search.
+
+ A presentation choice, and it must never be described as more: a member
+ still lists the whole group by opening it, the node serves the index
+ exactly as before, and a client that ignores the flag lists the group in
+ Search too. What it buys is a family album not turning up in the middle of
+ a film library. Absent means listed.
+ """
+ roster = _roster(state)
+ ctx = _group_ctx(state, group_id)
+ await roster.set_search_listed(group_id, listed,
+ set_by=state.get("node_user_id", ""))
+ ctx["search_listed"] = listed
+ log.info("Search listing for group %s: %s", group_id[:8],
+ "on" if listed else "off")
+ return {"listed": listed, "group_id": group_id}
+
+
+# ── Videos: force TMDB re-matching ───────────────────────────────────────────
+#
+# `media_cache.file_tmdb` is keyed by a file's content hash and is otherwise
+# only pruned on deletion, so a fixed matcher/parser never dislodges a match
+# already in cache. This drops a group's *auto-resolved* mappings so the
+# next `media_meta_req` for each poster tile re-resolves against the current
+# code. Re-resolution is lazy and calls TMDB once per unique title — real
+# API budget — so this is an explicit operator action, never a background job.
+# Manual "Fix match" corrections (media_cache.tmdb_override) are kept.
+
+async def rematch_video(state: dict, group_id: str) -> dict:
+ media_cache = state.get("media_cache")
+ if media_cache is None:
+ raise OpError("No media cache in this process", status=503)
+ indexer = (state.get("indexers") or {}).get(group_id)
+ if indexer is None:
+ raise OpError("Unknown group", status=404)
+ file_ids = [e.id for e in indexer.index.entries if e.type == "video"]
+ removed = await media_cache.clear_tmdb_matches(file_ids)
+ log.info("Video rematch for group %s: %d auto match(es) cleared across %d video file(s)",
+ group_id[:8], removed, len(file_ids))
+ return {"status": "cleared", "removed": removed, "videos": len(file_ids),
+ "group_id": group_id}