diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ops/apps.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ops/apps.py | 295 |
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} |