"""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}