diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 151 |
1 files changed, 97 insertions, 54 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(): |