aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/bundle_store.py4
-rw-r--r--packages/meshbay-node/src/meshbay_node/chat/store.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py77
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich.py64
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py7
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py7
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/indexer.py13
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/title_parse.py31
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py36
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_probe.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/musicbrainz.py6
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py13
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py16
-rw-r--r--packages/meshbay-node/src/meshbay_node/tmdb.py10
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py208
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py2
-rw-r--r--packages/meshbay-node/tests/conftest.py4
-rw-r--r--packages/meshbay-node/tests/test_audio_root_gates_enrichment.py9
-rw-r--r--packages/meshbay-node/tests/test_audio_transcode.py38
-rw-r--r--packages/meshbay-node/tests/test_bundle_store_recovery.py2
-rw-r--r--packages/meshbay-node/tests/test_chat_encryption.py2
-rw-r--r--packages/meshbay-node/tests/test_chat_history_binary.py2
-rw-r--r--packages/meshbay-node/tests/test_chat_multidevice.py5
-rw-r--r--packages/meshbay-node/tests/test_cli_dispatch.py2
-rw-r--r--packages/meshbay-node/tests/test_device_on_connection.py7
-rw-r--r--packages/meshbay-node/tests/test_disk_io_off_loop.py62
-rw-r--r--packages/meshbay-node/tests/test_enrich.py2
-rw-r--r--packages/meshbay-node/tests/test_enrich_photo.py2
-rw-r--r--packages/meshbay-node/tests/test_group_roster.py6
-rw-r--r--packages/meshbay-node/tests/test_media_cache.py2
-rw-r--r--packages/meshbay-node/tests/test_musicbrainz.py5
-rw-r--r--packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py7
-rw-r--r--packages/meshbay-node/tests/test_packaging_win.py39
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py2
-rw-r--r--packages/meshbay-node/tests/test_season_and_search_requests.py9
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py12
-rw-r--r--packages/meshbay-node/tests/test_startup_scan_enrichment.py4
-rw-r--r--packages/meshbay-node/tests/test_stream_video_transcode.py4
-rw-r--r--packages/meshbay-node/tests/test_title_parse.py12
-rw-r--r--packages/meshbay-node/tests/test_tmdb.py2
-rw-r--r--packages/meshbay-node/tests/test_tmdb_config_policy.py5
-rw-r--r--packages/meshbay-node/tests/test_tmdb_enabled_policy.py2
-rw-r--r--packages/meshbay-node/tests/test_tmdb_rematch_policy.py2
-rw-r--r--packages/meshbay-node/tests/test_tmdb_search_bound.py186
-rw-r--r--packages/meshbay-node/tests/test_tmdb_search_ladder.py4
-rw-r--r--packages/meshbay-node/tests/test_transfer_settings.py3
-rw-r--r--packages/meshbay-node/tests/test_video_root_gates_enrichment.py2
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py3
48 files changed, 682 insertions, 264 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/bundle_store.py b/packages/meshbay-node/src/meshbay_node/bundle_store.py
index c65b94d..36792ac 100644
--- a/packages/meshbay-node/src/meshbay_node/bundle_store.py
+++ b/packages/meshbay-node/src/meshbay_node/bundle_store.py
@@ -6,7 +6,7 @@ Keypair bundles: AES-GCM encrypted (Ed25519 + X25519) private keys, encrypted
with the user's password-derived bundle_key. Opaque to the node. An optional
second copy (bundle_enc_recovery) is wrapped under the account's recovery key
instead, so a forgotten passphrase does not strand the identity — see
-docs/auth-confirm.md §4.3.
+docs/MESHBAY_DESIGN.md §3.6.
Both are stored and served over the P2P DataChannel during MNP handshake.
"""
@@ -168,7 +168,7 @@ class BundleStore:
copy wrapped under the account's recovery key.
A call that omits bundle_enc_recovery — a plain re-backup, or a
- passphrase-change re-wrap (docs/auth-confirm.md §3.2) — must not erase a
+ passphrase-change re-wrap (docs/MESHBAY_DESIGN.md §3.6) — must not erase a
recovery copy already stored, so the upsert keeps the existing value
when the new one is None.
"""
diff --git a/packages/meshbay-node/src/meshbay_node/chat/store.py b/packages/meshbay-node/src/meshbay_node/chat/store.py
index 9e5ee90..17cd68e 100644
--- a/packages/meshbay-node/src/meshbay_node/chat/store.py
+++ b/packages/meshbay-node/src/meshbay_node/chat/store.py
@@ -4,7 +4,7 @@ MeshBay Node — SQLite-backed chat message store.
One database per group. The node is a relay and an archive: it stores what it
was handed, serves it back, and — once a group has chat encryption switched on —
cannot read any of it. Decryption happens in the client, which is the only place
-that holds the epoch key (`docs/chat-sender-keys.md` §5).
+that holds the epoch key (`docs/MESHBAY_DESIGN.md` §4.5).
Three things about the schema are load-bearing rather than incidental:
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 478faae..3492d03 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -192,7 +192,7 @@ class NodeDaemon:
# failure — a persistently unprobeable file (corrupt, still being
# written) does not get re-queued on every coalesced broadcast. A
# restart retries everything, matching the "disposable, rebuildable"
- # stance the rest of this cache takes (docs/mediacenter.md §1/§2).
+ # stance the rest of this cache takes (docs/MESHBAY_DESIGN.md §6.5).
# Shared across the video and audio enrichment paths — content-
# addressed ids never collide between the two. Keyed by
# (group_id, entry.id), not entry.id alone: the id is a content
@@ -438,7 +438,7 @@ class NodeDaemon:
"tmdb_enabled": await self._roster.tmdb_enabled(
group_cfg.id) if self._roster else True,
# Music app equivalent of tmdb_enabled — per-group from
- # the start (docs/musicbay.md §6).
+ # the start (docs/MESHBAY_DESIGN.md §9.8).
"musicbrainz_enabled": await self._roster.musicbrainz_enabled(
group_cfg.id) if self._roster else True,
}
@@ -466,7 +466,7 @@ class NodeDaemon:
# 6b. Media cache (Videos app — TMDB metadata + thumbnails).
# Node-wide like audit.db, not per-group: a thumbnail is the same
# bytes regardless of which group happens to share the file
- # (docs/mediacenter.md §2/§5.5).
+ # (docs/MESHBAY_DESIGN.md §6.5, §9.7).
media_cache_db = data_dir / "media_cache.db"
self._media_cache = MediaCache(db_path=media_cache_db)
await self._media_cache.open()
@@ -483,16 +483,18 @@ class NodeDaemon:
self._state["tmdb_token_customized"] = bool(tmdb_token)
self._state["tmdb_language"] = tmdb_language or ""
- # 6c. Music app (docs/musicbay.md) — same media_cache.db, its own
- # enricher (mutagen, not ffmpeg) and its own MusicBrainz client.
+ # 6c. Music app (docs/MESHBAY_DESIGN.md §9.8) — same
+ # media_cache.db, its own enricher (mutagen, not ffmpeg) and its
+ # own MusicBrainz client.
# The User-Agent contact is the owner's hub email, resolved at
# login — no roster setting or env var needed any more.
self._audio_enricher = AudioEnricher(self._media_cache)
self._musicbrainz_client = MusicBrainzClient(owner_email=session.email)
self._state["musicbrainz_contact_configured"] = bool(session.email)
- # 6d. Photos app (docs/photos.md) — same media_cache.db, its own
- # enricher (Pillow, not ffmpeg/mutagen). No credential, no
+ # 6d. Photos app (docs/MESHBAY_DESIGN.md §9.9) — same
+ # media_cache.db, its own enricher (Pillow, not ffmpeg/mutagen).
+ # No credential, no
# third-party client to construct: EXIF is read locally.
self._photo_enricher = PhotoEnricher(self._media_cache)
self._state["media_cache"] = self._media_cache
@@ -1345,7 +1347,7 @@ class NodeDaemon:
delta = idx.diff(previous)
self._last_broadcast_snapshot[group_id] = (idx.version, idx.entries_by_id())
- # Videos app (docs/mediacenter.md §5.2): schedule async technical
+ # Videos app (docs/MESHBAY_DESIGN.md §6.5): schedule async technical
# probe + title parse + thumbnail generation for every newly-seen
# video entry under the group's configured video_root. Never blocks
# this broadcast — enrichment fields arrive later as their own
@@ -1370,18 +1372,19 @@ class NodeDaemon:
seen = {e.id for e in new_entries}
new_entries = new_entries + [e for e in rebuilt if e.id not in seen]
spawn(self._enrich_new_video_entries(indexer, new_entries))
- # Music app (docs/musicbay.md §6): same shape, gated on audio_root
- # exactly like video_root above (added later — musicbay.md's
- # original "no root, whole shared tree" call didn't hold up).
+ # Music app (docs/MESHBAY_DESIGN.md §9.8): same shape, gated on
+ # audio_root exactly like video_root above (added later — the original
+ # "no root, whole shared tree" call didn't hold up).
spawn(self._enrich_new_audio_entries(indexer, new_entries))
- # Photos app (docs/photos.md §5): same shape, gated on photo_roots
- # (a list, not a single string — §2.1).
+ # Photos app (docs/MESHBAY_DESIGN.md §9.9): same shape, gated on
+ # photo_roots (a list, not a single string).
spawn(self._enrich_new_photo_entries(indexer, new_entries))
# A rename/move changes the very filename (or season folder) that
- # §3.3/§3.4's title-parse read display_title/season/episode from,
- # but leaves the file's content — and so its id and everything
- # ffprobe/thumbnailing already found — untouched. Only entries
+ # docs/MESHBAY_DESIGN.md §9.7's title-parse read
+ # display_title/season/episode from, but leaves the file's content —
+ # and so its id and everything ffprobe/thumbnailing already found —
+ # untouched. Only entries
# whose name or path actually differ from the last broadcast get a
# fresh pass; an update that is enrichment's own field-fill
# (duration/thumb_hash/... landing via _on_enriched below) leaves
@@ -1396,13 +1399,13 @@ class NodeDaemon:
# Videos/Music/Photos apps: a file that leaves the index also loses
# its thumbnail/cover and file->tmdb/file->mbid mapping — the "real
- # deletion obligation" docs/mediacenter.md §2/§8 calls out
- # explicitly rather than leaving implicit (docs/musicbay.md §6
+ # deletion obligation" docs/MESHBAY_DESIGN.md §6.5 calls out
+ # explicitly rather than leaving implicit (docs/MESHBAY_DESIGN.md §9.8
# follows the same rule). tmdb_meta/mbid_meta rows are left alone
- # (§2: shared across files).
+ # (shared across files).
#
- # Found live (docs/photos.md): a root removed and a new one added
- # for the identical content (an operator renaming/relocating a
+ # Found live (docs/MESHBAY_DESIGN.md §9.9): a root removed and a new
+ # one added for the identical content (an operator renaming/relocating a
# shared folder) pruned the thumbnail here — correctly, the content
# is gone from *this* root — but left the hash in
# `_enriched_attempted`, which is never otherwise cleared. The same
@@ -1473,8 +1476,8 @@ class NodeDaemon:
A group with no video_root set yet does not enrich anything — TMDB
lookups and ffmpeg thumbnailing are real, ongoing per-file cost
- (mediacenter.md §5.2/§10), and running them over an operator's whole
- shared index before they have chosen which folder is actually their
+ (docs/MESHBAY_DESIGN.md §6.5), and running them over an operator's
+ whole shared index before they have chosen which folder is actually their
media library would burn both TMDB's rate limit and the node's CPU
on files that were never meant to be in the Videos app at all. Once a
root is set, `_enrich_video_root_now` (called when it changes)
@@ -1553,14 +1556,14 @@ class NodeDaemon:
async def _enrich_new_audio_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
"""
- Music app (docs/musicbay.md §2.1, §6): fire (never await further)
+ Music app (docs/MESHBAY_DESIGN.md §9.8): fire (never await further)
tag/cover enrichment for unattempted audio entries under the
group's configured audio_root — same gate as
- `_enrich_new_video_entries` above (musicbay.md's original "no root,
- whole shared tree" call turned out wrong against a real messy
- library: everything under every shared folder got mixed together
- with no way to scope it down). `_enriched_attempted` is shared with
- the video path — content-addressed ids never collide across the two.
+ `_enrich_new_video_entries` above (the original "no root, whole
+ shared tree" call turned out wrong against a real messy library:
+ everything under every shared folder got mixed together with no way
+ to scope it down). `_enriched_attempted` is shared with the video
+ path — content-addressed ids never collide across the two.
"""
if not self._audio_enricher or not self._roster:
return
@@ -1589,9 +1592,10 @@ class NodeDaemon:
# 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 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:
+ # ambiguous (artist-or-release, docs/MESHBAY_DESIGN.md §9.8),
+ # 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))
@@ -1632,8 +1636,8 @@ class NodeDaemon:
async def _enrich_new_photo_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
"""
- Photos app (docs/photos.md §5): fire (never await further) thumbnail/
- EXIF enrichment for unattempted image entries under any of the
+ Photos app (docs/MESHBAY_DESIGN.md §9.9): fire (never await further)
+ thumbnail/EXIF enrichment for unattempted image entries under any of the
group's configured photo_roots. Same gate shape as
`_enrich_new_video_entries`/`_enrich_new_audio_entries` — no root
configured yet means no work, since thumbnailing every image in a
@@ -1671,8 +1675,9 @@ class NodeDaemon:
so a folder that already had photos in it before it was added to
photo_roots would otherwise never get enriched at all. Also covers
a root being *removed*: nothing un-enriches on removal (the cache
- entry is harmless, just unused — docs/photos.md's cache is
- disposable), so re-sweeping the new set is enough.
+ entry is harmless, just unused — the media cache is disposable and
+ tied to the index, docs/MESHBAY_DESIGN.md §6.5), so re-sweeping the
+ new set is enough.
"""
indexer = self._state.get("indexers", {}).get(group_id)
if not indexer:
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
index 6eeb1ef..b44a246 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
@@ -4,7 +4,7 @@ filename parsing (title_parse), and thumbnail generation (ffmpeg) for a
newly-added video IndexEntry.
Runs through its own small bounded worker pool — separate from the streaming
-transcode pool (docs/mediacenter.md §5.2, mirroring webrtc_server.py's
+transcode pool (docs/MESHBAY_DESIGN.md §6.5, mirroring webrtc_server.py's
`_transcode_semaphore`) — so indexing a large library never blocks on this,
and enrichment never competes with an active viewer for CPU. The scan itself
already put the entry in the index with hash/size/type only; this fills in
@@ -33,26 +33,27 @@ THUMB_WIDTH = 320
# "Show/SeasonFolder/episode.mkv" is the expected shape, with a little slack
# for an extra wrapper folder — not an attempt to find the exact group root.
MAX_ANCESTOR_DEPTH = 4
-# Bounds the "borrow a title from a sibling episode filename" scan (§3.4) so
-# a folder with thousands of files costs a fixed, small amount of work.
+# Bounds the "borrow a title from a sibling episode filename" scan
+# (docs/MESHBAY_DESIGN.md §9.7) so a folder with thousands of files costs a
+# fixed, small amount of work.
MAX_SIBLINGS_CHECKED = 20
# Bounds the whole-show scan _synthetic_episode_number uses to rank a
-# season's files across more than one folder (§3.4c) — a show's total file
-# count, not just one folder's, so this needs more headroom than
-# MAX_SIBLINGS_CHECKED.
+# season's files across more than one folder (docs/MESHBAY_DESIGN.md §9.7) —
+# a show's total file count, not just one folder's, so this needs more
+# headroom than MAX_SIBLINGS_CHECKED.
MAX_SEASON_FILES_CHECKED = 500
def _season_and_show_from_ancestors(file_path: Path) -> tuple[int, Path] | None:
"""
- §3.4/§3.4c: walk up ancestor folders for a season-like one (a numbered
- season, or Specials/Bonus/Extras -> season 0) — season from the first
- (innermost) match, but the show's own name from *above every
- consecutive season-like ancestor*, not just the first one. A
- per-season Bonus folder (`Show/Season N/Bonus/file.ext`) is nested two
- levels inside the show, both of them season-like on their own
- ("Bonus" and "Season N") — stopping at the first would hand back
- "Season N" as the show's name instead of "Show".
+ docs/MESHBAY_DESIGN.md §9.7: walk up ancestor folders for a season-like one
+ (a numbered season, or Specials/Bonus/Extras -> season 0) — season from the
+ first (innermost) match, but the show's own name from *above every
+ consecutive season-like ancestor*, not just the first one. A per-season
+ Bonus folder (`Show/Season N/Bonus/file.ext`) is nested two levels inside
+ the show, both of them season-like on their own ("Bonus" and "Season N") —
+ stopping at the first would hand back "Season N" as the show's name instead
+ of "Show".
Trusted over any per-file guessit title once found: a bare episode
numbering convention with no show name embedded at all
@@ -87,15 +88,17 @@ def _season_and_show_from_ancestors(file_path: Path) -> tuple[int, Path] | None:
def _title_from_siblings(file_path: Path) -> str | None:
"""
- §3.4: an episode filename with no show name in it borrows the title from
- a representative sibling in the same folder, never from the folder name
- alone (an acronym-named show folder is a real, observed case).
+ docs/MESHBAY_DESIGN.md §9.7: an episode filename with no show name in it
+ borrows the title from a representative sibling in the same folder, never
+ from the folder name alone (an acronym-named show folder is a real,
+ observed case).
Requires the sibling to carry its own episode number too, not just a
- title — a folder where every file is a one-off-named Special (§3.4b)
- has plenty of `display_title`s (guessit reads *a* title off nearly
- anything) but none of them name the show; requiring a real episode
- number alongside is what tells apart a genuinely representative sibling
+ title — a folder where every file is a one-off-named Special
+ (docs/MESHBAY_DESIGN.md §9.7) has plenty of `display_title`s (guessit
+ reads *a* title off nearly anything) but none of them name the show;
+ requiring a real episode number alongside is what tells apart a
+ genuinely representative sibling
from another Special just like this one.
"""
try:
@@ -119,11 +122,11 @@ def _title_from_siblings(file_path: Path) -> str | None:
def _synthetic_episode_number(file_path: Path, show_root: Path, season: int) -> int:
"""
- §3.4b/§3.4c: a Specials/Bonus folder's files often carry no episode
- number at all — each is just named after its own one-off title. The
- frontend (video-app.js's buildSeasons) sorts within a season by this
- number but only needs it to provide a stable order, not to mean
- anything beyond that.
+ docs/MESHBAY_DESIGN.md §9.7: a Specials/Bonus folder's files often carry no
+ episode number at all — each is just named after its own one-off title. The
+ frontend (video-app.js's buildSeasons) sorts within a season by this number
+ but only needs it to provide a stable order, not to mean anything beyond
+ that.
Ranked across the *whole show*, not just this file's own folder: season
0 routinely spans more than one folder under the show's root — a
@@ -289,10 +292,11 @@ class Enricher:
and (title_parse.has_episode_marker(entry.name)
or title_parse.year_in(entry.name) is None)):
# No season-like ancestor at all (a flat library) but the
- # filename itself carries season+episode (§3.4) — *and* it
- # is a real marker, not guessit reading a bare number as
- # SxxExx. A movie whose "1080p" tag was truncated to "108",
- # or "1280" left in the name, otherwise parses to S01E08 /
+ # filename itself carries season+episode
+ # (docs/MESHBAY_DESIGN.md §9.7) — *and* it is a real marker,
+ # not guessit reading a bare number as SxxExx. A movie whose
+ # "1080p" tag was truncated to "108", or "1280" left in the
+ # name, otherwise parses to S01E08 /
# S12E80 and gets shelved as a nonexistent series
# (found live 2026-08-29). A genuine flat-dumped episode
# has an explicit SxxExx/1x08/"Episode N" marker; a movie
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
index 4fa08ef..d484e35 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
@@ -1,7 +1,7 @@
"""
Index-time enrichment for the Music group app: embedded tag/cover
extraction (mutagen) and filename-parse fallback for a newly-added audio
-IndexEntry (docs/musicbay.md §2.1, §6).
+IndexEntry (docs/MESHBAY_DESIGN.md §9.8).
Runs through its own small bounded worker pool, the same discipline as the
Videos app's `enrich.py` — separate from any other pool, never blocking a
@@ -15,14 +15,13 @@ block the event loop.
MusicBrainz lookups are **not** done here. Tag/cover extraction is free and
local, so it runs for every audio file the Music app is enabled for,
regardless of whether MusicBrainz itself is turned on for the group — the
-flat view (docs/musicbay.md §5.2) needs nothing more than this. MusicBrainz
+flat view (docs/MESHBAY_DESIGN.md §9.8) needs nothing more than this. MusicBrainz
is a separate, lazy, per-request enrichment (`music_meta_req`, handled in
webrtc_server.py), the same "fetched on demand, cached once" shape TMDB
already uses.
**Revised 2026-08-24** against a real ~5700-file library (folder-per-artist
-mostly, but not uniformly — see musicbay.md's own "what got measured" note
-if one gets added). Two findings drove this revision, both confirmed with
+mostly, but not uniformly). Two findings drove this revision, both confirmed with
real data before writing the fix:
1. The original ancestor walk always went up two levels (parent = album,
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py
index 93a68cc..6613f85 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py
@@ -3,7 +3,7 @@ Index-time enrichment for the Photos group app: a resized thumbnail and a
minimal, best-effort info set (`taken_at`, `camera`) read from the image's
own EXIF block, for a newly-added image IndexEntry.
-Deliberately small — docs/photos.md §2.4 is explicit that this app does not
+Deliberately small — docs/MESHBAY_DESIGN.md §9.9 is explicit that this app does not
build a full EXIF-viewer panel. Two fields only, both best-effort (missing
EXIF is the ordinary case for a screenshot or a re-saved/edited image, not
an error). GPS is never read here, on purpose: it is a location disclosure
@@ -12,7 +12,8 @@ module extracts, caches, or hands it to a caller.
Runs through its own small bounded worker pool, separate from the video
(ffmpeg) and audio (mutagen) enrichment pools — mirrors enrich.py exactly,
-per docs/photos.md §5's "never shared with either" rule, even though
+per docs/MESHBAY_DESIGN.md §6.5's "its own small bounded pool, never the
+streaming pool" rule, even though
Pillow's own work is comparatively cheap: a burst of hundreds of newly
shared photos should not peg every CPU core at once.
"""
@@ -100,7 +101,7 @@ def _read_image(file_path: Path) -> tuple[bytes, int, int, int | None, str | Non
# width/height and resizing — otherwise a phone photo stored
# "sideways" reports its raw, pre-rotation dimensions (swapped from
# what it actually displays as) and produces a sideways thumbnail
- # (docs/photos.md §2.4). Never reads Orientation itself as a
+ # (docs/MESHBAY_DESIGN.md §9.9). Never reads Orientation itself as a
# client-visible field; this is display correction only, and
# width/height must describe the *displayed* image, matching what
# the lightbox and the info panel show.
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
index 887d435..3fe4f3e 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py
@@ -82,7 +82,8 @@ def _is_indexable(path: Path) -> bool:
# Found live: a 1256-byte ".mp3" with no audio stream at all, just an ID3
# tag — a truncated/corrupted rip, sitting between two good tracks of the
-# same album (docs/musicbay.md). A source this small claiming to be audio
+# same album (docs/MESHBAY_DESIGN.md §9.8). A source this small claiming to
+# be audio
# is far more likely broken than real, so it is skipped before ever being
# hashed rather than indexed and left to fail at playback time. Scoped to
# audio only — a tiny real file of any other type is still worth indexing.
@@ -186,9 +187,8 @@ def _size_files(files: list[Path]) -> list[tuple[Path, int]]:
executor for the same reason `_walk_root` is (its own docstring above).
Previously a plain loop straight on the asyncio event loop thread: for a
root with many thousands of files (a real personal library, not a
- hypothetical — docs/musicbay.md's own "several thousand files" example)
- that blocked the entire daemon, every WebRTC session and the admin UI
- included, for as long as the stat() calls took — and did so *before*
+ hypothetical) that blocked the entire daemon, every WebRTC session and
+ the admin UI included, for as long as the stat() calls took — and did so *before*
`_scan_root` had even set `progress.scanning`, so a consumer polling it
saw "not scanning" the whole time real, blocking work was happening.
"""
@@ -673,8 +673,9 @@ class DirectoryIndexer:
Entries under a root that is gone from the config are dropped — the
operator removed it deliberately, which is not the same event as a
- volume disappearing, and conflating the two is what §6.9 exists to
- prevent. Roots that survive keep their entries; new ones are scanned.
+ volume disappearing, and conflating the two is what
+ docs/MESHBAY_DESIGN.md §6.2 exists to prevent. Roots that survive
+ keep their entries; new ones are scanned.
The set takes effect before anything is scanned: the roots table, the
watcher and `self.roots` all move at once. With ``wait=False`` the scan
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
index beb0d3f..14728ff 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
@@ -1,18 +1,18 @@
"""
Filename -> title/year/season/episode parsing for the Videos group app.
-Wraps `guessit` and layers the fixes from docs/mediacenter.md §3.3/§3.4 on
+Wraps `guessit` and layers the fixes from docs/MESHBAY_DESIGN.md §9.7 on
top of it: none of them are per-title hacks, each is a generic rule found
by validating guessit's raw output against real TMDB search results over a
~1950-file library (movies, TV shows, and a small franchise set).
-Scope is deliberately narrow (§3.5): title, year, season, episode. Technical
+Scope is deliberately narrow: title, year, season, episode. Technical
facts (resolution, codec, duration) come from ffprobe, never the filename —
a mislabeled `1080p` tag is a real, observed failure mode.
This module never touches the filesystem or the network. The orchestration
that decides *which* file supplies a show's title (a representative episode
-filename, not the folder name — §3.4) lives in the indexer, which has the
+filename, not the folder name) lives in the indexer, which has the
directory listing; this module only parses strings it's handed.
"""
@@ -91,7 +91,7 @@ _YEAR_RE = re.compile(r"(?<!\d)(?:19|20)\d{2}(?!\d)")
def year_in(text: str) -> int | None:
"""First 19xx/20xx in `text`, or None — used to lift a year off a show
- folder name ("Some.Show.2022.S01") for the search fallback (§10.1/V8)."""
+ folder name ("Some.Show.2022.S01") for the search fallback (V8)."""
m = _YEAR_RE.search(text or "")
return int(m.group(0)) if m else None
@@ -102,7 +102,7 @@ def clean_query(s: str) -> str:
parenthesized-year stripping `naive_title` does. `naive_title` assumes
a real filename; a show's `display_title` is a folder basename
("Some.Show.Name" — `rsplit('.', 1)` would eat ".Name"), so it needs a
- gentler normaliser (§10.1/V8).
+ gentler normaliser (V8).
"""
s = re.sub(r"[._-]+", " ", s or "")
s = _strip_editions(s)
@@ -115,7 +115,7 @@ def _strip_editions(title: str) -> str:
def naive_title(filename: str) -> str:
"""
- The mandated fallback (§3.6, §4.1): strip the extension, replace every
+ The mandated fallback: strip the extension, replace every
`.`/`_`/`-` with a space, drop a trailing parenthesized year, collapse
whitespace. Always computable, never fails, used both as the flat-mode
display name of last resort and as a second TMDB query candidate.
@@ -150,14 +150,14 @@ def sequel_variants(title: str) -> list[str]:
A trailing sequel index often has no exact match in the real TMDB
title: the file has a digit where TMDB uses a Roman numeral (or the
reverse), spells the number out, or wraps it as "Part N" / "Chapitre N"
- (§3.3 row 4, §10.1/V10). Returns extra candidate titles to try — the
+ (V10). Returns extra candidate titles to try — the
index re-rendered as digit and as Roman numeral, plus (only when there
is no "Part"/"Episode"/… keyword) the bare base.
The bare base is withheld for a keyword'd index — "<Saga> Chapter III"
→ "<Saga>" — because a franchise's bare name is very often a real,
*different* film (the series' first entry), and that variant matched
- every later entry to it (§10.1/V14). Without the keyword ("<Franchise>
+ every later entry to it (V14). Without the keyword ("<Franchise>
3") the number is decoration and the bare base is the right thing to
try.
"""
@@ -190,7 +190,7 @@ def sequel_variants(title: str) -> list[str]:
def season_from_folder_name(name: str) -> int | None:
"""
- §3.4: a season-like ancestor folder, vocabulary-driven rather than
+ A season-like ancestor folder, vocabulary-driven rather than
assuming a numeric convention everywhere. A specials/bonus/extras
folder maps to season 0 (matching TMDB's own `season_number: 0`).
Returns None if `name` doesn't look like a season folder at all.
@@ -212,7 +212,8 @@ def season_from_folder_name(name: str) -> int | None:
@dataclass
class ParsedName:
display_title: str | None # None => caller must supply from elsewhere (e.g. a sibling file)
- alt_title: str | None = None # guessit's alternative_title, a second query candidate (§3.3 row 1)
+ alt_title: str | None = None # guessit's alternative_title, a second
+ # query candidate
naive_title: str = "" # always available, fully punctuation-normalized fallback
year: int | None = None
season: int | None = None
@@ -249,7 +250,7 @@ def parse_movie_filename(filename: str) -> ParsedName:
-# ── Music app (docs/musicbay.md §2.1) ────────────────────────────────────────
+# ── Music app (docs/MESHBAY_DESIGN.md §9.8) ────────────────────────────────────────
#
# Filename parsing is the *fallback* here, not the primary source (unlike
# Videos, where guessit does all the work): embedded ID3/Vorbis tags are read
@@ -307,7 +308,7 @@ def strip_track_prefix(text: str) -> str:
# "Season 1"/"Saison 1". guessit will also invent a season+episode from a
# bare 3-4 digit run ("1080p" truncated to "108" -> S01E08; "1280" ->
# S12E80), which is how a plain movie ends up shelved as a series
-# (§10.1/V14). The indexer uses this to tell a real flat-library episode
+# (V14). The indexer uses this to tell a real flat-library episode
# from that hallucination.
_EPISODE_MARKER_RE = re.compile(
r"s\d{1,2}[\s._-]*e\d{1,3}"
@@ -326,9 +327,9 @@ def has_episode_marker(filename: str) -> bool:
def parse_episode_filename(filename: str) -> ParsedName:
"""
Parse an episode filename. `display_title` may come back None (e.g.
- `S08E02.SUBFRENCH.720p.mkv` carries no show name at all, §3.2) — the
+ `S08E02.SUBFRENCH.720p.mkv` carries no show name at all) — the
indexer then supplies the show title from a representative sibling
- filename in the same folder rather than the folder name itself (§3.4).
+ filename in the same folder rather than the folder name itself.
"""
g = guessit(filename)
title = g.get("title")
@@ -351,7 +352,7 @@ def parse_episode_filename(filename: str) -> ParsedName:
)
-# A bare leading episode number, no show name attached (§3.4c) — the same
+# A bare leading episode number, no show name attached — the same
# shape as music's _TRACK_PREFIX_RE, capped at 3 digits for the same reason:
# a leading year ("2010 - Episode.mkv") is 4 digits and must not match.
# guessit's own `episode` is not a substitute here: given exactly 3 digits it
diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py
index 2898dea..9dfdf73 100644
--- a/packages/meshbay-node/src/meshbay_node/media_cache.py
+++ b/packages/meshbay-node/src/meshbay_node/media_cache.py
@@ -4,8 +4,8 @@ Videos and Music group apps.
Node-wide (not per-group, `data_dir/media_cache.db`), same rationale as
`tmdb_enabled`/`tmdb_api_token` (and `musicbrainz_enabled`,
-docs/musicbay.md §6) living in `group_settings` under the `group_id=""`
-sentinel (docs/mediacenter.md §5.5): the credential/budget is one
+docs/MESHBAY_DESIGN.md §9.8) living in `group_settings` under the `group_id=""`
+sentinel (docs/MESHBAY_DESIGN.md §9.7): the credential/budget is one
operator's, and a thumbnail or cover image is the same bytes
regardless of which group happens to share the file. The `file_mbid`/
`mbid_meta` tables below are the Music app's equivalent of `file_tmdb`/
@@ -13,7 +13,8 @@ regardless of which group happens to share the file. The `file_mbid`/
release's cover is cached under a synthetic `musicbrainz:{mbid}` file_id,
the same trick `_fetch_and_cache_poster` uses for a TMDB poster_path).
-Disposable and rebuildable, like the rest of the file index (§1, §2) — never
+Disposable and rebuildable, like the rest of the file index
+(docs/MESHBAY_DESIGN.md §6.5) — never
a second identity for a file. Every row here is keyed off a value the node
can already derive (a file's own blake3 id, or a TMDB id), so losing this
database costs re-probing/re-fetching, not data.
@@ -98,14 +99,14 @@ CREATE TABLE IF NOT EXISTS mbid_meta (
json TEXT NOT NULL,
fetched_at REAL NOT NULL
);
--- Photos app (docs/photos.md): the technical/EXIF fields enrich_photo.py
--- reads alongside the thumbnail. Durable for the same reason `thumbs` is —
--- without this, only the thumbnail bytes survived a restart, and every
--- image was still fully re-decoded through Pillow just to re-derive
--- width/height/taken_at/camera, which get_thumb_hash_by_file_id's own
--- cache hit had already proven unnecessary. thumb_hash is not duplicated
--- here — get_thumb_hash_by_file_id(file_id) already answers that, and a
--- second copy would just be one more place for the two to drift.
+-- Photos app (docs/MESHBAY_DESIGN.md §9.9): the technical/EXIF fields
+-- enrich_photo.py reads alongside the thumbnail. Durable for the same reason
+-- `thumbs` is — without this, only the thumbnail bytes survived a restart, and
+-- every image was still fully re-decoded through Pillow just to re-derive
+-- width/height/taken_at/camera, which get_thumb_hash_by_file_id's own cache
+-- hit had already proven unnecessary. thumb_hash is not duplicated here —
+-- get_thumb_hash_by_file_id(file_id) already answers that, and a second copy
+-- would just be one more place for the two to drift.
CREATE TABLE IF NOT EXISTS photo_meta (
file_id TEXT PRIMARY KEY,
width INTEGER,
@@ -116,10 +117,10 @@ CREATE TABLE IF NOT EXISTS photo_meta (
"""
# TMDB overviews/ratings do drift; a file's own resolved tmdb_id does not
-# need re-checking on this schedule, only the metadata blob (§5.4, V3).
+# need re-checking on this schedule, only the metadata blob (V3).
TMDB_META_TTL_SECS = 30 * 86400
-# Same default as TMDB (docs/musicbay.md §6) — MusicBrainz release data is
+# Same default as TMDB (docs/MESHBAY_DESIGN.md §9.8) — MusicBrainz release data is
# not expected to drift faster; revisit if that proves wrong in practice.
MUSICBRAINZ_META_TTL_SECS = 30 * 86400
@@ -231,7 +232,7 @@ class MediaCache:
Full per-file reset: forget the match *and* any manual override
marker, so the next `media_meta_req` re-resolves from scratch with
the current matcher. This is the explicit operator "re-match this
- one" action (§10.1/V13) — deliberately stronger than
+ one" action (V13) — deliberately stronger than
`clear_file_tmdb`, which spares an override.
"""
await self._db.execute("DELETE FROM file_tmdb WHERE file_id = ?", (file_id,))
@@ -282,9 +283,10 @@ class MediaCache:
# ── tmdb id + season number -> season-level metadata json ────────────────
#
# A show's own overview (tmdb_meta above) is one static field an operator
- # found does not necessarily describe every season alike (mediacenter.md
- # §5.4) — this is TMDB's per-season `overview`/`air_date`/`poster_path`,
- # fetched and cached independently, on the same staleness schedule.
+ # found does not necessarily describe every season alike
+ # (docs/MESHBAY_DESIGN.md §9.7) — this is TMDB's per-season
+ # `overview`/`air_date`/`poster_path`, fetched and cached independently,
+ # on the same staleness schedule.
async def get_season_meta(self, tmdb_id: str, season: int) -> dict | None:
async with self._db.execute(
diff --git a/packages/meshbay-node/src/meshbay_node/media_probe.py b/packages/meshbay-node/src/meshbay_node/media_probe.py
index 81174c8..a6267d9 100644
--- a/packages/meshbay-node/src/meshbay_node/media_probe.py
+++ b/packages/meshbay-node/src/meshbay_node/media_probe.py
@@ -145,7 +145,7 @@ async def probe_video(path: str) -> VideoProbe:
width/height come from the same ffprobe call (one extra `-show_entries`
field, no second process spawn) — resolution is deliberately never
- guessed from the filename (docs/mediacenter.md §3.5).
+ guessed from the filename (docs/MESHBAY_DESIGN.md §9.7).
"""
from meshbay_node.platform import ffprobe_cmd
proc = await asyncio.create_subprocess_exec(
diff --git a/packages/meshbay-node/src/meshbay_node/musicbrainz.py b/packages/meshbay-node/src/meshbay_node/musicbrainz.py
index d5ca1e9..ce2d233 100644
--- a/packages/meshbay-node/src/meshbay_node/musicbrainz.py
+++ b/packages/meshbay-node/src/meshbay_node/musicbrainz.py
@@ -2,7 +2,7 @@
MusicBrainz (musicbrainz.org) + Cover Art Archive (coverartarchive.org)
client for the Music group app.
-Called only by the node, never by a client (docs/musicbay.md §3): the node
+Called only by the node, never by a client (docs/MESHBAY_DESIGN.md §9.8): the node
makes the one lookup per unique release, shared by every member, and self-
paces against MusicBrainz's shared rate limit rather than letting several
members' tile requests multiply it.
@@ -70,8 +70,8 @@ def _similarity(query: str, val: str | None) -> float:
def _best_match_release(artist: str, album: str, results: list[dict]) -> tuple[dict | None, float]:
"""
Same "trust the search's own ranking" shape as tmdb.py's `_best_match`
- (§3.3 of mediacenter.md found a locally-recomputed re-rank pick a
- coincidentally closer-looking wrong result once — no reason to expect
+ (docs/MESHBAY_DESIGN.md §9.7: a locally-recomputed re-rank was found to
+ pick a coincidentally closer-looking wrong result once — no reason to expect
MusicBrainz's own scored search to fare differently under the same
treatment). MusicBrainz already returns results ordered by its own
`score`; only the top one is considered.
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index b95ee2b..ca1b87d 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -284,7 +284,7 @@ async def unpin_member(state: dict, user_id: str) -> dict:
# the key, as `set_gek` does — would make every message anyone ever sent
# permanently unreadable to everybody, which is what a plain GEK-derived
# archive key would have done on the very first `member unpin`
-# (docs/chat-sender-keys.md F4).
+# (finding F4, docs/MESHBAY_DESIGN.md §13.6).
async def _wrap_for_node(state: dict, key: bytes) -> dict:
@@ -1380,7 +1380,8 @@ async def set_node_settings(state: dict, settings: dict) -> dict:
# `webrtc._stream_sem` was assigned here for months. That attribute
# has never existed -- the pool is `ctx["_transcode_sem"]` -- so the
# `hasattr` guard was always False and the setting only ever took
- # effect on a restart, which draft-v6 §2.11 says it does not need.
+ # effect on a restart, which docs/MESHBAY_DESIGN.md §6.8 says it
+ # does not need.
if webrtc is not None:
webrtc.set_capacity(
max_concurrent_streams=updated["max_concurrent_streams"])
@@ -1539,7 +1540,7 @@ 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/mediacenter.md §5.5).
+ 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
@@ -1569,8 +1570,8 @@ async def set_tmdb_config(state: dict, token: str | None = None,
async def set_tmdb_enabled(state: dict, group_id: str, enabled: bool) -> dict:
"""
- Whether TMDB lookups run for this group at all (docs/mediacenter.md
- §5.5) — per-group, unlike set_tmdb_config above: an operator running a
+ 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.
@@ -1591,7 +1592,7 @@ async def set_tmdb_enabled(state: dict, group_id: str, enabled: bool) -> dict:
async def set_musicbrainz_enabled(state: dict, group_id: str, enabled: bool) -> dict:
"""
Whether MusicBrainz lookups run for this group at all
- (docs/musicbay.md §6) — per-group from the start, same reasoning as
+ (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.
"""
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index 0753400..8c9b3ef 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -16,7 +16,7 @@ Three tables:
only in the operator's hands and the invitee's.
The code is what binds a public key to an account without asking the hub
-(finding H3). See `docs/invite-pairing-v1.md`.
+(finding H3). See `docs/MESHBAY_DESIGN.md` §3.4.
"""
from __future__ import annotations
@@ -63,7 +63,7 @@ DEFAULT_DEVICE_REQUEST_TTL = 3600
_SCHEMA = """\
-- One row per DEVICE, not per person. A browser and a desktop client are two
-- keys belonging to one account, and `user_id` alone as the key made the second
--- silently overwrite the first (INSERT OR REPLACE). See docs/desktop-client-v1.md §4.
+-- silently overwrite the first (INSERT OR REPLACE). See docs/MESHBAY_DESIGN.md §3.3.
CREATE TABLE IF NOT EXISTS identities (
user_id TEXT NOT NULL,
username TEXT NOT NULL,
@@ -81,7 +81,7 @@ CREATE TABLE IF NOT EXISTS identities (
-- transcript binds `nonce_node` — the approving connection's handshake
-- nonce — so even a stored signature is unverifiable without it.
--
- -- This is what Tier 2 needs (docs/desktop-client-v1.md §4.8): relayed with
+ -- This is what Tier 2 needs (docs/MESHBAY_DESIGN.md §3.3): relayed with
-- the roster, it lets a member verify for themselves that a second device
-- belongs to an account whose first device they have already pinned,
-- instead of taking the node's word. Verified and discarded until
@@ -407,7 +407,7 @@ class Roster:
Every live device of every active member of one group, with the evidence
that admitted it.
- For Tier 2 (`docs/desktop-client-v1.md` §4.8), and therefore
+ For Tier 2 (`docs/MESHBAY_DESIGN.md` §3.3), and therefore
**member-visible** — unlike `list_identities`, which answers the
operator. Two consequences of that, and both are the price of the
feature rather than oversights:
@@ -750,10 +750,10 @@ class Roster:
return apps
# The TMDB credential and query language are one operator's budget, not a
- # per-group concern (docs/mediacenter.md §5.5) — stored under the
+ # per-group concern (docs/MESHBAY_DESIGN.md §9.7) — stored under the
# group_id="" sentinel, the same precedent as `roster.get_member("",
- # user_id)` authorizing the operator node-wide (desktop-client-v1.md
- # §6.3). Unset means "the shipped default token, TMDB's own default
+ # user_id)` authorizing the operator node-wide (docs/MESHBAY_DESIGN.md
+ # §6.1). Unset means "the shipped default token, TMDB's own default
# language" — the same "absent means the old behaviour" discipline
# enabled_apps already follows.
#
@@ -791,7 +791,7 @@ class Roster:
# Which folder(s) inside the group's shared roots each application uses as
# its entry point. One storage shape for every app, keyed by the app's own
# name, so adding an application needs no change here at all — that is the
- # whole point of the plugin architecture (docs/refactor-groups.md §1.6).
+ # whole point of the plugin architecture (docs/MESHBAY_DESIGN.md §9.3).
#
# Always a JSON list, even for an app that only ever wants one directory.
# Two shapes for one idea is how `video_root` (scalar) and `photo_roots`
diff --git a/packages/meshbay-node/src/meshbay_node/tmdb.py b/packages/meshbay-node/src/meshbay_node/tmdb.py
index c9623df..9a0db12 100644
--- a/packages/meshbay-node/src/meshbay_node/tmdb.py
+++ b/packages/meshbay-node/src/meshbay_node/tmdb.py
@@ -1,7 +1,7 @@
"""
TMDB (themoviedb.org) client for the Videos group app.
-Called only by the node, never by a client (docs/mediacenter.md §2): the
+Called only by the node, never by a client (docs/MESHBAY_DESIGN.md §6.5): the
node holds the one credential and makes the one request per unique title,
shared by every member. Token resolution order (§5.5):
@@ -11,9 +11,9 @@ shared by every member. Token resolution order (§5.5):
exception, so a node with no token configured just serves thumbnails)
The real secret (whichever token resolves) never appears in source control:
-there is no literal fallback value in this file. See mediacenter.md's
-implementation notes on why a shipped default is a deployment concern, not
-a code concern.
+there is no literal fallback value in this file. Videos is the one
+credentialed application (docs/MESHBAY_DESIGN.md §9.7, §9.8), and which
+token ships is a deployment concern, not a code concern.
Results also come back in whatever language the operator configured
(roster.py's `tmdb_language`, e.g. "fr-FR") — one language for the whole
@@ -75,7 +75,7 @@ def _best_match(
top result is what's returned. The similarity ratio rides along purely
as a confidence signal for the caller's fallback decision.
- One narrow exception (§10.1/V9): when the top result is *not* a
+ One narrow exception (V9): when the top result is *not* a
confident textual hit (ratio < 0.6) and a `year` was requested, a
different result of that **exact** release year is preferred. TMDB
already year-filtered the search, so an entry landing on the requested
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 9ea70d8..8a5bbff 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -174,7 +174,8 @@ USER_BLOB_ACCOUNT_MAX = 8 * 1024 * 1024
_USER_BLOB_KIND_RE = re.compile(
r"^(playlists|playlist:[A-Za-z0-9_-]{1,64})$")
-# Chat link-preview results, kept in memory only (draft-v6 §2.7: the node
+# Chat link-preview results, kept in memory only (docs/MESHBAY_DESIGN.md §6.5:
+# the node
# produces enrichment on demand and keeps nothing durable — the asking device
# caches). Bounded and time-limited so a busy group cannot grow it without end
# and a page that changed its card is picked up within the hour.
@@ -212,6 +213,23 @@ _LINK_PREVIEW_RATE_WINDOW = 60.0
_LINK_PREVIEW_RATE_PER_CONN = 15
_LINK_PREVIEW_RATE_NODE = 60
+# A free-text TMDB search spends the *operator's* credential, which is rated by
+# TMDB and shared by everyone in the group: one member typing in the search box
+# can exhaust what every other member's automatic matching depends on, and the
+# operator is the one who has to notice. §6.5's rule is a bound and a named
+# adversary in the same commit; this one arrived without either.
+#
+# Per member rather than per connection, unlike link previews above: three tabs
+# is one person, and a ceiling a tab can multiply is not a ceiling. Kept in the
+# group context so it survives a reconnect, which is the other thing a per-session
+# count cannot do.
+#
+# Generous next to what a person types — ten searches a minute is a search every
+# six seconds, sustained — and small next to a loop.
+_TMDB_SEARCH_WINDOW = 60.0
+_TMDB_SEARCH_PER_MEMBER = 10
+_TMDB_SEARCH_NODE = 30
+
# Chat limits. A message is a member-supplied write onto the operator's disk
# (`chat.db`, where retention is a manual CLI command — §6.6), relayed from there
# to every other connected member and turned into a notification for every member
@@ -327,6 +345,24 @@ SEEK_PROBE_MAX_BACKOFF_SECS = 60
# subtitle track, it is an ffmpeg that found something else to write, and it
# would sit in the media cache for ever.
SUBTITLE_MAX_BYTES = 8 * 1024 * 1024
+
+# What a whole-file audio transcode may produce. The output is AAC at 192 kbit/s,
+# so this is about forty-five minutes of source — past any track, any single
+# piece, most sets.
+#
+# The bound is the media cache's, not memory's. `put_thumb` writes one SQLite row
+# and the store is 512 MB with least-recently-used eviction, sized for what it
+# holds: thumbnails, posters, subtitle tracks, short transcodes. A three-hour
+# audiobook at this bitrate is ~260 MB — a single row that would evict most of
+# the cache to make room for itself, and be evicted in turn by the next few
+# thumbnails. It is not a size this store can hold usefully.
+#
+# It does not take away something that worked: `AUDIO_TRANSCODE_TIMEOUT_SECS` is
+# 120, so a source long enough to reach this cap was already liable to be killed
+# mid-transcode. What changes is that the refusal now says which limit was met.
+# Serving audio of that length properly is streaming the transcode rather than
+# buffering it, which is a different feature from this one.
+AUDIO_TRANSCODE_MAX_BYTES = 64 * 1024 * 1024
# Bundle fetches are served in the pre-proof window (C4). Bounded and audited
# until the native client removes remote keypair bundles entirely.
MAX_PRE_PROOF_FETCHES = 4
@@ -1004,7 +1040,7 @@ class WebRTCPeerSession:
self._ctx.get("daemon_state", {}).get("tmdb_token_customized", False)),
"tmdb_language": str(
self._ctx.get("daemon_state", {}).get("tmdb_language") or ""),
- # Music app (docs/musicbay.md §6) — same shape as the TMDB
+ # Music app (docs/MESHBAY_DESIGN.md §9.8) — same shape as the TMDB
# fields above. No language field: MusicBrainz search doesn't
# take one the way TMDB does.
"musicbrainz_enabled": bool(self._group_ctx().get("musicbrainz_enabled", True)),
@@ -1180,7 +1216,7 @@ class WebRTCPeerSession:
}
# The recovery-wrapped copy (MNP 0.14) rides along when present, so a
# client holding the recovery key can re-wrap it under a new
- # passphrase — docs/auth-confirm.md §4.5.
+ # passphrase — docs/MESHBAY_DESIGN.md §3.6.
if kp.get("bundle_enc_recovery"):
resp["bundle_enc_recovery"] = kp["bundle_enc_recovery"]
self._send(resp)
@@ -1564,7 +1600,7 @@ class WebRTCPeerSession:
# A person may hold several devices on one node. The authority admitting a
# new one is a key the node already pinned — never the hub, which has stored
# no user keys since 2026-08-14 and therefore cannot countersign anything.
- # See docs/desktop-client-v1.md §4.
+ # See docs/MESHBAY_DESIGN.md §3.3.
async def _do_device_request(self, msg: dict) -> None:
"""
@@ -1725,7 +1761,7 @@ class WebRTCPeerSession:
#
# This is what lets another member check for themselves that this device
# belongs to an account whose earlier device they have already pinned,
- # instead of taking the node's word (Tier 2, desktop-client-v1.md §4.8).
+ # instead of taking the node's word (Tier 2, docs/MESHBAY_DESIGN.md §3.3).
await roster.pin_identity(
user_id=self._user_id, username=self._username or "",
pk_ed25519=pk_ed_b64, pk_x25519=pk_x_b64, via="device",
@@ -1752,7 +1788,7 @@ class WebRTCPeerSession:
What is checked, in order: the key is a live device *of this account* in
the node's own roster (never a token claim — that is
- `per-node-identity-v1.md`'s rule), the timestamp is fresh, and the
+ `docs/MESHBAY_DESIGN.md` §3.2's rule), the timestamp is fresh, and the
signature verifies over a transcript naming this node, this group and
this connection's nonce. A key that is merely well-formed proves nothing.
@@ -1970,7 +2006,7 @@ class WebRTCPeerSession:
Admission policy for a group, read from the node's own configuration.
Never from the hub: a hub that could declare a group open would be handed
- the key to it (§3.4 of docs/invite-pairing-v1.md).
+ the key to it (docs/MESHBAY_DESIGN.md §3.4).
"""
gctx = (self._ctx.get("groups") or {}).get(group_id) or {}
return gctx.get("join_policy", "invite")
@@ -2296,9 +2332,9 @@ class WebRTCPeerSession:
# include "video" or "music" — both can make outbound third-party
# network calls (TMDB, MusicBrainz) once enabled, so an operator opts a
# group in explicitly rather than getting it for free
- # (docs/mediacenter.md §5.6, docs/musicbay.md §4.4).
- # `helloworld` is the reference implementation (docs/refactor-groups.md
- # §4.1), hidden client-side behind `?dev=1`. It is here because the
+ # (docs/MESHBAY_DESIGN.md §9.7, §9.8).
+ # `helloworld` is the reference implementation (docs/MESHBAY_DESIGN.md
+ # §9.4), hidden client-side behind `?dev=1`. It is here because the
# allow-list is server-side enforcement — a client that names an app this
# node does not know is refused — and an app the node refused could not
# demonstrate anything. This entry and the client's registry line are the
@@ -2369,7 +2405,7 @@ class WebRTCPeerSession:
per-group concern (see _do_tmdb_enabled for the per-group on/off
switch). Signed like the rest: this changes outbound third-party
network traffic the node did not have before the Videos app
- (docs/mediacenter.md §5.5, §8) — an unsigned change would let any
+ (docs/MESHBAY_DESIGN.md §9.7, §6.5) — an unsigned change would let any
member alter egress the operator never agreed to.
"""
token = msg.get("token")
@@ -2752,7 +2788,7 @@ class WebRTCPeerSession:
def _do_musicbrainz_enabled(self, msg: dict) -> None:
"""
Whether MusicBrainz lookups run for this group at all. Per-group
- from the start (docs/musicbay.md §3.2/§6) — signed like
+ from the start (docs/MESHBAY_DESIGN.md §9.8) — signed like
tmdb_enabled: it decides whether this group's members'
Music tab ever makes outbound MusicBrainz traffic.
"""
@@ -3833,13 +3869,13 @@ class WebRTCPeerSession:
self, thumb_hash: str, chunk_index: int, gek: bytes | None,
) -> dict | None:
"""
- docs/mediacenter.md §5.3: a thumbnail is served through the same
+ docs/MESHBAY_DESIGN.md §6.5: a thumbnail is served through the same
chunked file_req path as a real file, resolved against the media
cache instead of the index when the id doesn't match a file.
Sliced by `chunk_index` like a real file's chunks, not just handed
back whole: a thumbnail/poster/cover never approached CHUNK_SIZE so
this used to be equivalent to "only chunk 0 exists", but an audio
- transcode result (docs/musicbay.md, the WMA/Musepack exception) is
+ transcode result (docs/MESHBAY_DESIGN.md §9.8, the WMA/Musepack exception) is
cached in the same media_cache blob store and can be several MB —
genuinely multi-chunk, same as a file read straight off disk.
"""
@@ -3959,7 +3995,7 @@ class WebRTCPeerSession:
async def _fetch_and_cache_poster(media_cache, tmdb_client, poster_path: str | None) -> str | None:
"""
Downloads a TMDB poster/backdrop once, caches it under its own
- blake3 like a video thumbnail (docs/mediacenter.md §5.4), and
+ blake3 like a video thumbnail (docs/MESHBAY_DESIGN.md §9.7), and
returns the hash a client then fetches via the normal file_req/
chunk path (§5.3) — no client ever contacts image.tmdb.org directly.
@@ -4008,7 +4044,7 @@ class WebRTCPeerSession:
async def _do_audio_transcode_request(self, msg: dict) -> None:
"""
- docs/musicbay.md's one exception to "no node-side transcode pool":
+ docs/MESHBAY_DESIGN.md §9.8's one exception to "no node-side transcode pool":
WMA and Musepack tag/cover fine (enrich_audio.py) but decode in no
mainstream browser's <audio> element at all. Transcoded to AAC/M4A
once and cached under its own content hash — same "computed once,
@@ -4023,6 +4059,25 @@ class WebRTCPeerSession:
if not entry:
self._send({"type": "error", "detail": "File not found"})
return
+
+ # The gate `BROWSER_INCOMPATIBLE_AUDIO_EXTS` exists for, applied where it
+ # costs something. Nothing on the node read it: the player asks for these
+ # two extensions and no others, and `music-player.js` described itself as
+ # "kept in sync with the node's" constant — so the whole restriction lived
+ # in the caller, and a member's own message is not the caller.
+ #
+ # What that let through: this converts a *whole file* and holds a
+ # transcode slot shared with video streaming while it runs. Pointed at a
+ # two-hour film it spends minutes of the operator's CPU and a slot every
+ # other viewer is queued behind. `AUDIO_TRANSCODE_MAX_BYTES` catches the
+ # result, after the work; only this catches the work.
+ if Path(entry.name).suffix.lower() not in BROWSER_INCOMPATIBLE_AUDIO_EXTS:
+ self._send({
+ "type": "error",
+ "detail": "This file does not need transcoding — play it directly.",
+ "code": "transcode_not_applicable",
+ })
+ return
file_path, refusal = await off_disk(ctx["roots"], _locate, ctx["roots"], entry)
if refusal is not None:
self._send({"type": "error", "detail": refusal})
@@ -4215,7 +4270,7 @@ class WebRTCPeerSession:
async def _do_music_meta_request(self, msg: dict) -> None:
"""
- docs/musicbay.md §4.3: MusicBrainz metadata for one track, resolved
+ docs/MESHBAY_DESIGN.md §9.8: MusicBrainz metadata for one track, resolved
from the group's index by its content id. Album-level (release), the
direct analogue of Videos' show-level TMDB caching: one search per
(artist, album) pair serves cover art and canonical naming to every
@@ -4295,7 +4350,7 @@ class WebRTCPeerSession:
async def _do_media_meta_request(self, msg: dict) -> None:
"""
- docs/mediacenter.md §5.4: TMDB metadata for one file, resolved from
+ docs/MESHBAY_DESIGN.md §9.7: TMDB metadata for one file, resolved from
the group's index by its content id (root+relpath the client already
knows from index_sync/index_delta identify the entry; its own `id`
is what actually names one file — never a raw filesystem path off
@@ -4320,7 +4375,7 @@ class WebRTCPeerSession:
media_cache = self._ctx.get("media_cache")
tmdb_client = self._ctx.get("tmdb_client")
- # Per-group, not node-wide (docs/mediacenter.md §5.5, 2026-08-24):
+ # Per-group, not node-wide (docs/MESHBAY_DESIGN.md §9.7, 2026-08-24):
# treated exactly like "no client configured" — same silent, no-error
# degradation, since a member's Videos tab already has to handle "no
# TMDB match" as the ordinary case.
@@ -4429,7 +4484,7 @@ class WebRTCPeerSession:
return
media_cache = self._ctx.get("media_cache")
tmdb_client = self._ctx.get("tmdb_client")
- # Per-group, not node-wide (docs/mediacenter.md §5.5, 2026-08-24) —
+ # Per-group, not node-wide (docs/MESHBAY_DESIGN.md §9.7, 2026-08-24) —
# same silent zero-confidence degradation as "no client configured".
if (media_cache is None or tmdb_client is None
or not self._group_ctx().get("tmdb_enabled", True)):
@@ -4467,7 +4522,7 @@ class WebRTCPeerSession:
async def _do_tmdb_search_request(self, msg: dict) -> None:
"""
Candidate TMDB matches for an operator correcting a wrong automatic
- match (docs/mediacenter.md, §V-whatever this becomes) — a plain
+ match (docs/MESHBAY_DESIGN.md §9.7, §V-whatever this becomes) — a plain
lookup, not a mutation, so unlike `tmdb_override` this needs no
admin authority: any member can see what TMDB itself would offer,
the same as the automatic search already silently does on their
@@ -4480,7 +4535,7 @@ class WebRTCPeerSession:
return
media_cache = self._ctx.get("media_cache")
tmdb_client = self._ctx.get("tmdb_client")
- # Per-group, not node-wide (docs/mediacenter.md §5.5, 2026-08-24) —
+ # Per-group, not node-wide (docs/MESHBAY_DESIGN.md §9.7, 2026-08-24) —
# same silent empty-results degradation as "no client configured":
# a member with TMDB off for this group sees the same "type it in
# yourself" affordance either way, never an error.
@@ -4490,6 +4545,21 @@ class WebRTCPeerSession:
"query": query, "media_type": media_type, "results": []})
return
+ # Refused out loud, not as an empty result: "no matches" is what the
+ # client draws for an empty list, and telling somebody their film is
+ # unknown when the node simply declined to ask is a worse answer than
+ # the truth. `video-app.js`'s `runSearch` puts `detail` on screen.
+ if not self._tmdb_search_rate_ok():
+ log.info("tmdb_search_req: rate-limited (user=%s)", (self._user_id or "")[:8])
+ self._send({
+ "type": "error",
+ "detail": "Too many searches in the last minute. This spends the "
+ "operator's search quota, which everyone in the group "
+ "shares — try again shortly.",
+ "code": "tmdb_search_rate_limited",
+ })
+ return
+
raw = (await tmdb_client.search_movie_results(query) if media_type == "movie"
else await tmdb_client.search_tv_results(query))
results = []
@@ -4613,7 +4683,7 @@ class WebRTCPeerSession:
def _do_tmdb_rematch(self, msg: dict) -> None:
"""
An operator dropping one file's cached TMDB match so it re-resolves
- with the current matcher (§10.1/V13) — the one-click alternative to
+ with the current matcher (V13) — the one-click alternative to
the full search-and-pick "Fix match" flow, and reachable without
SSH (`meshbay-node video rematch` clears a whole group). Signed like
`tmdb_override`: `media_cache` is shared node-wide.
@@ -4655,7 +4725,8 @@ class WebRTCPeerSession:
async def _tmdb_search(self, tmdb_client, entry, is_show: bool):
"""
- §3.3's retry ladder — same shape for movies and shows (§10.1/V8).
+ docs/MESHBAY_DESIGN.md §9.7's scored ladder — same shape for movies
+ and shows (V8).
TMDB's own top result is still trusted per query (§3.3's last row —
no local re-ranking of *its* list); what the ladder adds is that it
*scores every candidate query* and keeps the best, instead of
@@ -4701,7 +4772,7 @@ class WebRTCPeerSession:
specific than a punctuation-normalised restatement of `primary`
(an alternative_title, a sequel variant); when it does not and the
primary hit is already decent, the remaining calls are skipped
- (§10.1/V11 — they almost never win and cost a round trip each).
+ (V11 — they almost never win and cost a round trip each).
"""
def _year_of(res: dict) -> int | None:
d = str(res.get("release_date") or res.get("first_air_date") or "")
@@ -5121,6 +5192,33 @@ class WebRTCPeerSession:
if isinstance(m.payload, bytes) else m.payload)
return row
+ def _tmdb_search_rate_ok(self) -> bool:
+ """
+ True when this search is within both the member's window and the node's;
+ records it when so, and trims both to the window on every call so neither
+ list can grow without bound.
+
+ Both are checked because they answer different questions: the member's
+ keeps one person from spending everyone's quota, and the node's keeps a
+ group of them from doing it together.
+ """
+ now = time.monotonic()
+ w = _TMDB_SEARCH_WINDOW
+ ctx = self._group_ctx()
+ by_member = ctx.setdefault("tmdb_search_hits", {})
+ who = self._user_id or ""
+ mine = [t for t in by_member.get(who, []) if now - t < w]
+ node = [t for t in self._ctx.get("tmdb_search_hits_node", []) if now - t < w]
+ if len(mine) >= _TMDB_SEARCH_PER_MEMBER or len(node) >= _TMDB_SEARCH_NODE:
+ by_member[who] = mine
+ self._ctx["tmdb_search_hits_node"] = node
+ return False
+ mine.append(now)
+ node.append(now)
+ by_member[who] = mine
+ self._ctx["tmdb_search_hits_node"] = node
+ return True
+
def _link_preview_rate_ok(self) -> bool:
"""
True when this preview fetch is within both the per-connection and the
@@ -5144,7 +5242,8 @@ class WebRTCPeerSession:
async def _do_link_preview_request(self, msg: dict) -> None:
"""
- Unfurl a URL a member pasted into chat (draft-v6 §2.7 enrichment rule:
+ Unfurl a URL a member pasted into chat (docs/MESHBAY_DESIGN.md §6.5's
+ enrichment rule:
the client asks, the node produces on demand, the asking device
caches — nothing durable here).
@@ -5915,13 +6014,14 @@ class WebRTCPeerSession:
# anything failing loudly: a file uploaded from a phone could not be
# deleted from the same person's laptop, and the only symptom was
# "Signature verification failed" on their own file
- # (docs/desktop-client-v1.md §4.8 A).
+ # (docs/MESHBAY_DESIGN.md §3.3).
#
# `uploader_pk` is kept, and stops being the authorization key: it is
# now the audit record of *which device* did it. Authorization is by
# account, through the roster — never through a token claim, which is
- # the protection `per-node-identity-v1.md` added and which a lookup by
- # `uploader_id` in the hub's world would give straight back.
+ # the protection per-node identity keys give (docs/MESHBAY_DESIGN.md
+ # §3.2) and which a lookup by `uploader_id` in the hub's world would
+ # give straight back.
if not (await self._verify_admin_sig(transcript, sig)
or await self._verify_uploader_sig(entry, transcript, sig)):
self._send({"type": "error", "detail": "Signature verification failed"})
@@ -5935,7 +6035,7 @@ class WebRTCPeerSession:
) -> None:
# Node operator only. A group admin who does not run the node has no
# authority over who this node admits (deny by default). Delegation is
- # designed but deferred — see §6.2 of docs/invite-pairing-v1.md.
+ # designed but deferred — see docs/MESHBAY_DESIGN.md §3.4.
if not await self._verify_admin_sig(transcript, sig):
self._send({"type": "error", "detail": "Signature verification failed"})
self._audit("admin_auth_failed", f"invite_create:{pending['subject'][:16]}")
@@ -6240,7 +6340,7 @@ class WebRTCPeerSession:
# node's answer to it, since ffmpeg re-encodes these in real time on
# any machine that can run this daemon. Reported live against an
# Xvid/MP3 .avi. `transcode_incompatible_video`'s own documentation
- # (draft-v6 §2.11) already said "HEVC *and other browser-
+ # (docs/MESHBAY_DESIGN.md §6.8) already said "HEVC *and other browser-
# incompatible video codecs*"; only HEVC was ever wired up.
can_copy = (bool(codec_str)
and raw_video_codec not in BROWSER_INCOMPATIBLE_VIDEO_CODECS)
@@ -6678,6 +6778,30 @@ def _locate(roots: RootSet, entry) -> tuple[Path | None, str | None]:
return path, None
+def _read_scratch_capped(tmp_path: Path, cap: int, what: str) -> bytes:
+ """
+ Stat ffmpeg's output, refuse it if it is too big, read it. Blocking.
+
+ Run through `asyncio.to_thread` and not `off_disk`: this file is ffmpeg's
+ own, under `tempfile.mkstemp` on the system disk, so it is not a group root
+ and there is no spun-down platter to serialise against — it only has to be
+ off the event loop. A whole transcode read inline is tens of megabytes of
+ blocking read while nothing else in the node is served.
+
+ The size is checked before the bytes are asked for, so an oversized result
+ costs a stat rather than the read *and* the memory.
+ """
+ size = tmp_path.stat().st_size
+ if size > cap:
+ raise RuntimeError(f"{what} is {size} bytes, over the {cap} cap")
+ return tmp_path.read_bytes()
+
+
+async def _discard_scratch(tmp_path: Path) -> None:
+ """Remove one of ffmpeg's temp files, off the loop like the read of it."""
+ await asyncio.to_thread(tmp_path.unlink, True)
+
+
def _append_chunk(tmp_path: Path, chunk_bytes: bytes, first: bool) -> None:
"""Add one chunk to a partial upload. Blocking; called through `off_disk`."""
with open(tmp_path, "wb" if first else "ab") as f:
@@ -6734,9 +6858,11 @@ async def _transcode_audio_to_aac(file_path: Path) -> bytes:
if proc.returncode != 0:
raise RuntimeError(
f"ffmpeg exited {proc.returncode}: {stderr.decode(errors='replace')[:300]}")
- return tmp_path.read_bytes()
+ return await asyncio.to_thread(
+ _read_scratch_capped, tmp_path, AUDIO_TRANSCODE_MAX_BYTES,
+ "transcoded audio")
finally:
- tmp_path.unlink(missing_ok=True)
+ await _discard_scratch(tmp_path)
async def _seek_lands_at(file_path: Path, t: float, map_args: list[str]) -> float | None:
@@ -6790,7 +6916,7 @@ async def _seek_lands_at(file_path: Path, t: float, map_args: list[str]) -> floa
log.warning("stream: seek probe failed at %.1fs: %r", t, e)
return None
finally:
- tmp_path.unlink(missing_ok=True)
+ await _discard_scratch(tmp_path)
text = stdout.decode(errors="replace").strip().rstrip(",")
try:
landed = float(text)
@@ -6848,10 +6974,8 @@ async def _extract_subtitle_to_webvtt(file_path: Path, ordinal: int,
if proc.returncode != 0:
raise RuntimeError(
f"ffmpeg exited {proc.returncode}: {stderr.decode(errors='replace')[:300]}")
- size = tmp_path.stat().st_size
- if size > SUBTITLE_MAX_BYTES:
- raise RuntimeError(f"subtitle track is {size} bytes, over the {SUBTITLE_MAX_BYTES} cap")
- blob = tmp_path.read_bytes()
+ blob = await asyncio.to_thread(
+ _read_scratch_capped, tmp_path, SUBTITLE_MAX_BYTES, "subtitle track")
# A WebVTT file that is only its header has no cues in it. That is what
# a bitmap track extracted by mistake produces, and what a text track
# whose stream is empty produces; either way there is nothing to show,
@@ -6861,7 +6985,7 @@ async def _extract_subtitle_to_webvtt(file_path: Path, ordinal: int,
raise RuntimeError("extracted subtitle contains no cues")
return blob
finally:
- tmp_path.unlink(missing_ok=True)
+ await _discard_scratch(tmp_path)
class WebRTCTransport:
@@ -6931,9 +7055,9 @@ class WebRTCTransport:
`ctx["_transcode_sem"]`, and `hasattr(webrtc, "_stream_sem")` is always
False. So the hot-swap was a no-op and **`max_concurrent_streams` has
never taken effect from the Node page without a restart**, contrary to
- draft-v6 §2.11. This is the one implementation, on the object that owns
- the state, so the next two caps do not each grow their own copy of the
- mistake.
+ docs/MESHBAY_DESIGN.md §6.8. This is the one implementation, on the
+ object that owns the state, so the next two caps do not each grow their
+ own copy of the mistake.
What resizing means, stated because it is a decision and not a
detail: **the new cap governs new streams; the ones already running are
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index 96fa137..77491a0 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -9,7 +9,7 @@ are both clients of it. (Chat is served to browsers over MNP/WebRTC, not here.)
Served only on 127.0.0.1 — never network-exposed — and every request is gated
by a per-run session token (11.5.3) written to `<data_dir>/ui-token`. There is
no server-rendered UI: the Node page ships in the desktop client (see
-`docs/refactor-node-ui.md`).
+`docs/MESHBAY_DESIGN.md` §6.7).
"""
import logging
diff --git a/packages/meshbay-node/tests/conftest.py b/packages/meshbay-node/tests/conftest.py
index 692a118..2ca0c8a 100644
--- a/packages/meshbay-node/tests/conftest.py
+++ b/packages/meshbay-node/tests/conftest.py
@@ -8,7 +8,7 @@ from meshbay_node.roots import RootSet
# ffmpeg / ffprobe run via asyncio.create_subprocess_exec, which needs the
# ProactorEventLoop — but the repo-root conftest forces the SelectorEventLoop
-# on win32 so aiortc's ICE stack works there (see devel/windows-devel.md §5).
+# on win32 so aiortc's ICE stack works there (see docs/MESHBAY_DESIGN.md §11.2).
# The two are mutually exclusive on one Windows asyncio loop; until the media
# path gets a thread-based subprocess runner, these tests can't run on win32.
needs_subprocess = pytest.mark.skipif(
@@ -39,7 +39,7 @@ def _restore_media_tool_paths():
_plat._ffmpeg_path, _plat._ffprobe_path = before
-# Windows-only gaps still to close (see devel/windows-devel.md §5/§6).
+# Windows-only gaps still to close (see docs/MESHBAY_DESIGN.md §11.2).
win32_todo = pytest.mark.skipif(
sys.platform == "win32",
reason="Windows behaviour not implemented yet (W3 / platform specifics)",
diff --git a/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py b/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py
index d353d27..251617c 100644
--- a/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py
+++ b/packages/meshbay-node/tests/test_audio_root_gates_enrichment.py
@@ -1,12 +1,11 @@
"""
-Music-app enrichment (tag/cover extraction, docs/musicbay.md §2.1/§6) only
+Music-app enrichment (tag/cover extraction, docs/MESHBAY_DESIGN.md §9.8) only
ever runs for a group that has an audio_root configured, and only for files
under it — see daemon.py's _enrich_new_audio_entries. Same reasoning as
Videos' video_root gate (test_video_root_gates_enrichment.py), added later:
-musicbay.md's original "no root, whole shared tree" call turned out wrong
-against a real messy library, where everything under every shared folder
-got mixed together with no way to scope Music down to just the actual
-music library.
+the original "no root, whole shared tree" call turned out wrong against a
+real messy library, where everything under every shared folder got mixed
+together with no way to scope Music down to just the actual music library.
Setting or changing the folder (ops.set_app_directory) fires a one-off sweep
(_enrich_audio_root_now) of whatever it already contains — same shape as
diff --git a/packages/meshbay-node/tests/test_audio_transcode.py b/packages/meshbay-node/tests/test_audio_transcode.py
index c2f7f64..a6557f0 100644
--- a/packages/meshbay-node/tests/test_audio_transcode.py
+++ b/packages/meshbay-node/tests/test_audio_transcode.py
@@ -1,6 +1,6 @@
"""
Tests for the Music app's one exception to "no node-side transcode pool"
-(docs/musicbay.md §2.2): WMA and Musepack tag/cover fine (enrich_audio.py)
+(docs/MESHBAY_DESIGN.md §9.8): WMA and Musepack tag/cover fine (enrich_audio.py)
but decode in no mainstream browser's <audio> element at all, so
`_do_audio_transcode_request` converts to AAC/M4A on request and caches the
result — served back through the ordinary file_req/chunk path, generalized
@@ -191,3 +191,39 @@ async def test_multi_chunk_cached_blob_reassembles_correctly(tmp_path, media_cac
assert len(chunk_msgs) == total_chunks
reassembled = _reassemble_file_chunks(session.sent, gek, blob_hash)
assert reassembled == blob
+
+
+async def test_only_the_two_formats_that_need_it_are_transcoded(tmp_path, media_cache):
+ """
+ The gate that was written down and never applied.
+
+ `BROWSER_INCOMPATIBLE_AUDIO_EXTS` was read by nobody: the player asked only
+ for `.wma` and `.mpc`, and the node converted whatever file id it was given.
+ A member's own message is not the player, and this conversion is whole-file
+ while holding a transcode slot shared with video streaming — so one message
+ naming a two-hour film spends minutes of the operator's CPU and a slot every
+ other viewer is queued behind. The size cap catches the result; only this
+ catches the work.
+ """
+ clip = tmp_path / "feature.mkv"
+ clip.write_bytes(b"not really a film, and never opened")
+ session, file_id = _session(tmp_path, clip, generate_gek(), media_cache)
+
+ await session._do_audio_transcode_request({"file_id": file_id})
+
+ (msg,) = session.sent
+ assert msg["type"] == "error"
+ assert msg["code"] == "transcode_not_applicable"
+
+
+@pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed")
+async def test_the_two_formats_that_do_need_it_still_pass(tmp_path, media_cache):
+ """The gate must admit what it exists for; a refusal of everything is not a gate."""
+ clip = tmp_path / "clip.wma"
+ _make_wma_clip(clip)
+ session, file_id = _session(tmp_path, clip, generate_gek(), media_cache)
+
+ await session._do_audio_transcode_request({"file_id": file_id})
+
+ assert [m for m in session.sent if m.get("type") == MNP.AUDIO_TRANSCODE_RESP], (
+ f"a WMA file was refused: {session.sent}")
diff --git a/packages/meshbay-node/tests/test_bundle_store_recovery.py b/packages/meshbay-node/tests/test_bundle_store_recovery.py
index 10a3400..d649716 100644
--- a/packages/meshbay-node/tests/test_bundle_store_recovery.py
+++ b/packages/meshbay-node/tests/test_bundle_store_recovery.py
@@ -1,5 +1,5 @@
"""
-The recovery-wrapped keypair copy (docs/auth-confirm.md §4.3, MNP 0.14).
+The recovery-wrapped keypair copy (docs/MESHBAY_DESIGN.md §3.6, MNP 0.14).
`bundle_enc_recovery` is a second copy of the identity bundle wrapped under the
account's recovery key. The store has to add the column to a database that
diff --git a/packages/meshbay-node/tests/test_chat_encryption.py b/packages/meshbay-node/tests/test_chat_encryption.py
index 0401d27..e286306 100644
--- a/packages/meshbay-node/tests/test_chat_encryption.py
+++ b/packages/meshbay-node/tests/test_chat_encryption.py
@@ -1,7 +1,7 @@
"""
Chat encryption: what the node stores, what it refuses, and what survives.
-Design A of `docs/chat-sender-keys.md`. Every test here is written as "this
+Design A of `docs/MESHBAY_DESIGN.md` §4.5. Every test here is written as "this
does not work" or "this still works after X" — the regressions the plan's
register names, in the order they would bite.
diff --git a/packages/meshbay-node/tests/test_chat_history_binary.py b/packages/meshbay-node/tests/test_chat_history_binary.py
index 18efbf5..e2397a0 100644
--- a/packages/meshbay-node/tests/test_chat_history_binary.py
+++ b/packages/meshbay-node/tests/test_chat_history_binary.py
@@ -10,7 +10,7 @@ possible place to look for a wire-format error.
The fix keeps plaintext exactly where it has always been (a string in
`payload`, which older clients read) and gives ciphertext its own `ct` field.
-That way this is not a compatibility break either — `docs/chat-sender-keys.md`
+That way this is not a compatibility break either — `docs/MESHBAY_DESIGN.md` §4.5
R3.
"""
diff --git a/packages/meshbay-node/tests/test_chat_multidevice.py b/packages/meshbay-node/tests/test_chat_multidevice.py
index d718b2a..bb61285 100644
--- a/packages/meshbay-node/tests/test_chat_multidevice.py
+++ b/packages/meshbay-node/tests/test_chat_multidevice.py
@@ -11,8 +11,9 @@ what they said.
Neither shows up as an error anywhere. The first is a message that silently
reaches nobody after a second device connects and disconnects; the second is a
phone that never shows what was typed on the laptop. Both are
-`docs/chat-sender-keys.md` F7, and both are the same "keyed by account where it
-should be keyed by connection" mistake as `pin_identity`'s old INSERT OR REPLACE.
+finding F7 (`docs/MESHBAY_DESIGN.md` §13.6), and both are the same "keyed by
+account where it should be keyed by connection" mistake as `pin_identity`'s
+old INSERT OR REPLACE.
"""
from pathlib import Path
diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py
index ce661b5..ad60b91 100644
--- a/packages/meshbay-node/tests/test_cli_dispatch.py
+++ b/packages/meshbay-node/tests/test_cli_dispatch.py
@@ -173,7 +173,7 @@ def test_the_verb_list_here_matches_the_parser():
f"VERBS above")
# The server-rendered admin UI (and its `ui` verb) were removed in
- # docs/refactor-node-ui.md phase 5. The control API stays; the browser
+ # docs/MESHBAY_DESIGN.md §6.7. The control API stays; the browser
# page does not.
assert "ui" not in declared, "the `ui` verb came back"
diff --git a/packages/meshbay-node/tests/test_device_on_connection.py b/packages/meshbay-node/tests/test_device_on_connection.py
index 3da8a8c..03ed9ca 100644
--- a/packages/meshbay-node/tests/test_device_on_connection.py
+++ b/packages/meshbay-node/tests/test_device_on_connection.py
@@ -12,7 +12,7 @@ oldest live device" and calling it the answer:
* `_admin_exec_file_delete`, which authorized deletion against **that exact
key** — so a person could not delete their own file from their other device,
and the only symptom was "Signature verification failed" on their own upload
- (`docs/desktop-client-v1.md` §4.8 A).
+ (`docs/MESHBAY_DESIGN.md` §3.3).
`device_hello` closes the first: additive, signed, refused unless the key is a
live device *of this account in the node's own roster*. The second is closed by
@@ -224,8 +224,9 @@ class _Entry:
async def test_a_second_device_can_delete_the_first_devices_upload(
tmp_path, roster):
"""
- §4.8 A. Alice uploads from her phone and deletes from her desktop. Before
- the fix this failed with "Signature verification failed" on her own file.
+ docs/MESHBAY_DESIGN.md §3.3. Alice uploads from her phone and deletes
+ from her desktop. Before the fix this failed with "Signature verification
+ failed" on her own file.
"""
sk_phone, pk_phone, pk_x_phone = _keys()
sk_desk, pk_desk, pk_x_desk = _keys()
diff --git a/packages/meshbay-node/tests/test_disk_io_off_loop.py b/packages/meshbay-node/tests/test_disk_io_off_loop.py
index 2179e66..ceaf565 100644
--- a/packages/meshbay-node/tests/test_disk_io_off_loop.py
+++ b/packages/meshbay-node/tests/test_disk_io_off_loop.py
@@ -25,6 +25,7 @@ import threading
import time
from pathlib import Path
+import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common.crypto import generate_gek
from meshbay_common.protocol import MNP
@@ -252,13 +253,11 @@ def test_no_handler_touches_the_disk_on_the_loop():
on_the_disk_thread = {"_locate", "_append_chunk", "_read_and_encrypt",
"_mkdir_if_absent", "_is_empty_dir", "_rmdir_if_empty",
"safe_subdir"}
- # ffmpeg's own output, under `tempfile.mkstemp` on the system disk — not a
- # group root, so not what spins down. Listed rather than silently allowed:
- # these still read a whole transcode into memory from the loop, and the day
- # that matters it is a different measurement from this one.
- ffmpeg_scratch = {"_transcode_audio_to_aac", "_seek_lands_at",
- "_extract_subtitle_to_webvtt"}
- allowed = on_the_disk_thread | ffmpeg_scratch
+ # ffmpeg's own output goes through `_read_scratch_capped` and
+ # `_discard_scratch` on a worker thread — `asyncio.to_thread` and not
+ # `off_disk`, because a temp file is not a group root and has no platter to
+ # serialise against. Nothing is exempt here any more.
+ allowed = on_the_disk_thread | {"_read_scratch_capped"}
found = []
@@ -354,3 +353,52 @@ async def test_chunks_of_one_upload_keep_their_order_under_a_slow_disk(tmp_path,
refusals = [m for m in session.sent if m.get("type") == "error"]
assert not refusals, f"a chunk was refused: {refusals}"
assert (shared / "clip.bin").read_bytes() == b"".join(pieces)
+
+
+def test_the_scratch_read_is_only_ever_reached_on_a_thread():
+ """
+ `_read_scratch_capped` blocks by design, so the guard above allows it — and
+ that allowance is worth nothing if somebody calls it straight from a
+ handler. Passed to `asyncio.to_thread` it appears in the syntax tree as a
+ name; called inline it appears as a call, which is what this refuses.
+ """
+ tree = ast.parse(Path(webrtc_server.__file__).read_text())
+ direct = [n.lineno for n in ast.walk(tree)
+ if isinstance(n, ast.Call)
+ and isinstance(n.func, ast.Name)
+ and n.func.id == "_read_scratch_capped"]
+ assert not direct, (
+ f"_read_scratch_capped is called directly at line(s) {direct} — hand it "
+ "to `asyncio.to_thread` instead, or the cap is paid for on the loop")
+
+
+async def test_ffmpeg_output_over_the_cap_is_refused_before_it_is_read(tmp_path):
+ """
+ The stat comes first, so an oversized result costs a stat rather than the
+ read and the memory. The number in the message is the one that was measured,
+ not the cap, because an operator reading a log wants to know by how much.
+ """
+ scratch = tmp_path / "out.m4a"
+ scratch.write_bytes(b"x" * 5000)
+
+ with pytest.raises(RuntimeError, match=r"5000 bytes, over the 1024 cap"):
+ webrtc_server._read_scratch_capped(scratch, 1024, "transcoded audio")
+
+ # And under the cap it simply reads.
+ assert webrtc_server._read_scratch_capped(scratch, 8192, "x") == b"x" * 5000
+
+
+async def test_a_slow_scratch_read_does_not_stop_the_loop(tmp_path, monkeypatch):
+ """Measured like the others: the loop keeps its wake-ups during the read."""
+ scratch = tmp_path / "out.vtt"
+ scratch.write_bytes(CONTENT)
+ monkeypatch.setattr(webrtc_server, "_read_scratch_capped",
+ _slow(webrtc_server._read_scratch_capped))
+
+ with _Ticker() as ticker:
+ blob = await asyncio.to_thread(
+ webrtc_server._read_scratch_capped, scratch, 1 << 20, "subtitle track")
+
+ assert blob == CONTENT
+ assert ticker.ticks > SLOW_S / TICK_S / 2, (
+ f"the loop was blocked: {ticker.ticks} wake-ups during a {SLOW_S}s read")
diff --git a/packages/meshbay-node/tests/test_enrich.py b/packages/meshbay-node/tests/test_enrich.py
index 75cf0ee..e059bbe 100644
--- a/packages/meshbay-node/tests/test_enrich.py
+++ b/packages/meshbay-node/tests/test_enrich.py
@@ -134,7 +134,7 @@ def test_synthetic_episode_number_does_not_collide_across_per_season_bonus_folde
# ── end-to-end against a real (tiny, synthetic) video file ──────────────────
# ffprobe runs via asyncio subprocess, which the win32 selector loop (forced
-# for aiortc, see devel/windows-devel.md §5) cannot spawn.
+# for aiortc, see docs/MESHBAY_DESIGN.md §11.2) cannot spawn.
pytestmark_ffmpeg = pytest.mark.skipif(
not _HAVE_FFMPEG or sys.platform == "win32",
reason="needs ffprobe installed and a ProactorEventLoop",
diff --git a/packages/meshbay-node/tests/test_enrich_photo.py b/packages/meshbay-node/tests/test_enrich_photo.py
index 7684f40..81367eb 100644
--- a/packages/meshbay-node/tests/test_enrich_photo.py
+++ b/packages/meshbay-node/tests/test_enrich_photo.py
@@ -172,7 +172,7 @@ async def test_enricher_corrects_orientation(tmp_path, media_cache):
def test_gps_is_never_read_by_this_module():
"""
- docs/photos.md §2.4/§11: GPS must never be extracted, cached, or handed
+ docs/MESHBAY_DESIGN.md §9.9: GPS must never be extracted, cached, or handed
to a caller — a location disclosure the instant it is surfaced to every
group member. Grep-based, the same discipline test_hub_address_seam.py/
test_task_lifetime.py already apply elsewhere in this codebase to a
diff --git a/packages/meshbay-node/tests/test_group_roster.py b/packages/meshbay-node/tests/test_group_roster.py
index cb9828c..a41cce2 100644
--- a/packages/meshbay-node/tests/test_group_roster.py
+++ b/packages/meshbay-node/tests/test_group_roster.py
@@ -1,9 +1,9 @@
"""
Tier 2: a member verifies another member's device for themselves.
-`docs/desktop-client-v1.md` §4.8, and `docs/chat-sender-keys.md` §13, which
-recorded why it could not ship with the encryption: **the evidence was not being
-kept.** `_do_device_add` verified the countersignature and stored only
+`docs/MESHBAY_DESIGN.md` §3.3, which records why it could not ship with the
+encryption: **the evidence was not being kept.** `_do_device_add` verified the
+countersignature and stored only
`added_by_pk` — *which* key approved, never the proof — and the transcript binds
`nonce_node`, the approving connection's handshake nonce, so even a stored
signature was unverifiable by anyone who was not on that connection.
diff --git a/packages/meshbay-node/tests/test_media_cache.py b/packages/meshbay-node/tests/test_media_cache.py
index f12a366..dc78d8d 100644
--- a/packages/meshbay-node/tests/test_media_cache.py
+++ b/packages/meshbay-node/tests/test_media_cache.py
@@ -133,7 +133,7 @@ async def test_prune_file_also_clears_the_override_marker(cache):
assert await cache.clear_tmdb_matches(["gone"]) == 1
-# ── Music app (docs/musicbay.md §6) — file_mbid/mbid_meta ────────────────────
+# ── Music app (docs/MESHBAY_DESIGN.md §9.8) — file_mbid/mbid_meta ────────────
@pytest.mark.asyncio
async def test_file_mbid_round_trip(cache):
diff --git a/packages/meshbay-node/tests/test_musicbrainz.py b/packages/meshbay-node/tests/test_musicbrainz.py
index cff7fea..ccf7597 100644
--- a/packages/meshbay-node/tests/test_musicbrainz.py
+++ b/packages/meshbay-node/tests/test_musicbrainz.py
@@ -156,7 +156,8 @@ async def test_no_contact_configured_makes_no_request():
result, ratio = await client.search_release("Anyone", "Anything")
assert result is None
- assert calls == [], "an unidentified client must never be sent — see musicbay.md §3.1"
+ assert calls == [], ("an unidentified client must never be sent "
+ "— see docs/MESHBAY_DESIGN.md §9.8")
await client.close()
@@ -227,7 +228,7 @@ async def test_cover_art_found_returns_bytes():
@pytest.mark.asyncio
async def test_calls_are_paced_at_least_min_interval_apart():
"""
- docs/musicbay.md §3.2: the ~1 req/s courtesy limit is this node's own
+ docs/MESHBAY_DESIGN.md §9.8: the ~1 req/s courtesy limit is this node's own
job, not something the server hands out — verified by timing two calls
back to back rather than mocking the clock, so a change to the pacing
implementation that still meets the contract doesn't break this test.
diff --git a/packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py b/packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py
index e86a3f3..76b687e 100644
--- a/packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py
+++ b/packages/meshbay-node/tests/test_musicbrainz_enabled_policy.py
@@ -1,7 +1,8 @@
"""
-Whether MusicBrainz lookups run *at all* for a group — docs/musicbay.md
-§3.2/§6. Per-group from the start (unlike tmdb_enabled, which started
-node-wide and moved per-group later once the lesson was already learned).
+Whether MusicBrainz lookups run *at all* for a group —
+docs/MESHBAY_DESIGN.md §9.8. Per-group from the start (unlike tmdb_enabled,
+which started node-wide and moved per-group later once the lesson was already
+learned).
Same shape as test_tmdb_enabled_policy.py: a signed operator instruction,
scoped to self._group_id (not passed explicitly on the wire), stored via
roster.py's group_settings table under the real group_id.
diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py
index 877994d..7b44bbe 100644
--- a/packages/meshbay-node/tests/test_packaging_win.py
+++ b/packages/meshbay-node/tests/test_packaging_win.py
@@ -24,16 +24,14 @@ NSH = CLIENT / "build" / "installer.nsh"
MAIN_JS = CLIENT / "src" / "main.js"
PRELOAD_JS = CLIENT / "src" / "preload.js"
-# The "Light" target: Electron client + UI, no bundled node. See
-# C:\Users\admin\devel\light-client.md for the evaluation this implements.
+# The "Light" target: Electron client + UI, no bundled node.
LIGHT_NSH = CLIENT / "build" / "installer-light.nsh"
LIGHT_YML = WIN / "electron-builder.light.yml"
BUILD_WIN_LIGHT = WIN / "build-win-light.ps1"
BUILD_WIN_COMMON = WIN / "build-win-common.ps1"
# The "MSIX" target: same feature set as Full, packaged for Microsoft Store
-# submission instead of NSIS. See C:\Users\admin\devel\msix-installer.md for
-# the plan this implements.
+# submission instead of NSIS.
MSIX_YML = WIN / "electron-builder.msix.yml"
BUILD_WIN_MSIX = WIN / "build-win-msix.ps1"
MSIX_EXTENSIONS_XML = CLIENT / "build" / "appx-extensions.xml"
@@ -683,8 +681,7 @@ def test_ffmpeg_bundling_is_the_default_not_opt_in():
# ------------------------------------------------------------------------
-# The "Light" target: Electron client + UI, no bundled node. See
-# C:\Users\admin\devel\light-client.md for the evaluation. Weak, text-
+# The "Light" target: Electron client + UI, no bundled node. Weak, text-
# reading evidence throughout, same reasoning as the rest of this file:
# there is no electron-builder/PowerShell/NSIS runner here, and it is the
# right kind of evidence for what these guard against -- a config drifting
@@ -921,9 +918,9 @@ def test_create_group_page_falls_back_when_no_node_is_bundled():
# ------------------------------------------------------------------------
# The "MSIX" target: same feature set as Full, packaged for Microsoft Store
-# submission instead of NSIS. See C:\Users\admin\devel\msix-installer.md for
-# the plan. Unlike Light, this target keeps the node runtime and both
-# service scripts -- what changes is packaging format, not what ships.
+# submission instead of NSIS. Unlike Light, this target keeps the node
+# runtime and both service scripts -- what changes is packaging format, not
+# what ships.
# Weak, text-reading evidence throughout, same reasoning as the rest of
# this file: there is no electron-builder/appx runner here either.
# ------------------------------------------------------------------------
@@ -931,9 +928,9 @@ def test_create_group_page_falls_back_when_no_node_is_bundled():
def test_msix_config_is_standalone_and_keeps_the_full_bundle():
"""
Unlike Light, MSIX ships the same node-runtime/ffmpeg/service scripts as
- Full -- an AppX install never elevating (msix-installer.md 4) is not a
- reason to drop the daemon, only to change how its two elevated
- operations get triggered (see the two tests below). --config still
+ Full -- an AppX install never elevating is not a reason to drop the
+ daemon, only to change how its two elevated operations get triggered
+ (see the two tests below). --config still
means this file is read alone (app-builder-lib's getConfig), so it
cannot silently inherit Full's package.json build.nsis or any signing
config meant for NSIS.
@@ -979,11 +976,11 @@ def test_msix_declares_no_csc_on_purpose():
No certificateFile/certificateSubjectName/certificateSha1 anywhere in
this config -- per app-builder-lib's own windowsSignToolManager.js, an
AppX target built with no certificate configured is logged as "Windows
- Store only build" and left unsigned; Microsoft signs it at publish time
- (msix-installer.md 3). Configuring a cert here would be wasted work, not
- extra safety, and would risk this target picking up whatever might one
- day be configured for Full's NSIS signing if it were ever added to this
- file instead of package.json's own build.win.
+ Store only build" and left unsigned; Microsoft signs it at publish
+ time. Configuring a cert here would be wasted work, not extra safety,
+ and would risk this target picking up whatever might one day be
+ configured for Full's NSIS signing if it were ever added to this file
+ instead of package.json's own build.win.
"""
yml = MSIX_YML.read_text(encoding="utf-8")
for forbidden in ("certificateFile", "certificateSubjectName", "certificateSha1"):
@@ -993,10 +990,10 @@ def test_msix_declares_no_csc_on_purpose():
def test_msix_declares_the_network_capabilities_firewall_ps1_would_add():
"""
Matches firewall.ps1's own rules, which are `-Profile Any` (private AND
- public network) -- msix-installer.md 8's #1 open item: whether Windows
- actually auto-exempts a full-trust packaged app on the strength of
- these declarations is unverified until sideloaded, but the declaration
- itself must at least match what the elevated NSIS path grants today, or
+ public network). Whether Windows actually auto-exempts a full-trust
+ packaged app on the strength of these declarations is unverified until
+ sideloaded, but the declaration itself must at least match what the
+ elevated NSIS path grants today, or
an MSIX install would be silently narrower than Full/Light.
"""
yml = MSIX_YML.read_text(encoding="utf-8")
diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py
index 14285e7..2e8c183 100644
--- a/packages/meshbay-node/tests/test_roster_pairing.py
+++ b/packages/meshbay-node/tests/test_roster_pairing.py
@@ -7,7 +7,7 @@ or a mistake that must not work. The one to keep an eye on is
sovereignty inert as shipped, and it fails closed, so nothing else in the suite
notices if it comes back.
-See `docs/invite-pairing-v1.md`.
+See `docs/MESHBAY_DESIGN.md` §3.4.
"""
import base64
diff --git a/packages/meshbay-node/tests/test_season_and_search_requests.py b/packages/meshbay-node/tests/test_season_and_search_requests.py
index 44d7da6..4141d13 100644
--- a/packages/meshbay-node/tests/test_season_and_search_requests.py
+++ b/packages/meshbay-node/tests/test_season_and_search_requests.py
@@ -1,6 +1,7 @@
"""
`_do_season_meta_request` (per-season TMDB overview/poster/air_date, for the
-season-tab view — docs/mediacenter.md §5.4's fix for a 3-season show whose
+season-tab view — docs/MESHBAY_DESIGN.md §9.7's per-season text, found live
+against a 3-season show whose
overview read as season-3-specific for every season) and
`_do_tmdb_search_request` (raw TMDB candidates for an operator correcting a
wrong automatic match). Neither is a signed admin op — see each handler's own
@@ -21,6 +22,12 @@ def _session(media_cache=None, tmdb_client=None) -> WebRTCPeerSession:
session = WebRTCPeerSession.__new__(WebRTCPeerSession)
session._ctx = {"media_cache": media_cache, "tmdb_client": tmdb_client}
session._group_id = None
+ # Set because production always has one: `_dispatch_message` refuses every
+ # message until the handshake settles `_user_id`, so a session reaching any
+ # of these handlers without it does not exist. Left out, this fixture was
+ # narrower than the node and the per-member search ceiling could not be
+ # exercised by it at all.
+ session._user_id = "u1"
session.sent = []
session._send = session.sent.append
return session
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py
index a229153..d6ecb71 100644
--- a/packages/meshbay-node/tests/test_security_regressions.py
+++ b/packages/meshbay-node/tests/test_security_regressions.py
@@ -1,10 +1,10 @@
"""
Phase 11.5 security regression tests.
-Each test here encodes a finding from `second-review.md`. They are negative tests:
-they assert that an attack does NOT work. The pre-11.5 code passed 209 feature
-tests while every one of these attacks succeeded — the suite only ever exercised
-happy paths, never an authorization boundary.
+Each test here encodes a finding from `docs/MESHBAY_DESIGN.md` §13.3. They are
+negative tests: they assert that an attack does NOT work. The pre-11.5 code
+passed 209 feature tests while every one of these attacks succeeded — the suite
+only ever exercised happy paths, never an authorization boundary.
If one of these starts failing, a fix has been reverted. Do not "fix" the test.
"""
@@ -447,7 +447,7 @@ def test_daemon_sets_no_global_chat_store(tmp_path):
def test_no_member_can_hand_the_node_key_material(tmp_path):
"""
- C5b, strengthened by the invite redesign (docs/invite-pairing-v1.md).
+ C5b, strengthened by the invite redesign (docs/MESHBAY_DESIGN.md §3.4).
This test used to assert that `gek_bundle_store` answered with an admin
challenge and stored nothing without an operator signature. The message is now
@@ -818,7 +818,7 @@ def test_node_control_api_serves_no_html():
H2 was stored XSS in the server-rendered admin dashboard: a member-chosen
filename, or a hub-supplied username, landed in an HTML page on the
operator's machine unescaped. That dashboard is gone
- (docs/refactor-node-ui.md phase 5) — the control API is JSON only, so there
+ (docs/MESHBAY_DESIGN.md §6.7) — the control API is JSON only, so there
is no server-side template to inject into. The Node page that replaced it
ships in the desktop client and escapes by default (Preact).
diff --git a/packages/meshbay-node/tests/test_startup_scan_enrichment.py b/packages/meshbay-node/tests/test_startup_scan_enrichment.py
index d2cbc3e..f43c6e9 100644
--- a/packages/meshbay-node/tests/test_startup_scan_enrichment.py
+++ b/packages/meshbay-node/tests/test_startup_scan_enrichment.py
@@ -12,8 +12,8 @@ is already running (seen by the watchdog), would trigger it. Found live
against a real library after the first restart with this feature enabled.
Enrichment only runs once a group has a video_root configured (a group
-with none set gets no TMDB/thumbnail work at all, docs/mediacenter.md
-§5.2/§10) — this test's fake roster reports the shared root itself as the
+with none set gets no TMDB/thumbnail work at all, docs/MESHBAY_DESIGN.md
+§6.5) — this test's fake roster reports the shared root itself as the
configured video_root, so the enrichment-scheduling behaviour under test
is exercised the same way a real operator's group would be.
"""
diff --git a/packages/meshbay-node/tests/test_stream_video_transcode.py b/packages/meshbay-node/tests/test_stream_video_transcode.py
index 0fd12c6..316fdce 100644
--- a/packages/meshbay-node/tests/test_stream_video_transcode.py
+++ b/packages/meshbay-node/tests/test_stream_video_transcode.py
@@ -19,8 +19,8 @@ None — there is nothing to put in `stream_init` for the client to check. Until
live against an episode rip in a `.avi` — mpeg4 video, mp3 audio, 720x404 —
which the reporting machine re-encodes at about six times playback speed. The setting
that governs it, `transcode_incompatible_video`, was documented from the start
-as covering "HEVC *and other browser-incompatible video codecs*" (draft-v6
-§2.11); only HEVC was ever wired up.
+as covering "HEVC *and other browser-incompatible video codecs*"
+(docs/MESHBAY_DESIGN.md §6.8); only HEVC was ever wired up.
These tests spawn real ffmpeg/ffprobe against small synthetic files (lavfi
test sources, ~1s), the same style as test_stream_audio_transcode.py.
diff --git a/packages/meshbay-node/tests/test_title_parse.py b/packages/meshbay-node/tests/test_title_parse.py
index bbe9afa..2c4573a 100644
--- a/packages/meshbay-node/tests/test_title_parse.py
+++ b/packages/meshbay-node/tests/test_title_parse.py
@@ -1,6 +1,6 @@
"""
Tests for indexer/title_parse.py — synthetic filenames only, one per
-docs/mediacenter.md §3.3/§3.4 rule. The real ~1950-file library validation
+docs/MESHBAY_DESIGN.md §9.7 rule. The real ~1950-file library validation
is a manual acceptance step (§11), not something this repo's corpus holds.
"""
@@ -72,7 +72,7 @@ def test_sequel_variants_empty_when_no_trailing_digit():
assert sequel_variants("Some Movie") == []
-# ── §10.1/V10: wider sequel-index handling ──────────────────────────────────
+# ── V10: wider sequel-index handling ────────────────────────────────────────
def test_sequel_variants_roman_numeral_offers_the_digit_form():
v = sequel_variants("Old Frontier III")
@@ -81,7 +81,7 @@ def test_sequel_variants_roman_numeral_offers_the_digit_form():
def test_sequel_variants_rewrites_a_part_keyword_index_but_keeps_the_name():
- # §10.1/V14: with a "Part"/"Episode"/… keyword the bare base is
+ # V14: with a "Part"/"Episode"/… keyword the bare base is
# withheld — "Some Saga" alone collides with a franchise-origin film.
v = sequel_variants("Some Saga Part 2")
assert "Some Saga II" in v
@@ -97,7 +97,7 @@ def test_sequel_variants_reads_a_spelled_out_index():
def test_sequel_variants_saga_shape_does_not_offer_the_bare_franchise():
# Every "<Saga> Chapter <N>" was matching the franchise's first entry
- # because the bare "<Saga>" variant hit it at ratio 1.0 (§10.1/V14).
+ # because the bare "<Saga>" variant hit it at ratio 1.0 (V14).
v = sequel_variants("Some Saga Chapter III")
assert "Some Saga" not in v
assert "Some Saga 3" in v
@@ -125,7 +125,7 @@ def test_clean_query_despaces_a_folder_name_without_eating_the_last_word():
assert naive_title("Some.Show.Name") != "Some Show Name" # the trap it avoids
-# ── §10.1/V14: telling a real episode marker from a mangled number ──────────
+# ── V14: telling a real episode marker from a mangled number ────────────────
def test_has_episode_marker_accepts_real_markers():
for name in ["Some.Show.S01E08.mkv", "some.show.s1.e8.mkv",
@@ -244,7 +244,7 @@ def test_non_season_folder_name_returns_none():
assert season_from_folder_name("Some Show Name") is None
-# ── §3.4c: a bare leading episode number, guessit's 3-digit blind spot ───────
+# ── V-findings: a bare leading episode number, guessit's 3-digit blind spot ─
def test_leading_episode_number_reads_the_whole_number():
assert leading_episode_number("001 Episode's Own Title.mkv") == 1
diff --git a/packages/meshbay-node/tests/test_tmdb.py b/packages/meshbay-node/tests/test_tmdb.py
index ab452ce..299ff2a 100644
--- a/packages/meshbay-node/tests/test_tmdb.py
+++ b/packages/meshbay-node/tests/test_tmdb.py
@@ -40,7 +40,7 @@ async def test_search_movie_returns_top_result_and_confidence():
await client.close()
-# ── §10.1/V9: year-exact preference, only when the top hit is weak ──────────
+# ── V9: year-exact preference, only when the top hit is weak ────────────────
@pytest.mark.asyncio
async def test_year_exact_result_wins_when_the_top_hit_is_low_confidence():
diff --git a/packages/meshbay-node/tests/test_tmdb_config_policy.py b/packages/meshbay-node/tests/test_tmdb_config_policy.py
index c1781e3..cef4fda 100644
--- a/packages/meshbay-node/tests/test_tmdb_config_policy.py
+++ b/packages/meshbay-node/tests/test_tmdb_config_policy.py
@@ -1,6 +1,7 @@
"""
-The operator's custom TMDB API token and query language — docs/mediacenter.md
-§5.5. Same shape as test_apps_enabled_policy.py/test_scan_settings_policy.py:
+The operator's custom TMDB API token and query language —
+docs/MESHBAY_DESIGN.md §9.7. Same shape as
+test_apps_enabled_policy.py/test_scan_settings_policy.py:
a signed operator instruction, node-wide (group_id="") rather than per-group,
stored via roster.py's group_settings table.
diff --git a/packages/meshbay-node/tests/test_tmdb_enabled_policy.py b/packages/meshbay-node/tests/test_tmdb_enabled_policy.py
index 8e945ad..0a748cd 100644
--- a/packages/meshbay-node/tests/test_tmdb_enabled_policy.py
+++ b/packages/meshbay-node/tests/test_tmdb_enabled_policy.py
@@ -1,5 +1,5 @@
"""
-Whether TMDB lookups run *at all* for a group — docs/mediacenter.md §5.5.
+Whether TMDB lookups run *at all* for a group — docs/MESHBAY_DESIGN.md §9.7.
Per-group (2026-08-24 — used to be node-wide, folded into tmdb_config): a
real media-library group and a test/demo group on the same node need not
share the decision to spend TMDB quota and make outbound requests. Same
diff --git a/packages/meshbay-node/tests/test_tmdb_rematch_policy.py b/packages/meshbay-node/tests/test_tmdb_rematch_policy.py
index ff259c1..198d75b 100644
--- a/packages/meshbay-node/tests/test_tmdb_rematch_policy.py
+++ b/packages/meshbay-node/tests/test_tmdb_rematch_policy.py
@@ -1,5 +1,5 @@
"""
-`tmdb_rematch` (§10.1/V13) — an operator dropping one file's cached TMDB
+`tmdb_rematch` (V13) — an operator dropping one file's cached TMDB
match so it re-resolves with the current matcher. Signed like
`tmdb_override` (media_cache is shared node-wide); unlike `clear_file_tmdb`
it forgets a manual override marker too, since the operator is explicitly
diff --git a/packages/meshbay-node/tests/test_tmdb_search_bound.py b/packages/meshbay-node/tests/test_tmdb_search_bound.py
new file mode 100644
index 0000000..486a2c2
--- /dev/null
+++ b/packages/meshbay-node/tests/test_tmdb_search_bound.py
@@ -0,0 +1,186 @@
+"""
+One member's typing must not spend what the whole group depends on.
+
+`tmdb_search_req` takes a member's free text and calls TMDB with the
+**operator's** credential. That credential is rated by TMDB and shared: the
+automatic matching every other member sees runs on it too. So a member holding
+down a search box — or a script doing it — degrades the library for everyone and
+costs the operator their quota, and the node had no ceiling of any kind on it.
+§6.5's standing rule is a bound and a named adversary in the same commit; this
+handler shipped with neither.
+
+Two members in every test here, which is the point: a ceiling that one person
+can exhaust for another is not a ceiling, it is a queue. The per-member window
+is what keeps them apart, and the node-wide one is what keeps them together
+from emptying the operator's quota — they answer different questions and both
+are checked.
+
+The refusal is an error rather than an empty result. An empty list is what "no
+such film" looks like, and telling somebody their film is unknown when the node
+simply declined to ask is a worse answer than the truth.
+"""
+
+import pytest
+from meshbay_node.transport import webrtc_server
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+GROUP = "g" * 32
+
+
+class _FakeTmdb:
+ """Counts what would have been spent."""
+
+ def __init__(self):
+ self.calls = 0
+
+ async def search_movie_results(self, query):
+ self.calls += 1
+ return [{"id": 1, "title": "Some Saga", "release_date": "1999-01-01",
+ "poster_path": None}]
+
+ async def search_tv_results(self, query):
+ self.calls += 1
+ return []
+
+
+class _FakeMediaCache:
+ async def get_thumb_hash_by_file_id(self, _file_id):
+ return None
+
+
+@pytest.fixture
+def group():
+ """One group's context, shared by every session in it, as a node has."""
+ return {
+ "gek": b"k" * 32,
+ "tmdb_enabled": True,
+ }
+
+
+@pytest.fixture
+def node(group):
+ tmdb = _FakeTmdb()
+ ctx = {
+ "groups": {GROUP: group},
+ "media_cache": _FakeMediaCache(),
+ "tmdb_client": tmdb,
+ }
+ return ctx, tmdb
+
+
+def _member(ctx, user_id: str) -> WebRTCPeerSession:
+ s = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ s._ctx = ctx
+ s._group_id = GROUP
+ s._user_id = user_id
+ s._peer_id = user_id
+ s.sent = []
+ s._send = s.sent.append
+ s._audit = lambda *a, **k: None
+ return s
+
+
+async def _search(session, query="a film"):
+ await session._do_tmdb_search_request(
+ {"query": query, "media_type": "movie"})
+
+
+def _refusals(session):
+ return [m for m in session.sent
+ if m.get("code") == "tmdb_search_rate_limited"]
+
+
+async def test_a_member_at_the_ceiling_does_not_stop_another_one(node, monkeypatch):
+ """
+ The property a one-member test cannot state.
+
+ Alice exhausts her own window; Bob, who has typed nothing, must be served
+ exactly as if she had not been there.
+ """
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 3)
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_NODE", 100)
+ ctx, tmdb = node
+
+ alice = _member(ctx, "alice")
+ for i in range(4):
+ await _search(alice, f"film {i}")
+ assert tmdb.calls == 3, "the ceiling did not stop the fourth search"
+ assert len(_refusals(alice)) == 1
+
+ bob = _member(ctx, "bob")
+ await _search(bob, "something else")
+ assert tmdb.calls == 4
+ assert _refusals(bob) == []
+
+
+async def test_one_member_cannot_spend_the_whole_node_quota(node, monkeypatch):
+ """
+ And the other half: two members together still meet a node-wide ceiling,
+ because the operator's credential is one credential however many people
+ hold the search box down.
+ """
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 100)
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_NODE", 2)
+ ctx, tmdb = node
+
+ alice, bob = _member(ctx, "alice"), _member(ctx, "bob")
+ await _search(alice)
+ await _search(bob)
+ await _search(bob)
+
+ assert tmdb.calls == 2
+ assert len(_refusals(bob)) == 1
+
+
+async def test_a_members_count_survives_their_reconnection(node, monkeypatch):
+ """
+ Kept in the group context, not on the session: otherwise the ceiling is one
+ reconnect wide, and a client that drops its DataChannel between searches has
+ no ceiling at all.
+ """
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 2)
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_NODE", 100)
+ ctx, tmdb = node
+
+ first = _member(ctx, "alice")
+ await _search(first, "one")
+ await _search(first, "two")
+
+ reconnected = _member(ctx, "alice") # same person, new connection
+ await _search(reconnected, "three")
+
+ assert tmdb.calls == 2, "a reconnect reset the member's window"
+ assert len(_refusals(reconnected)) == 1
+
+
+async def test_a_refusal_is_said_out_loud_and_not_drawn_as_no_matches(node, monkeypatch):
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_PER_MEMBER", 0)
+ ctx, _ = node
+
+ alice = _member(ctx, "alice")
+ await _search(alice)
+
+ (msg,) = alice.sent
+ assert msg["type"] == "error"
+ assert msg["code"] == "tmdb_search_rate_limited"
+ assert msg.get("results") is None, (
+ "a refusal that carries an empty result list reads as 'no such film'")
+
+
+async def test_the_windows_do_not_grow_without_bound(node, monkeypatch):
+ """
+ The lists are trimmed on every call, so the thing that bounds a member also
+ bounds what remembering them costs.
+ """
+ monkeypatch.setattr(webrtc_server, "_TMDB_SEARCH_WINDOW", 0.0)
+ ctx, tmdb = node
+
+ alice = _member(ctx, "alice")
+ for i in range(12):
+ await _search(alice, f"film {i}")
+
+ # Every entry ages out before the next call, so nothing is refused, and what
+ # is kept is the one just recorded rather than one per search ever made.
+ assert tmdb.calls == 12
+ assert len(ctx["groups"][GROUP]["tmdb_search_hits"]["alice"]) == 1
+ assert len(ctx["tmdb_search_hits_node"]) == 1
diff --git a/packages/meshbay-node/tests/test_tmdb_search_ladder.py b/packages/meshbay-node/tests/test_tmdb_search_ladder.py
index 9865c77..dc83733 100644
--- a/packages/meshbay-node/tests/test_tmdb_search_ladder.py
+++ b/packages/meshbay-node/tests/test_tmdb_search_ladder.py
@@ -208,7 +208,7 @@ async def test_strong_direct_match_costs_a_single_request():
assert client.calls == [("A Quiet Film", 2010)]
-# ── §10.1/V11: a decent primary hit with nothing more specific to try ──────
+# ── V11: a decent primary hit with nothing more specific to try ────────────
async def test_decent_primary_with_no_stronger_candidate_costs_one_call():
result, _, client = await _run(
@@ -222,7 +222,7 @@ async def test_decent_primary_with_no_stronger_candidate_costs_one_call():
"is not worth a second request once the primary hit is decent")
-# ── §10.1/V8: the show branch uses the same scored ladder ─────────────────
+# ── V8: the show branch uses the same scored ladder ───────────────────────
async def test_show_scored_ladder_beats_a_weak_primary_hit():
result, _, _ = await _run_show(
diff --git a/packages/meshbay-node/tests/test_transfer_settings.py b/packages/meshbay-node/tests/test_transfer_settings.py
index 7f3719d..bcfa6a8 100644
--- a/packages/meshbay-node/tests/test_transfer_settings.py
+++ b/packages/meshbay-node/tests/test_transfer_settings.py
@@ -4,7 +4,8 @@ of setting.
The **pools** are the machine's: how many transfers this node runs at once,
across every group, from `[node]` in node.toml with a roster override — the
-§2.11 pattern, changed from the Node page or the CLI, applied live.
+docs/MESHBAY_DESIGN.md §6.8 pattern, changed from the Node page or the CLI,
+applied live.
The **per-member cap** is a group's: how many one member may run at once here.
It lives on the node like every other group setting (not the hub, which would
diff --git a/packages/meshbay-node/tests/test_video_root_gates_enrichment.py b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py
index fd0e040..d06b3f4 100644
--- a/packages/meshbay-node/tests/test_video_root_gates_enrichment.py
+++ b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py
@@ -1,5 +1,5 @@
"""
-Videos-app enrichment (ffprobe/thumbnailing/TMDB, mediacenter.md §5.2/§10)
+Videos-app enrichment (ffprobe/thumbnailing/TMDB, docs/MESHBAY_DESIGN.md §6.5)
only ever runs for a group that has a video_root configured, and only for
files under it — see daemon.py's _enrich_new_video_entries. Burning TMDB's
rate limit and the node's CPU on an operator's whole shared index before
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index 3010fe9..0839d42 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -768,7 +768,8 @@ async def test_webrtc_peer_cleanup_on_close(sk_node, sk_hub, gek, shared_dir):
browser_pc, channel, received = await _setup_peer(
transport, sk_hub, gek, "peer-cleanup")
- # Keyed per connection, not per account (docs/chat-sender-keys.md F7), so
+ # Keyed per connection, not per account (finding F7,
+ # docs/MESHBAY_DESIGN.md §13.6), so
# membership is asserted by the session object rather than by user_id —
# one account may hold several entries here.
peers = transport._ctx["_peers"]