summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/ops/apps.py
blob: 0e4f6dd71d931db4c0463f2b4ef83b009551fef8 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
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}