summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md2
-rw-r--r--docs/apps.md49
-rw-r--r--docs/desktop-client-v1.md10
-rw-r--r--docs/mediacenter.md731
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py12
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py15
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py42
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js70
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apps.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js37
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js229
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/icon.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js36
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js36
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js36
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js36
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js36
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js36
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js36
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js38
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js36
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js36
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css286
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js198
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js816
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py2
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py3
-rw-r--r--packages/meshbay-node/pyproject.toml1
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py209
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich.py166
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/group_index.py33
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/title_parse.py174
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py188
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_probe.py70
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py62
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py46
-rw-r--r--packages/meshbay-node/src/meshbay_node/tmdb.py177
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py626
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py16
-rw-r--r--packages/meshbay-node/tests/test_enrich.py130
-rw-r--r--packages/meshbay-node/tests/test_media_cache.py71
-rw-r--r--packages/meshbay-node/tests/test_poster_cache.py88
-rw-r--r--packages/meshbay-node/tests/test_rename_reenrichment.py163
-rw-r--r--packages/meshbay-node/tests/test_season_and_search_requests.py187
-rw-r--r--packages/meshbay-node/tests/test_startup_scan_enrichment.py92
-rw-r--r--packages/meshbay-node/tests/test_stream_audio_transcode.py6
-rw-r--r--packages/meshbay-node/tests/test_title_parse.py129
-rw-r--r--packages/meshbay-node/tests/test_tmdb.py166
-rw-r--r--packages/meshbay-node/tests/test_tmdb_config_policy.py230
-rw-r--r--packages/meshbay-node/tests/test_tmdb_language_fallback.py98
-rw-r--r--packages/meshbay-node/tests/test_tmdb_override_policy.py172
-rw-r--r--packages/meshbay-node/tests/test_video_root_gates_enrichment.py153
-rw-r--r--packages/meshbay-node/tests/test_video_root_policy.py141
-rw-r--r--packages/meshbay-node/tests/test_wizard_apps_endpoint.py76
-rw-r--r--packaging/systemd/meshbay-node-user.service3
-rw-r--r--packaging/systemd/meshbay-node.service5
57 files changed, 6406 insertions, 142 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index ece3828..b4b5242 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -703,7 +703,7 @@ SFR residential Fedora 44 → meshbay.org OVH VPS:
| WebRTC signaling (hub) | `meshbay_hub.api.signaling` | Phase 9.2 — SDP/ICE relay |
| Browser transport client | `static/transport.js` | Phase 9.4 — WebRTC DataChannel |
| Web SPA | `static/app.js` | Phase 9.6 — Preact + preact-router. Routing and every page except a group's — see `docs/apps.md` for the group UI's file layout (2026-08-23 split) |
-| Group applications (adding one) | `docs/apps.md` | Chat/Files today, Videos/Music/Photos planned. Props contract, enablement mechanism, checklist |
+| Group applications (adding one) | `docs/apps.md` | Chat/Files/Videos today (see `docs/mediacenter.md` for Videos: poster grid, TMDB metadata, season tabs, manual match correction), Music/Photos planned. Props contract, enablement mechanism, checklist |
| File download (large) | `static/file-utils.js` | `downloadEntry`/`_openDownloadTarget`, File System Access API (`showSaveFilePicker`) — stream to disk |
| Background tasks (node) | `meshbay_node.transport.webrtc_server` | `_spawn()` — the only way to start one; a bare `ensure_future` can be collected |
| Stream handover (node) | `meshbay_node.transport.webrtc_server` | `_replace_stream` + `shutdown_tasks` — one viewer, one film, and the slot comes back when they leave |
diff --git a/docs/apps.md b/docs/apps.md
index 94116c6..a032f56 100644
--- a/docs/apps.md
+++ b/docs/apps.md
@@ -5,13 +5,18 @@
> `meshbay-draft-v6.md` §2.7 for why this exists and what it changes; this
> document is the how-to.
-A group has "applications" — Chat and Files today, Videos/Music/Photos planned
-(Netflix-style browsing, Spotify-style playback, an album viewer). None of the
-planned ones need an MNP protocol change: video/audio/image files are already
-classified by the node's indexer (`meshbay_node/indexer/indexer.py`, `type:
-video|audio|image`) and flow through the same `index_sync`/`file_req`/
-`stream_req` messages Files and `VideoPlayer` already use. Adding one is a new
-file plus one registry entry — nothing about the group shell changes.
+A group has "applications" — Chat, Files, and Videos today (a poster-grid
+browser; see `docs/mediacenter.md`), Music/Photos planned (a music player, an
+album viewer). Video/audio/image files are already classified by the node's
+indexer (`meshbay_node/indexer/indexer.py`, `type: video|audio|image`) and
+flow through the same `index_sync`/`file_req`/`stream_req` messages Files and
+`VideoPlayer` already use — Music/Photos need no MNP change beyond that.
+Videos itself did need one: TMDB metadata (`media_meta_req`/`resp`), per-season
+overview (`season_meta_req`/`resp`), and operator match correction
+(`tmdb_search_req`/`resp`, `tmdb_override`/`_ack`) are all additive message
+pairs on top of the same index/chunk plumbing, not a replacement for it.
+Adding a new app is still a new file plus one registry entry — nothing about
+the group shell changes.
---
@@ -172,18 +177,24 @@ the only node-side touches, and both are allow-lists, not new wire messages.
## 5. What does not exist yet
-- **Thumbnails/posters.** No generation mechanism, client or node side. A
- Netflix-style grid or a photo album needs *something* here; the pragmatic
- v1 (decided but not built) is lazy, client-side: decrypt the image, or a
- video's first segment, only for tiles actually visible, with an in-memory
- cache — no new MNP message, no node-side store. This is real per-tile cost
- (a full chunk-pipelined decrypt per thumbnail), acceptable for a browsing
- grid, not for hundreds of tiles rendered at once — a virtualized grid is
- part of building Videos/Photos, not optional.
-- **Videos, Music, Photos themselves.** Deliberately out of scope for the
- refactor that built this — see `meshbay-draft-v6.md` §2.7. The
- infrastructure above is proven end-to-end with the two apps that already
- existed (Chat, Files); a new one is additive.
+- **Thumbnails/posters — built for Videos, 2026-08-23, see `docs/mediacenter.md`.**
+ The plan below (lazy, client-side, no node-side store) turned out to be
+ wrong once a real design pass ran the numbers: `docs/mediacenter.md` §2
+ revises `desktop-client-v1.md`'s O12 and has the node generate thumbnails
+ (an `ffmpeg` frame grab, its own bounded worker pool) and cache them
+ durably in its own `data_dir`, delivered over the existing `file_req`/
+ chunk path addressed by their own blake3 hash. TMDB posters/metadata are
+ fetched and cached by the node the same way — no client ever talks to
+ TMDB directly. A virtualized grid (`IntersectionObserver`-based lazy
+ mount) is built in `video-app.js`, per the note below. A future Photos
+ app can reuse the same node-side machinery (thumbnail cache, chunk-path
+ delivery) without re-deciding any of this.
+- **Videos, Music, Photos themselves.** Videos is now built (`video-app.js`,
+ `docs/mediacenter.md`). Music and Photos remain deliberately out of scope
+ — see `meshbay-draft-v6.md` §2.7. The infrastructure in this document was
+ proven end-to-end first with Chat/Files, then with Videos; Music/Photos
+ are additive from here, and can reuse Videos' thumbnail/chunk-delivery
+ machinery rather than re-deciding it.
- **The offline/loopback settings path.** `member_upload` can be toggled two
ways: over a live MNP connection, or (Electron only) via the node's local
HTTP API when MNP isn't connected (`platform.node.call('PUT', .../member-
diff --git a/docs/desktop-client-v1.md b/docs/desktop-client-v1.md
index 08dd548..c11837c 100644
--- a/docs/desktop-client-v1.md
+++ b/docs/desktop-client-v1.md
@@ -733,6 +733,14 @@ Detecting it differs by platform, and neither method is exotic:
### 6.10 The planned libraries are views, not a catalogue
+> **Revised 2026-08-23 for the Videos group app** — see `docs/mediacenter.md` §2.
+> The "no thumbnail store, ever" conclusion below (O12) was an extension by
+> analogy to draft-v5 §5.2, not a direct consequence of it. The Videos app
+> now caches thumbnails and TMDB metadata durably in the node's own
+> `data_dir` — still never inside a shared root, and still no per-file
+> identity beyond the file's own content hash. Everything else in this
+> section stands: views over the index, not a catalogue.
+
**Scope settled 2026-08-17, and it is deliberately small.** The planned "video library"
and "audio library" are **alternative views over the existing file index** — a variant of
the explorer. Presentation rests on the filename and the directory structure, plus a
@@ -1124,7 +1132,7 @@ Two rules came out of building it and both are easy to undo by accident:
| O10 | Canonical file identity across filesystems | Case folding and NFC normalization decide when two names are "the same file". It affects the index, the no-overwrite rule and cross-platform replication, so it must be defined once and shared (§6.8) |
| O11 | Root alias when the basename cannot be used | Duplicate basenames across volumes will be common, and a basename can fail the portability rules. Refusing is correct; "rename the folder on your disk" is a poor answer, so an explicit alias is the escape hatch (§6.7) |
| O13 | Hub identity pinning | The client points at a hub by URL and nothing pins that hub's identity. `GET /v1/hub/pubkey` exists; pinning it on first use — as `pk_node` already is (11.5.8) — would stop an account's hub being silently swapped. Bounded today, because a substituted hub cannot read content and no longer ships the code; worth doing all the same |
-| ~~O12~~ | Derived thumbnails vs draft-v5 §5.2 | **Resolved 2026-08-17** — frame grab produced on demand by the node, cached on the requesting device. The node keeps no thumbnail store, so §5.2 stands unchanged (§6.10) |
+| ~~O12~~ | Derived thumbnails vs draft-v5 §5.2 | **Resolved 2026-08-17, revised 2026-08-23** — frame grab produced on demand by the node, cached on the requesting device. The node keeps no thumbnail store, so §5.2 stands unchanged (§6.10). **Revision:** for the Videos group app, the node now also caches thumbnails and TMDB metadata durably in its own `data_dir` (not a shared root) — see `docs/mediacenter.md` §2 for the reasoning (TMDB quota is per-node not per-device; thin clients benefit from a node that does more) |
---
diff --git a/docs/mediacenter.md b/docs/mediacenter.md
new file mode 100644
index 0000000..66232f8
--- /dev/null
+++ b/docs/mediacenter.md
@@ -0,0 +1,731 @@
+# MeshBay — Videos application (design)
+
+> Status: **proposal**, prepared 2026-08-23, not implemented. Read `docs/apps.md`
+> first — Videos is a new group application built on the plug-in mechanism
+> described there, and this document assumes that mechanism rather than
+> re-explaining it. Read `docs/desktop-client-v1.md` §6.10 before touching
+> thumbnails or metadata — this document **revises decision O12** from that
+> file, with the reasoning recorded in §2 below.
+>
+> Follows the project convention: every claim names the adversary it holds
+> against (§8).
+
+---
+
+## 0. What was asked, in one paragraph
+
+A streaming-service-style poster browser for a shared directory (or a subtree
+of one), with two operator-selectable modes: (a) **poster-grid**, metadata
+and artwork from TMDB, falling back to a thumbnail when TMDB match fails; (b)
+**flat**, alphabetically sorted, thumbnail-based, folders as
+categories/seasons, no TMDB. Both need a filename parser, evaluated in §3
+against a real local media library (~1950 files across three collections —
+standalone movies, TV shows with nested season folders, and a small
+single-franchise set) against a >95% bar. A default TMDB read token is
+provided; an operator may supply their own.
+
+---
+
+## 1. What this design does not reopen
+
+Everything already settled stands, and this plan is written to fit inside it,
+not around it:
+
+- **Views over the index, never a catalogue** (`desktop-client-v1.md` §6.10,
+ draft-v6 §2.7). A file stays tied to its filesystem representation; nothing
+ here adds a second identity for a file, and nothing reaches the hub.
+- **The apps plug-in mechanism** (`apps.md`): a new `video-app.js`, one
+ registry entry, one node-side `ALLOWED_APPS` entry, i18n keys, the asset
+ list, the two file-set tests. Enablement is a per-group,
+ operator-signed setting, same shape as `member_upload`.
+- **Group-related server state lives on the node** (E9). Nothing in this
+ design puts a row on the hub. TMDB and thumbnail caches are node-local,
+ disposable, and rebuildable from the shared files plus TMDB — the same
+ status the file index's own hash/size already have, not the kind of
+ authority state E9 protects (membership, roster, uploads).
+- **Filesystem portability** (§6.8): nothing here writes into a shared root.
+ See §2.
+
+## 2. Revision to decision O12 — where the cache lives, and why
+
+`desktop-client-v1.md` §6.10 (2026-08-17) decided thumbnails are computed
+**on demand by the node** and cached **on the requesting device**, never
+durably on the node. The stated reason was an analogy to draft-v5 §5.2's
+chat-thumbnail rule ("the node stores no second copy of an image it already
+holds"). Re-reading §5.2's actual text: it is about the **upload directory**,
+and the chat case it names is one the **browser computes itself for free**
+(resizing an image it already decrypted) — the node has no work to do at all.
+A video thumbnail is not that: it needs `ffmpeg` to decode a frame, work only
+the node can do, and §6.10 already conceded this ("a frame grab is strictly
+less than the decoding the node already does for streaming"). So the
+per-device-cache-only conclusion was an **extension by analogy** to a general
+project preference for keeping the node free of derived state — a real,
+deliberate stance (E9, "no metadata store, ever"), but a policy call, not a
+consequence of §5.2 itself.
+
+**Revised here, on two concrete grounds raised by the operator:**
+
+1. **TMDB quota is per API token, not per device.** A token shipped inside
+ every install and called directly from every browser/Electron client
+ scales with the number of *devices* across every MeshBay deployment in
+ existence, hitting TMDB's rate limits far sooner than if each **node**
+ makes the calls on behalf of all its own members — the number of nodes is
+ the relevant denominator, and each node's own cache means one lookup per
+ unique title serves every member, indefinitely.
+2. **Thin/future clients benefit from a node that does more, not less.**
+ `desktop-client-v1.md` §7.6 already flags Android as hostile to *hosting*
+ a node but fine as a *client*; a phone with limited storage and battery
+ should receive a small ready-made JPEG and a JSON blob, not decode video
+ or hold its own multi-gigabyte thumbnail cache.
+
+**What is decided:**
+
+- **TMDB metadata and posters/backdrops are fetched, cached and served by the
+ node.** Clients never call TMDB directly, from any platform. This also
+ removes a question this design would otherwise raise: the native client's
+ tested invariant is *"issues no request outside `/v1/` and the signaling
+ socket"* (`desktop-client-v1.md` §8.1) — if the client never talks to TMDB,
+ that invariant is untouched, no CSP change, no new egress from the
+ renderer.
+- **Video thumbnails are generated by the node (unchanged) and now cached by
+ the node too**, not only handed over and forgotten.
+- **The cache lives in the node's own `data_dir`** — a SQLite store or blob
+ files beside `chat.db`/`audit.db`/`bundle_store.db` — **never inside a
+ shared root**. This is not a compromise; it is strictly better than the
+ operator's original `.thumbs`-in-the-media-folder idea, and for reasons
+ independent of the sovereignty argument (§9): the node's own `data_dir` is
+ always writable by the process regardless of platform, whereas a shared
+ root is routinely a read-only backup mount, a NAS share the node has no
+ write access to, or an external exFAT/NTFS drive where a stray `.thumbs`
+ folder is not hidden by default on Windows and would appear as an ordinary
+ visible folder full of preview images of a private group's content to
+ anyone who plugs that drive into a different machine. Keeping the cache
+ entirely off the shared volume avoids that whole class of problem for
+ free, with no loss of the benefit the operator asked for (one computation,
+ reused by every member, every platform).
+- **A visible, node-wide toggle exists to turn TMDB calls off entirely**
+ (`tmdb_enabled`, default on) for an operator who wants zero third-party
+ network traffic. This is new "outbound to a third party" behaviour the
+ node did not have before (§8), and it is disabled independently of the
+ Videos app itself being enabled.
+- **Cache lifecycle is tied to the index.** A file's thumbnail cache entry
+ and its file→TMDB-id mapping are pruned when the indexer removes that
+ file's `IndexEntry` (the same event that already prunes the index itself).
+ TMDB metadata JSON is cached per TMDB id with its own refresh window (30
+ days is a reasonable default — TMDB ratings/overviews do change) rather
+ than tied to any one file, since several files (episodes of a show) share
+ one show-level fetch.
+
+**Security read of this reversal (per adversary, full table in §8):** against
+the node's own operator, nothing changes — they already hold every file in
+plaintext and could compute all of this themselves; against another member or
+an outside attacker, the same GEK-proofed MNP channel that already serves
+files and streams now also serves metadata and thumbnails, no new
+authorization surface. Keeping the TMDB token server-side is a **strict
+improvement** over shipping it to every renderer: one place holds the
+credential, not every device.
+
+`desktop-client-v1.md` should get a one-line pointer added at O12 when this is
+implemented, noting the revision and pointing here — not done in this
+document, which only adds the new design.
+
+---
+
+## 3. Filename parsing — empirical study
+
+### 3.1 Method
+
+The operator's own library was used as the test corpus, listed on
+2026-08-23:
+
+| Set | Files | Notes |
+|---|---|---|
+| movies collection | 261 | flat directory, mixed naming conventions, ~15 years of scene/tracker releases |
+| TV shows collection | 1677 | 21 shows, nested up to `Show/SxxSeasonFolder/episode.mkv`, several non-standard layouts |
+| single-franchise collection | 9 | small, single-franchise sanity check |
+
+`guessit` 4.4.0 (pure Python, MIT, pip-installable, actively maintained —
+the de facto reference implementation for this exact problem, used by
+Sonarr/Radarr-adjacent tooling) was installed in a scratch venv and run
+against every bare filename. Its output was then validated for real against
+the live TMDB API using the token the operator provided, with a small
+Python harness: search by parsed title (+ year for movies), score the result
+against the query with `difflib.SequenceMatcher`, and — this matters, see
+§3.3 — retry with a more naively cleaned title before giving up.
+
+The harness and raw output are not committed; the corpus itself is a local
+media library external to the repo. Re-running this validation against
+the production parser, on the same corpus, is listed as an acceptance step
+in §6.
+
+### 3.2 Results
+
+| Metric | Movies (261) | TV shows, show-level (21 shows / 1677 episodes) |
+|---|---|---|
+| `guessit` extracts a non-empty title from the bare filename | **100%** | 98.7% raw; the 1.3% gap is entirely episode files with no show name in the filename itself (e.g. `S08E02.SUBFRENCH.720p.mkv`) — recovered to 100% once the parent folder supplies the title (§3.4) |
+| `guessit` extracts season+episode | — | 98.6% raw (same gap, same fix) |
+| **Confident TMDB match, real network call** | **92.3%** (241/261), first pass, before the fixes in §3.3 | **100%** (21/21 shows, 1677/1677 episodes weighted) |
+
+Series clears the 95% bar outright. Films does not on the raw first pass —
+every miss has an identified, generic (not per-title) cause, listed next.
+
+### 3.3 What the film misses were, and the fix for each
+
+| Cause | Example pattern | Fix |
+|---|---|---|
+| Real title lands in `guessit`'s `alternative_title` field, not `title` | A "Franchise Name + numeric code" filename (e.g. `Franchise.007.-.1999.-.The.Real.Subtitle...`) parses to `title="Franchise 007"`, `alternative_title="The Real Subtitle"` | **Search both fields**; prefer whichever gets a confident TMDB result |
+| Hyphenated proper noun split at the hyphen | A hyphenated title immediately followed by a parenthesized year (e.g. `Hero-Name.(2002)...`) parses to `release_group="Hero"`, `title="Name"` (a known `guessit` tokenizer quirk before a parenthesized year) | Fallback tier: retry with **all** of `.`, `_`, `-` normalized to spaces (the naive fallback used in this test only normalized `.`/`_`, which is why this specific case still missed even with a fallback in place — noted as a bug in the test harness itself, not the parser design) |
+| French edition vocabulary not in `guessit`'s (English-centric) edition list | Titles carrying `Version.Longue`, `remasterise`, `Original.Version` as a release tag | Extend the edition/tag stripping list with `version longue`, `version intégrale`, `remasterisé`/`remasterise`, `non censuré`, `original version` |
+| Trailing sequel digit kept, real TMDB title has none or uses Roman numerals | A sequel filename ending in a bare `1`/`2` where the real TMDB title has no trailing digit, or uses a Roman numeral instead | Additional retry: same title **without** the trailing digit, and with it converted to a Roman numeral |
+| The test's own scoring picked the wrong result | A show referenced by its acronym in the folder name: TMDB's own search genuinely returns the right show first for that acronym — confirmed by a direct re-query — but the harness re-ranked TMDB's top-5 by local string similarity and preferred a coincidentally closer-looking unrelated show | **Trust TMDB's own top-ranked result** rather than re-ranking locally; use local similarity only as a confidence signal for a "not the right one? search again" affordance, never to override the choice |
+| No TMDB entry exists under any reasonable query | An obscure abbreviated title, unresolved even manually | Inherent, expected. Falls to the mandated fallback: thumbnail + minimally cleaned filename (§4.3) |
+
+None of these are per-title hacks; each is a generic rule. Applying them was
+not re-run against the full corpus for this planning pass (that means writing
+the production-quality parser, which is implementation, not planning) but
+every miss in the 261-file set is accounted for by one of the six rows above,
+which gives good confidence the >95% bar holds once built — to be confirmed
+empirically in §6's acceptance step, on the same corpus, before shipping.
+
+### 3.4 Directory context — the one thing bare-filename parsing cannot give you
+
+Two real findings from the series corpus, both because a bare filename does
+not carry the folder it lives in:
+
+- **Abbreviated folder names need corroboration from a file inside them.**
+ A show folder named after its acronym rather than its full name (with
+ episode files inside following the usual `Show.Name.SxxExx...` pattern) —
+ a design that only trusts a strict "does this look like the real title"
+ gate on the folder name alone is fragile the moment the query is
+ ambiguous, even when TMDB's own search actually does resolve the acronym
+ correctly (verified directly). **Rule:** derive the show title primarily
+ from `guessit` run on a representative **episode filename** inside the
+ folder (which reliably contains the full show name, per the table in
+ §3.2), and use the folder name only as a grouping key and a
+ manual-override anchor — never as the sole source of the TMDB query.
+- **Non-standard season folders are real, and TMDB already models them.**
+ One show in the corpus numbers its episodes with a bare leading integer
+ and no `SxxExx` token anywhere, uses a French word (not "season") plus a
+ Roman numeral as its season-folder name, and keeps a sibling folder for
+ specials. Querying TMDB directly confirmed this is not an obstacle to
+ work around — **TMDB's own season list for that show uses the identical
+ non-English season-naming scheme**, with a `season_number: 0` entry for
+ specials that the sibling folder maps onto exactly. **Rule, kept generic:**
+ when no `SxxExx` token is found, look for a season-like ancestor folder
+ matching `season|saison|<local-language season word>\s*(\d+|[ivx]+)`
+ (Roman numerals included, vocabulary configurable per locale), and treat a
+ folder matching `bonus|extras|specials?` as season 0. Match that folder's
+ name against the show's own TMDB season list rather than assuming a
+ numeric convention everywhere.
+
+### 3.5 What the parser does **not** need to guess
+
+The node already probes real technical facts from the file itself via
+`ffprobe` for streaming (`_probe_video()`,
+`meshbay_node/transport/webrtc_server.py`) — resolution, codec, duration.
+**None of that should come from the filename.** A file mislabeled `1080p`
+that is actually 720p is a real, observed failure mode of filename-derived
+metadata; ffprobe is authoritative and already runs on the node. The parser's
+job is narrowed to exactly what TMDB search and the flat view need: title,
+year, season, episode — a materially smaller surface than `guessit`'s full
+scope (it also parses codecs, languages, release groups — useful for
+debugging, not needed by either view).
+
+### 3.6 Where the parser runs
+
+**On the node, in Python, using `guessit` as a real dependency** (pure
+Python, MIT license, no native extensions — trivially addable to
+`packages/meshbay-node/pyproject.toml`), wrapped by a small
+`meshbay_node.indexer.title_parse` module implementing the rules in §3.3–3.4.
+This follows directly from §2: since the node already owns the TMDB call, it
+is the natural place to own the parse step too — no filename-parsing code is
+needed in the browser/Electron client at all. The parsed `(title, year,
+season, episode)` and a best-effort `display_title` (the flat-mode fallback:
+extension stripped, `_`/`.` replaced by spaces, used when parsing confidence
+is low) travel to clients as ordinary index fields (§5), computed once by the
+indexer and cached the same way size/hash already are.
+
+---
+
+## 4. The two view modes
+
+### 4.1 Mode A — poster grid (TMDB), default
+
+Browse by poster, click through to a detail view (poster,
+year, resolution + duration from the real probe, overview, cast, director,
+a **Play** button that hands off to the existing `video-player.js`/
+`stream_req` path unchanged). Falls back to a thumbnail-only card, with just
+the cleaned filename, when the node's TMDB lookup returns nothing or a
+low-confidence match (§3.3's last row) — exactly the operator's own fallback
+rule.
+
+Grouping for the poster grid: one card per **movie**, and one card per
+**show** (not per episode) that expands to a season/episode picker on click,
+using TMDB's own season list (§3.4) — the standard model for this kind of
+browser, and requiring no new grouping logic beyond what the folder structure
+and the node's per-show TMDB fetch already give.
+
+### 4.2 Mode B — flat, thumbnail-based, no TMDB
+
+Alphabetical, folder-driven, exactly as specified: a folder of movies is a
+flat list of thumbnail cards; a folder that turns out to be a show is a list
+of season subfolders (or files directly, if there's no season layer), each
+expandable, never TMDB-enriched. Per-card info comes entirely from the
+(now-populated, §5) index entry: `display_title`, `duration`, resolution
+(from the technical probe fields), file `size` — no network call, no TMDB
+dependency, and it keeps working with TMDB disabled node-wide.
+
+### 4.3 The toggle between modes
+
+**Per-device, `localStorage`**, same mechanism already used for the video
+player's resume position and the i18n language choice — a display
+preference with no security or authority implication, so it does not need
+the `roster.db` + signed-op treatment `member_upload`/`apps_enabled` get.
+Default: Mode A, per the operator's own spec.
+
+### 4.4 Scoping to a folder — added 2026-08-24, this was in §0 and got missed
+
+The operator's original ask was explicit: pick an already-shared directory,
+possibly a subdirectory of one already exposed — not necessarily the whole
+group. This did not make it into the plan when the rest of this document was
+written; caught only once the feature was in real use against an existing
+group.
+
+**Unlike the mode toggle above, this is not a per-device display
+preference** — it decides what *every* member's Videos tab shows, the same
+class of decision `apps_enabled`/`member_upload` already are. So it gets the
+same treatment: a per-group setting in `roster.db` (`video_root`, empty
+string meaning "the whole group index" — today's behaviour, unchanged),
+changed by a new signed op (`OP_VIDEO_ROOT`, `video_root`/`video_root_ack`
+on the wire) that any already-connected member is told about without
+reconnecting, exactly like `apps_enabled_ack`.
+
+**Where the operator sets it: Files, not Settings.** The folder being
+scoped is something the operator is already looking at while browsing —
+Files gets a toolbar action that toggles between "Set as Videos root" (any
+other folder) and "Clear Videos root" (the folder that already is one, or
+the top level when one is set deeper in the tree). No separate path-picker
+UI was built; browsing *to* the folder in the browser used for browsing
+everything else in the group is the picker.
+
+**Validation happens before a signature is ever asked for**, the same
+principle as apps_enabled's "empty set refused up front": a candidate path
+is resolved against the group's actual `RootSet` (the same traversal-safe
+resolution `entry_abs_path`/directory creation already use) and must name a
+real, currently-readable directory, or the request is refused immediately
+— a stale or mistyped path never reaches the operator's browser as a
+signing prompt.
+
+The client-side filter is a plain path-prefix test against `IndexEntry.path`
+(already root-relative, e.g. `"Séries/OVNI/S1/ep01.mkv"`) — no new index
+field, no server-side filtering; the node still sends the whole index, and
+narrowing to the configured folder is exactly the same kind of view-over-
+the-index work Files' own current-folder filter already does.
+
+---
+
+## 5. Protocol and index changes
+
+### 5.1 `IndexEntry` already reserves two of the three fields this needs
+
+`meshbay_common/protocol.py:133` — checked directly, and both fields are
+declared but **never populated by the indexer nor consumed by any client
+today**:
+
+```python
+duration: int | None = None # seconds, for media
+thumb_hash: str | None = None # blake3 of thumbnail
+```
+
+This is smaller than a fresh addition: `duration` needs the indexer to run
+(the existing) `ffprobe`-based probe at index time and store the result;
+`thumb_hash` needs a thumbnail to have been generated and cached, addressed
+by its own blake3 the same way a file is addressed by its `id` — which
+opens a clean reuse (§5.3). **New**, not yet present: `width`/`height`
+(resolution) and the parsed `display_title`/`season`/`episode` fields from
+§3. All are additive fields on the same dataclass, so this is a MNP
+**MINOR** bump (0.4 → 0.5, same class of change as the multi-root 0.2→0.3
+bump), not a breaking one — an older client simply doesn't render the new
+fields.
+
+### 5.2 Index-time cost, bounded the same way thumbnails already are
+
+Probing every video file with `ffprobe` at index time is real cost on a
+large library. `desktop-client-v1.md` §6.10 already established the pattern
+for exactly this shape of problem (thumbnails): **a small, bounded worker
+pool, its own short timeout, never sharing the streaming pool**
+(`max_concurrent_streams`), draining stdout and releasing the slot
+regardless — the same lesson `CLAUDE.md` records repeatedly for anything
+that shells out to `ffmpeg`/`ffprobe`. The probe (and the `guessit` parse,
+which is cheap by comparison) runs asynchronously after a file is first
+seen; the file appears in the index immediately with size/hash only, and an
+`index_sync` delta fills in the technical/parsed fields once ready — no
+scan is blocked waiting for it.
+
+### 5.3 Thumbnail delivery — reuse the chunk path
+
+Since `thumb_hash` is a blake3 exactly like a file's own `id`, the cleanest
+delivery is to **serve a thumbnail through the existing chunked
+`file_req`/`ChunkRequest` path**, with the node's chunk handler resolving a
+requested id against either a real file or the thumbnail cache. This reuses
+transport, GEK-scoped encryption, and backpressure handling that already
+exist and are already tested, rather than inventing a parallel mechanism. If
+that reuse proves awkward against the concrete dispatch code once someone
+is in it, a dedicated `thumb_req`/`thumb_resp` pair is a perfectly fine
+fallback — noted as an implementation choice, not a decision this document
+needs to force.
+
+### 5.4 New request/response: TMDB metadata
+
+A JSON blob, not chunked file content, so a plain request/response pair
+fits better than reusing the chunk path:
+
+```
+media_meta_req { path } # client → node, root+relpath already known from the index
+media_meta_resp { tmdb_id, title, original_title, overview, poster_thumb_hash,
+ backdrop_thumb_hash, release_date | first_air_date,
+ genres[], vote_average, runtime,
+ cast[] (name, character), director,
+ season?, episode? (for a TV request), confidence }
+```
+
+`poster_thumb_hash`/`backdrop_thumb_hash` point back at §5.3's mechanism —
+the node fetches the image from `image.tmdb.org` once, caches it under its
+own blake3 in `data_dir`, and clients pull it exactly like a video
+thumbnail. No client ever contacts `image.tmdb.org` or `api.themoviedb.org`
+directly (§2).
+
+**Bug found live, 2026-08-24**: `transport.js`'s generic reply routing
+resolves an untyped response against whichever request is *oldest pending*
+— correct for every request the client only ever has one of in flight, but
+`media_meta_req` is fired once per visible poster-grid tile, several at a
+time (apps.md §5's virtualization), exactly the shape `file_req` already
+special-cases with a `chunk:{file_id}:{chunk_index}` key. Without the same
+treatment, two concurrent lookups could resolve to each other's answer
+whenever their responses arrived out of order — reproduced live as two
+shows' posters/season lists swapping across reloads. Fixed by keying
+`media_meta_req`/`media_meta_resp` by `path`, the same way `file_req` is
+keyed by id+chunk.
+
+**Bug found live, 2026-08-24**: a poster-grid tile used to render its raw
+per-file frame immediately (already known from the index, no network round
+trip needed), then swap it for the TMDB poster once `media_meta_resp`
+arrived — and, for a §V6 merge candidate, briefly render as its own
+standalone card before collapsing into its sibling once both resolved to
+the same `tmdb_id`. Individually harmless, but visibly distracting in
+sequence (reported as "hurts the eyes"). `video-app.js`'s `PosterCard` now
+withholds a tile's content entirely — title, subtitle, and image alike —
+until both the metadata lookup and the chosen image (poster or fallback
+frame) have actually settled, showing a spinner in the meantime; a slow
+lookup (a very large, freshly-scanned library) just means the spinner
+stays a little longer. This does not (and structurally cannot, without
+giving up per-tile virtualization, apps.md §5) suppress every trace of a
+§V6 merge: a rare split-release show can still show two fully-resolved
+cards for an instant before they collapse into one, since the merge is
+only known once both siblings' independent lookups return.
+
+**Bug found live, 2026-08-24**: an operator reported the poster grid
+hanging on spinners forever on a *second* visit to the same group's Videos
+tab within one page session (revisit the tab, or a full reload — either
+way, reproducibly). Two real, separate causes:
+
+1. `_fetch_and_cache_poster` (`webrtc_server.py`) downloaded a TMDB poster/
+ backdrop from `image.tmdb.org` on *every* `media_meta_req`, even for a
+ file whose TMDB match was already cached — nothing had ever checked "is
+ this poster_path already fetched" before making the network call, since
+ the content-addressed `thumb_hash` isn't known until the bytes already
+ are. Fixed by keying the `thumbs` cache on a synthetic `tmdb:{poster_path}`
+ id, checked before the request (`media_cache.get_thumb_hash_by_file_id`).
+2. §V6's merge used to mint a brand-new key (every constituent title,
+ joined) the instant two raw groups turned out to be the same show —
+ destroying an already-visible, already-resolved card and rebuilding one
+ from scratch. Fixed by keying the merged entry as `groups[0].title`
+ instead: the overwhelmingly common (unmerged) case is unaffected, and a
+ merge now updates an existing card's props rather than replacing it.
+
+Neither of those was the actual hang, though both were real, worth fixing,
+and made it worse. The hang itself was a race in `PosterCard`'s own
+"nothing shown until ready" gate (§ above): `imageReady` was a `useState`
+reset to `false` by its *own* `useEffect` keyed on `posterHash`, and set to
+`true` by `MediaThumb`'s `onReady` callback. The instant `metaReady` flips
+true, `posterHash` jumps from the raw fallback frame to the resolved
+poster **in the same commit** that mounts `MediaThumb` for it. If that
+poster's bytes are already in `MediaThumb`'s session-lifetime blob cache —
+exactly true on a same-tab revisit, since that cache is never cleared —
+its `onReady` fires *synchronously* from that mount effect. Effects run
+children-first, so `PosterCard`'s own "reset on posterHash change" effect
+fired *after* it in that same commit, unconditionally overwriting the flag
+back to `false` — with nothing left to ever set it `true` again, since
+`posterHash` doesn't change a second time. The card was stuck as a
+spinner forever despite the image already being fully loaded and cached.
+Fixed by deriving `imageReady` from a direct comparison —
+`readyHash === posterHash`, where `onReady` sets `readyHash` — rather than
+a second, separately-timed writer of the same boolean: whichever of
+"meta resolves" and "image confirms ready" happens first, in whichever
+order effects run, the next render sees the same, correct answer. A cold
+fetch (no reload, or a genuinely new poster) never hit this at all,
+because `onReady` there fires asynchronously, in a later commit, well
+after the reset had already settled — which is exactly why it only ever
+showed up on a revisit, and only after the two fixes above had already
+made a revisit's fetch fast enough to *be* synchronous.
+
+### 5.5 Node-wide config, not per-group
+
+TMDB is one operator's budget and one credential, not a per-group concern,
+so it does not belong in `group_settings` keyed by a real `group_id`. The
+existing precedent for node-wide operator state stored in that same table is
+already established (`desktop-client-v1.md` §6.3: *"the precedent exists:
+`roster.get_member("", user_id)` already authorizes the operator
+node-wide"*) — so `tmdb_enabled` and an optional `tmdb_api_token` land in
+`group_settings` under the sentinel `group_id=""`, changed by a signed
+operator op (`OP_TMDB_CONFIG`, same shape as `OP_MEMBER_UPLOAD`/
+`OP_APPS_ENABLED`), and broadcast in `node_status` (the node-wide channel,
+as opposed to the per-group `handshake_ack`) so every connected client,
+across every group, sees whether TMDB is on without reconnecting. Absent
+means the shipped default token, present with a token means "use this one
+instead" — the same "absent means the old behaviour" discipline
+`member_upload`/`apps_enabled` already follow.
+
+**Added 2026-08-24**: a third field, `tmdb_language` (e.g. `"fr-FR"`),
+travels the same way — one node-wide setting, not a per-viewer request.
+Live against a real French-language show, the node's TMDB matches came back
+in English by default (`"UFOs"` for a show whose real title and audience
+are French) purely because no language was ever requested; TMDB honors a
+`language` parameter on every search/details call, so `tmdb.py`'s `_get()`
+now attaches whichever language is configured (or none, TMDB's own English
+default, if unset) to every request transparently — no change needed at
+any call site. The Settings UI pre-fills this field from the operator's own
+current browser language (a sensible default, not an attempt to read their
+hub-account profile, which the node has no way to learn), and the operator
+can still change it. This does not by itself fix an outright wrong match
+(a real, separate accuracy question, §3) — only which language a *correct*
+match's title/overview/poster come back in.
+
+**Correction, same day**: it turned out to fix more than that. Live, the
+French query didn't just translate the operator's example show — it
+resolved season 1's match correctly for the first time (previously a wrong,
+unrelated film in the same fallback slot, §3's kind of miss). A likely
+explanation, not a guaranteed one: TMDB's own search ranking can differ by
+language, and the French title happened to disambiguate what the English
+query could not. Not a substitute for §3's accuracy work — a French-only
+title with no ambiguity would not be helped this way — but a real,
+observed improvement worth having independent of translation.
+
+**Per-field English fallback, added same day**: TMDB does not fall back to
+English server-side for a field with no translation in the requested
+language — it returns `""` (or `[]`) for that one field, confirmed live
+(a French query left `overview` empty for a title TMDB has no French copy
+of, despite a good poster and cast). The TMDB *website* papers over exactly
+this gap client-side; `_tmdb_build_meta` (webrtc_server.py) now does the
+same — a second, English-forced details fetch only when a checked field
+(`overview`, `poster_path`, `genres`) comes back empty, merged field by
+field so a good localized field is never overwritten by the fallback.
+
+### 5.6 `ALLOWED_APPS`
+
+`webrtc_server.py:1629` — add `"video"` to the frozenset. `DEFAULT_APPS`
+(`roster.py`) is deliberately **left unchanged** (`("chat", "files")`): a
+brand-new group does not get Videos for free, since it is the first app that
+makes outbound third-party network calls (once TMDB is on) — the operator
+opts a group into it explicitly via the existing Settings checklist, same
+gesture as any other app.
+
+### 5.7 Season-specific overview, and correcting a wrong automatic match
+
+Found live, 2026-08-24, on a real 3-season show: TMDB's own search
+consistently resolved every season's folder to the same *season-3-specific*
+promotional entry — a wrong `tmdb_id`, not a MeshBay grouping bug (§3.3's
+`_best_match` deliberately trusts TMDB's own top result, per that section's
+own postmortem). Two independent problems, two independent fixes:
+
+**A show's own `overview` (§5.4) is one static field that does not
+necessarily describe every season alike.** New request/response pair, read
+lazily per selected tab (same virtualization discipline as `media_meta_req`,
+never fetched for a season the operator hasn't clicked):
+
+```
+season_meta_req { tmdb_id, season } # tmdb_id is whatever media_meta_resp
+ # already resolved — never re-searched here
+season_meta_resp { tmdb_id, season, confidence, name, overview, air_date,
+ poster_thumb_hash }
+```
+
+`video-app.js`'s `VideoDetailModal` shows a season tab bar
+(`Season 1` / `Season 2` / … / `Specials`) whenever a show has more than one
+season, defaulting to whichever season the representative episode belongs
+to. Selecting a tab both filters the episode list to that season and swaps
+in that season's own `overview`/`air_date` — falling back to the show-level
+`overview` when a season's own comes back empty (TMDB has no season-level
+text for every show), the same per-field fallback shape §5.4's English
+fallback already established, just one level further down when there is
+nothing at all to show otherwise.
+
+**An operator needs a way to correct a wrong match** when TMDB's own
+top-ranked result is simply wrong — no amount of local re-ranking fixes
+this (§3.3's last row is exactly the mistake that would repeat). Two more
+message pairs, the second an admin op:
+
+```
+tmdb_search_req { query, media_type } # media_type: "movie" | "tv"
+tmdb_search_resp { query, media_type, results: [{ tmdb_id, title, year,
+ poster_thumb_hash }] }
+
+tmdb_override { path, tmdb_id, media_type } # admin op, subject =
+ # "path={path},tmdb_id={tmdb_id},media_type={media_type}"
+tmdb_override_ack { path, tmdb_id, media_type } # broadcast to every connected peer
+```
+
+`tmdb_search_req` is deliberately **not** admin-gated — it is read-only (the
+same TMDB lookup the automatic matcher already performs on everyone's
+behalf) and returns nothing that isn't already visible in the search
+results a browser could get by hand. `media_type` is echoed back in the
+response, not only the query: a client that fires a movie search and a tv
+search for the same title in close succession needs it to tell the two
+responses apart for keyed matching (`transport.js`), the same reordering
+hazard §5.4's first postmortem already covers for `media_meta_req`.
+
+`tmdb_override`, once signed, is applied to **every index entry sharing the
+resolved file's `display_title`** (`webrtc_server.py`'s
+`_admin_exec_tmdb_override`) — the same grouping the poster grid itself uses
+(§3.4/V6) — not just the one file the operator happened to right-click,
+so the correction actually sticks for every episode of the show, and
+broadcasts a `tmdb_override_ack` to every connected peer so an already-open
+grid/modal picks up the change without a reconnect (`video-app.js`'s
+`useMediaMeta` subscribes to a module-level generation counter, bumped on a
+successful override, that forces every mounted tile/modal to refetch).
+
+New adminop: `OP_TMDB_OVERRIDE = "tmdb_override"` (`adminop.py`), following
+`OP_VIDEO_ROOT`/`OP_TMDB_CONFIG`'s exact shape — signed for the same reason:
+`media_cache` is shared node-wide, not per-viewer, so an unsigned override
+would let any member vandalize another show's metadata for everyone.
+
+**Bug found live, 2026-08-24**: the search overlay (a second, later
+`.video-overlay` sibling, opened on top of the detail modal) rendered
+completely invisibly the first time it shipped — present in the DOM
+(confirmed via the accessibility tree), inert on screen. Cause: its own
+`z-index: 1` lost to the base `.video-overlay` class's `z-index: 200` that
+the detail modal underneath it already used; both are `position: fixed`,
+so an explicit z-index always wins over DOM order regardless of which
+element mounted later. Fixed by giving `.video-search-overlay` an explicit
+`z-index: 210`.
+
+---
+
+## 6. Node-side implementation, concretely
+
+| Piece | Where | What |
+|---|---|---|
+| Title/season/episode parse | new `meshbay_node/indexer/title_parse.py` | Wraps `guessit`, applies §3.3–3.4's rules, returns `(display_title, year, season, episode, confidence)` |
+| Technical probe at index time | `meshbay_node/indexer/indexer.py`, reusing `_probe_video`'s ffprobe call from `webrtc_server.py` (extend `-show_entries` with `width,height`) | Populates `IndexEntry.duration`, new `width`/`height` |
+| Bounded probe/thumbnail pool | `meshbay_node/transport/webrtc_server.py`, sibling to the streaming pool | Small worker count, short timeout, drains stdout, never touches `max_concurrent_streams` |
+| TMDB client | new `meshbay_node/tmdb.py` | `httpx` (already a dependency) against `api.themoviedb.org`; resolves token via §5.5, falls back to the shipped default |
+| Caches | `data_dir/media_cache.db` (SQLite, alongside `chat.db`/`audit.db`/`bundle_store.db`) | `(content_hash) → tmdb_id` mapping; `(tmdb_id) → metadata json`, TTL 30 days; `(thumb_hash) → jpeg bytes`, pruned when the owning file leaves the index |
+| Operator config | `roster.py` `group_settings`, `group_id=""` | `tmdb_enabled`, `tmdb_api_token`; `ops.py` gains `set_tmdb_config(state, enabled, token=None)`, one `_op(...)` line, same adapters (loopback/CLI/MNP) as everything else in `ops.py` |
+| `pyproject.toml` | `packages/meshbay-node/pyproject.toml` | add `guessit>=4.4` |
+
+## 7. Client-side, per `apps.md`'s checklist
+
+1. `video-app.js` — receives the standard props (§2 of `apps.md`); internally
+ two presentational sub-views (poster grid / flat), switched by the
+ `localStorage` toggle (§4.3). Requests `media_meta_req` lazily, only for
+ currently-visible tiles in the poster grid (same virtualization discipline
+ `apps.md` §5 already calls out as mandatory for a grid of many tiles), and
+ thumbnails the same way.
+2. Register `{ key: "video", icon, labelKey: "group.tab_video", Component: VideoApp }` in `apps.js`.
+3. `ALLOWED_APPS` (§5.6).
+4. `group.tab_video` in all ten `static/locales/*.js`.
+5. `webapp.py`'s `_ASSETS` tuple — add `video-app.js`.
+6. `test_hook_ordering.py` (`STATIC_FILES`) and `test_transport_contracts.py`
+ (`SPLIT_FILES`) — add the new file to both.
+7. `npm run sync-ui` in `meshbay-client`.
+
+No hub change, no protocol change beyond §5's additive fields and two new
+message pairs — consistent with every other app added under this mechanism.
+
+---
+
+## 8. Security — per adversary
+
+| Claim | Passive hub | Active hub | Malicious node operator | Another member |
+|---|---|---|---|---|
+| TMDB token confidentiality | ✅ unreachable | ✅ unreachable | held by the operator, who could always query TMDB manually anyway — no new exposure | ✅ never transmitted to members |
+| Metadata/thumbnail delivery | ✅ unchanged transport | ✅ unchanged transport | sees it already (holds the plaintext file) | same GEK-proofed MNP channel as files/streaming — no new authorization surface |
+| New outbound traffic (node → TMDB) | — | — | the operator already controls their own node's egress | — |
+| Stale cache after file deletion | — | — | pruned on the index's own deletion event (§2); until built, a deleted file's thumbnail/metadata could persist on the node's disk after removal from the share — a real, addressable gap, not a design flaw, and called out here so it is not forgotten | — |
+
+**The claim this design supports:** moving TMDB/thumbnail work to the node
+adds no new authorization boundary — everything travels over the channel
+that already proves group membership and GEK possession — and *removes* a
+credential (the TMDB token) from every client that would otherwise hold it.
+
+**The claim it must not make:** that this is free. It is new node behaviour
+(egress to a third party, a new disk-resident cache with a real deletion
+obligation) that did not exist before, and both are called out explicitly
+rather than left implicit.
+
+## 9. Filesystem/Windows — why §2's choice avoids the trap
+
+`desktop-client-v1.md` §6.8/§7.5 already established exFAT/NTFS/Windows as
+the **common** case, not an edge case. Had the cache lived inside a shared
+root (the operator's original `.thumbs` idea), it would have inherited every
+one of those constraints for no benefit:
+
+- A read-only backup mount or a NAS share the node has no write access to
+ would refuse the write outright — not a Windows quirk, a real failure on
+ any platform.
+- Windows does not hide a leading-dot folder the way Unix does; `.thumbs`
+ would need an explicit "hidden" attribute set (and kept set across
+ recreation), or it shows up as an ordinary visible folder in Explorer —
+ previews of a private group's content, visible to anyone who plugs that
+ drive into a different machine.
+- It would need to be filtered out of every directory listing path
+ consistently (indexer, root-name collision checks, watcher
+ reconciliation) — one more place a future change could forget it.
+
+Keeping the cache in the node's own `data_dir` (§2) — the same location
+`chat.db`/`audit.db`/`bundle_store.db` already use on every supported
+platform — sidesteps all three for free, with no loss of the benefit the
+centralization was for (one computation, reused by every member).
+
+---
+
+## 10. Open items
+
+| # | Item | Why it is not decided here |
+|---|---|---|
+| V1 | Exact wording/UX of the "TMDB disabled" state in Mode A (Mode B unaffected) | Product/UI choice, not architectural |
+| V2 | Whether `poster_thumb_hash`/`backdrop_thumb_hash` reuse the chunk path (§5.3) or get a dedicated pair | Depends on how the concrete `file_req` dispatch code reads once someone is implementing against it |
+| V3 | TMDB metadata cache TTL (30 days proposed) | Needs no more than a config constant; not worth deciding without seeing real staleness complaints |
+| V4 | Multi-audio-track / subtitle-track surfacing in the detail view | Out of scope for this pass — `video-player.js`'s existing track handling is unchanged; Videos only adds discovery and metadata |
+| V5 | `Music`/`Photos` apps | Explicitly out of scope, per `apps.md` — this document only builds `video-app.js` and the shared node-side machinery (TMDB client, thumbnail cache, title parser) that a future audio/photo app could also reuse |
+| ~~V6~~ | ~~Two folders of the same show, named by different release groups, can produce two separate poster-grid cards~~ | **Closed, 2026-08-24.** Rather than fuzzy title matching (real design decision, still deferred), `PosterGrid` now merges raw show-groups client-side once each group's TMDB lookup resolves to the same confident `tmdb_id` (`onMetaResolved` reports each `PosterCard`'s resolved meta upward; a `useMemo` groups by id, combining episodes/seasons into one card). This only merges what TMDB already agrees is one show — a genuinely unmatched show still gets its own card, which is correct. Confirmed live: `OVNIs.S01...`/`Ovnis-S01...` now render as a single "OVNI(s)" card with both seasons |
+| ~~V7~~ | ~~A show's automatic TMDB match can be wrong at the show level (not just mis-ranked locally), and a wrong match's `overview` can read as scoped to one season~~ | **Closed, 2026-08-24 — §5.7.** Per-season tabs (own `overview`/`air_date`, falling back to the show-level text when empty) plus an operator-only "Fix match…" search-and-correct affordance, applied to every file sharing the resolved `display_title`. Confirmed live on the operator's real "War of the Worlds" 3-season show, itself matched to a wrong season-3-specific 1988 promotional TMDB entry: season tabs correctly filtered episodes and swapped in each season's own air_date, and the search overlay returned real TMDB candidates for a manual correction |
+
+## 11. Acceptance before shipping
+
+1. Re-run the §3 validation (real TMDB calls, same corpus, same script
+ methodology) against the actual production parser and `tmdb.py`, not the
+ scratch harness used for this planning pass. Keep the script in `QE/`
+ (not versioned, per repo convention) as a regression check for future
+ parser changes.
+2. Confirm the >95% bar on `Films/` with the §3.3 fixes applied for real,
+ not just argued.
+3. Verify cache pruning on file deletion actually fires (§2, §8) before
+ calling the durable-cache reversal complete — an untested claim here
+ would repeat the exact "documented but not built" pattern this project's
+ own history (CLAUDE.md) warns about repeatedly. Covered by an automated
+ test (`test_media_cache.py`), not just argued.
+
+**Done, 2026-08-24**: the full pipeline ran live against the operator's real
+node and library (a real show, real TMDB token) — probe/parse/thumbnail
+enrichment, TMDB search/match/credits, poster fetch-and-cache, chunk-path
+delivery, and the poster-grid/flat-list/detail-modal UI, end to end, no
+errors. One real bug was found and fixed this way that no unit test would
+have caught: `DirectoryIndexer.initial_scan()` (run once at startup) never
+itself triggers the `on_change` callback enrichment hangs off — daemon.py's
+`_bg_scan` now explicitly calls `_on_index_change` after the startup scan
+completes, or every file already on disk at boot (the common case) would
+never get enriched, only files added later while the node runs. Covered by
+`test_startup_scan_enrichment.py`. The exhaustive §3 corpus re-run (item 1
+above, item 2's >95% bar) is still open — this was a live smoke test of the
+mechanism, not the accuracy study.
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index a622623..938f5d4 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -8,5 +8,15 @@ __version__ = "0.6.0"
# handshake ack, for the group-applications registry. Additive — a node that
# predates it is never sent the op, and a client that predates it never looks
# for the field — so this is a MINOR bump too.
-MNP_VERSION = "0.4"
+# 0.5: added `width`/`height`/`display_title`/`season`/`episode` to
+# `IndexEntry`, started populating the already-declared `duration`/
+# `thumb_hash`, and added `media_meta_req`/`media_meta_resp`, for the Videos
+# group app. Additive — an older client simply doesn't render the new
+# fields — so this is a MINOR bump too.
+# 0.6: added `season_meta_req`/`season_meta_resp` (per-season TMDB overview,
+# rather than one static show-level summary applied to every season alike)
+# and `tmdb_search_req`/`tmdb_search_resp` + `tmdb_override`/`tmdb_override_ack`
+# (an operator correcting a wrong automatic TMDB match, found live against a
+# real show fragmented across TMDB entries per season). All additive.
+MNP_VERSION = "0.6"
MHP_VERSION = "0.1"
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
index c48f011..51396c7 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -61,6 +61,21 @@ OP_APPS_ENABLED = "apps_enabled"
# security property in itself, but the pattern (every operator setting is
# signed) is what keeps the authorization model simple to reason about.
OP_SET_SCAN_SETTINGS = "set_scan_settings"
+# Whether the node calls TMDB at all, and whether it uses the operator's own
+# API token instead of the shipped default. Signed like the rest: it turns
+# on outbound third-party network traffic that did not exist before the
+# Videos app (docs/mediacenter.md §5.5, §8) — an unsigned toggle would let
+# any member turn on egress the operator never agreed to.
+OP_TMDB_CONFIG = "tmdb_config"
+# Which folder (possibly a subfolder of a shared root) is the Videos app's
+# entry point for this group — per-group, unlike tmdb_config. Signed like
+# the rest: it decides what every member's Videos tab shows.
+OP_VIDEO_ROOT = "video_root"
+# Correcting a wrong automatic TMDB match — signed for the same reason as
+# tmdb_config: it changes what every member sees for a show/movie, node-wide
+# (media_cache is shared, not per-viewer), so an unsigned override would let
+# any member vandalize another show's metadata.
+OP_TMDB_OVERRIDE = "tmdb_override"
OP_ROOT_ADD = "root_add"
OP_ROOT_REMOVE = "root_remove"
OP_GROUP_ATTACH = "group_attach"
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index e1bfb8e..900acc2 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -90,6 +90,18 @@ class MNP:
APPS_ENABLED_ACK = "apps_enabled_ack"
SET_SCAN_SETTINGS = "set_scan_settings" # operator → node: reconcile/debounce timing
SET_SCAN_SETTINGS_ACK = "set_scan_settings_ack"
+ MEDIA_META_REQ = "media_meta_req" # client → node: TMDB metadata for a path
+ MEDIA_META_RESP = "media_meta_resp" # node → client: TMDB metadata (or none)
+ VIDEO_ROOT = "video_root" # operator → node: which folder is the Videos entry point
+ VIDEO_ROOT_ACK = "video_root_ack"
+ TMDB_CONFIG = "tmdb_config" # operator → node: enable/disable TMDB, set token
+ TMDB_CONFIG_ACK = "tmdb_config_ack" # node → everyone: new TMDB config (never the token)
+ SEASON_META_REQ = "season_meta_req" # client → node: TMDB overview/poster for one season
+ SEASON_META_RESP = "season_meta_resp" # node → client: season-level TMDB fields (or none)
+ TMDB_SEARCH_REQ = "tmdb_search_req" # client → node: candidate TMDB matches for a query
+ TMDB_SEARCH_RESP = "tmdb_search_resp" # node → client: candidate list (id, title, year, poster)
+ TMDB_OVERRIDE = "tmdb_override" # operator → node: replace a show/movie's TMDB match
+ TMDB_OVERRIDE_ACK = "tmdb_override_ack"
# Device linking. A new device files a request bound to a code it displays;
# an already-pinned device of the same account approves it. Neither the hub
# nor the node can produce the countersignature.
@@ -141,6 +153,30 @@ class IndexEntry:
thumb_hash: str | None = None # blake3 of thumbnail
uploader_id: str | None = None # user_id of who uploaded (None = pre-existing on disk)
uploader_pk: str | None = None # Ed25519 public key of uploader (base64 raw 32 bytes)
+ width: int | None = None # pixels, video only
+ height: int | None = None # pixels, video only
+ display_title: str | None = None # parsed or cleaned-filename title, Videos app
+ season: int | None = None # parsed season number, Videos app
+ episode: int | None = None # parsed episode number, Videos app
+
+
+def index_entry_wire(e: IndexEntry) -> dict:
+ """
+ The wire-dict shape used by INDEX_SYNC/INDEX_DELTA hand-built messages
+ (as opposed to GroupIndex.serialize()'s asdict() encoding of the whole
+ index). Centralized so the three call sites that build these
+ (webrtc_server._do_index_sync, daemon._broadcast_index_change's two
+ branches) can't drift from each other as fields are added.
+ """
+ return {
+ "id": e.id, "name": e.name, "path": e.path,
+ "size": e.size, "type": e.type, "added_at": e.added_at,
+ "uploader_id": e.uploader_id,
+ "duration": e.duration, "thumb_hash": e.thumb_hash,
+ "width": e.width, "height": e.height,
+ "display_title": e.display_title,
+ "season": e.season, "episode": e.episode,
+ }
@dataclass
@@ -149,6 +185,12 @@ class IndexDelta:
version: int
additions: list[IndexEntry] = field(default_factory=list)
deletions: list[str] = field(default_factory=list) # list of ids
+ # Same id as before (same file, same content hash), different field
+ # values — e.g. the Videos app's async enrichment filling in duration/
+ # thumb_hash/etc. after the file was already indexed with hash+size only.
+ # A distinct list from `additions`: `GroupIndex.diff()` only ever adds an
+ # id here once it has already appeared unchanged in a prior snapshot.
+ updates: list[IndexEntry] = field(default_factory=list)
# ── Chunk request/response ────────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index e11e718..36d94bb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -33,7 +33,7 @@ _ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js",
# app.js or group-page.js, so a change to any of them is a change
# to what the browser must fetch.
"icon.js", "file-utils.js", "hub-client.js", "apps.js",
- "chat-app.js", "files-app.js", "video-player.js",
+ "chat-app.js", "files-app.js", "video-player.js", "video-app.js",
"group-settings.js", "group-page.js")
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 044457b..f9bcd43 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -16,6 +16,7 @@ import {
refreshAccessToken,
} from './hub-client.js';
import { GroupPage } from './group-page.js';
+import { APPS } from './apps.js';
// ── Constants ────────────────────────────────────────────────────────────────
@@ -838,6 +839,15 @@ function CreateGroupWizard({ token, username, onCreated }) {
const [joinPolicy, setJoinPolicy] = useState('invite');
const [roots, setRoots] = useState([]);
const [uploadIdx, setUploadIdx] = useState(0);
+ // Every registered app, on by default — narrowing this down here means
+ // members never briefly see one the operator meant to leave off, the way
+ // toggling it afterward from Settings would.
+ const [enabledApps, setEnabledApps] = useState(() => APPS.map(a => a.key));
+ const toggleWizardApp = useCallback((key) => {
+ setEnabledApps(prev => prev.includes(key)
+ ? prev.filter(k => k !== key)
+ : [...prev, key]);
+ }, []);
// Step 2 progress
const [setupSteps, setSetupSteps] = useState([]);
@@ -914,8 +924,13 @@ function CreateGroupWizard({ token, username, onCreated }) {
const steps = [
{ label: t('wizard.step_create_hub'), status: 'pending' },
{ label: t('wizard.step_attach'), status: 'pending' },
- { label: t('wizard.step_index'), status: 'pending' },
];
+ // Only a step at all when it does something — the common case (every
+ // app left on, the default) has nothing to set and no reason to show a
+ // step for it.
+ if (enabledApps.length < APPS.length)
+ steps.push({ label: t('wizard.step_apps'), status: 'pending' });
+ steps.push({ label: t('wizard.step_index'), status: 'pending' });
if (roots.length > 1)
steps.push({ label: t('wizard.step_add_roots'), status: 'pending' });
steps.push({ label: t('wizard.step_gek'), status: 'pending' });
@@ -979,11 +994,24 @@ function CreateGroupWizard({ token, username, onCreated }) {
update('done');
advance();
- // 3. Wait for the node's own initial scan of this group to finish —
+ // 3. Narrow the enabled apps down, if the operator unchecked any —
+ // before the scan below, so a member who joins while it is still
+ // running never briefly sees an app meant to be off. Same
+ // not-hosted-yet race as steps 4+ below: the group is attached, but
+ // may not have reached groups_ctx yet.
+ if (enabledApps.length < APPS.length) {
+ update('running');
+ await withRetry(() => platform.node.call(
+ 'PUT', `/api/groups/${gid}/apps`, { apps: enabledApps }));
+ update('done');
+ advance();
+ }
+
+ // 4. Wait for the node's own initial scan of this group to finish —
// the group is not usable for anything below (extra roots, GEK) until
// this finishes, so nobody lands on a page that looks broken, or hits
// a "not configured" error from racing ahead of it. Can take tens of
- // minutes on a slow disk (see the StarWars benchmark) — the node
+ // minutes on a slow disk with a large library — the node
// keeps scanning on its own either way (test_hot_reload_survives_
// client_close.py); this step is only about not lying about it.
update('running');
@@ -991,7 +1019,7 @@ function CreateGroupWizard({ token, username, onCreated }) {
update('done');
advance();
- // 4. Add extra roots (if >1)
+ // 5. Add extra roots (if >1)
if (roots.length > 1) {
update('running');
for (let i = 0; i < roots.length; i++) {
@@ -1006,13 +1034,13 @@ function CreateGroupWizard({ token, username, onCreated }) {
advance();
}
- // 5. GEK init
+ // 6. GEK init
update('running');
await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`));
update('done');
advance();
- // 6. Generate pairing code
+ // 7. Generate pairing code
update('running');
const pairResult = await platform.node.call('POST', '/api/operator/pair');
if (pairResult && pairResult.code) {
@@ -1030,7 +1058,7 @@ function CreateGroupWizard({ token, username, onCreated }) {
update('error');
setSetupError(platform.bridgeMessage(err));
}
- }, [name, description, joinPolicy, roots, uploadIdx, token, onCreated]);
+ }, [name, description, joinPolicy, roots, uploadIdx, enabledApps, token, onCreated]);
// Step 0: Node detection
if (step === 0) {
@@ -1064,7 +1092,7 @@ function CreateGroupWizard({ token, username, onCreated }) {
// Step 1: Group details + directories
if (step === 1) {
- const canProceed = name.trim() && roots.length > 0;
+ const canProceed = name.trim() && roots.length > 0 && enabledApps.length > 0;
return html`<div class="page-content">
<h2>${t('wizard.title')}</h2>
${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`}
@@ -1111,6 +1139,25 @@ function CreateGroupWizard({ token, username, onCreated }) {
</div>
<div class="settings-section">
+ <h3 class="settings-heading">${t('members.apps_title')}</h3>
+ <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">
+ ${t('members.apps_hint')}</p>
+ <ul class="apps-toggle-list">
+ ${APPS.map(a => html`
+ <li key=${a.key} class="settings-row">
+ <label class="settings-label">
+ <input type="checkbox" checked=${enabledApps.includes(a.key)}
+ onChange=${() => toggleWizardApp(a.key)} />
+ ${' '}${t(a.labelKey)}
+ </label>
+ </li>
+ `)}
+ </ul>
+ ${enabledApps.length === 0 && html`
+ <p class="error-msg">${t('members.apps_need_one')}</p>`}
+ </div>
+
+ <div class="settings-section">
<h3 class="settings-heading">${t('wizard.directories')}</h3>
<p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">
${t('wizard.directories_hint')}</p>
@@ -1160,8 +1207,8 @@ function CreateGroupWizard({ token, username, onCreated }) {
${setupSteps.map((s, i) => html`
<div class="wizard-step wizard-step-${s.status}" key=${i}>
<span class="wizard-step-icon">
- ${s.status === 'done' ? '✓' :
- s.status === 'running' ? '●' :
+ ${s.status === 'running' ? html`<span class="spinner"></span>` :
+ s.status === 'done' ? '✓' :
s.status === 'error' ? '✗' : '○'}
</span>
<span>${s.label}</span>
@@ -1731,8 +1778,7 @@ function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) {
<span class="settings-label">${t('settings.default_tab')}</span>
<select class="settings-select" value=${defaultTab}
onChange=${changeDefaultTab}>
- <option value="chat">${t('group.tab_chat')}</option>
- <option value="files">${t('group.tab_files')}</option>
+ ${APPS.map((a) => html`<option key=${a.key} value=${a.key}>${t(a.labelKey)}</option>`)}
<option value="settings">${t('group.tab_settings')}</option>
</select>
</div>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
index 0db713c..88b4a0f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/apps.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
@@ -1,5 +1,6 @@
import { ChatPanel } from './chat-app.js';
import { FilesPanel } from './files-app.js';
+import { VideoApp } from './video-app.js';
/**
* Every group "application", in tab order.
@@ -16,6 +17,7 @@ import { FilesPanel } from './files-app.js';
const APPS = [
{ key: 'chat', icon: 'chat', labelKey: 'group.tab_chat', Component: ChatPanel },
{ key: 'files', icon: 'folder', labelKey: 'group.tab_files', Component: FilesPanel },
+ { key: 'video', icon: 'video', labelKey: 'group.tab_video', Component: VideoApp },
];
/** The registry filtered to what this group has enabled, in registry order. */
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 29f3602..55f8e36 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -80,6 +80,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// to the operator in Settings, not enforced from here (indexer.py owns
// that). Null until the handshake ack arrives.
const [scanSettings, setScanSettings] = useState(null);
+ // TMDB on/off + whether a custom token is set, node-wide (not per-group) —
+ // docs/mediacenter.md §5.5. Null until the handshake ack arrives.
+ const [tmdbConfig, setTmdbConfig] = useState(null);
+ // Which folder is the Videos app's entry point for this group — ''
+ // (the default) means the whole group index. Set from Files, per-group.
+ const [videoRoot, setVideoRoot] = useState('');
// Paired ≠ operator account. `is_node_admin` says the hub account owning this
// node is the one connecting; this says the node pinned *this browser's* key
// as an operator key. Only the second one lets you sign an invite, and only
@@ -127,20 +133,24 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
cacheGroupIndex(groupId, group ? group.name : groupId, fresh);
}, [groupId, group]);
- // additions/deletions only (daemon.py _broadcast_index_change, once there
- // is a previous snapshot to diff against) — applied on top of whatever
- // applyIndex last put in `entries`, instead of replacing the whole table
- // for one changed file.
+ // additions/deletions/updates (daemon.py _broadcast_index_change, once
+ // there is a previous snapshot to diff against) — applied on top of
+ // whatever applyIndex last put in `entries`, instead of replacing the
+ // whole table for one changed file. `updates` is the Videos app's async
+ // enrichment (duration/thumb_hash/display_title/...) arriving for a file
+ // already in the table — same id, new fields (see group_index.py diff()).
const applyIndexDelta = useCallback((deltaMsg) => {
setEntries((prev) => {
const deletions = new Set(deltaMsg.deletions || []);
const kept = prev.filter((e) => !deletions.has(e.id));
+ const updates = new Map((deltaMsg.updates || []).map((e) => [e.id, e]));
+ const updated = kept.map((e) => updates.get(e.id) || e);
// The index is keyed by content hash: an addition whose id is already
// present is the same duplicate-content case indexer.py's own
// reconcile sweep leaves alone, not a second row for one file.
- const keptIds = new Set(kept.map((e) => e.id));
+ const keptIds = new Set(updated.map((e) => e.id));
const additions = (deltaMsg.additions || []).filter((e) => !keptIds.has(e.id));
- const fresh = kept.concat(additions);
+ const fresh = updated.concat(additions);
cacheGroupIndex(groupId, group ? group.name : groupId, fresh);
return fresh;
});
@@ -196,11 +206,19 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setMemberUpload(ack.member_upload !== false);
setEnabledApps(ack.enabled_apps || null);
setScanSettings(ack.scan_settings || null);
+ setTmdbConfig({
+ enabled: ack.tmdb_enabled !== false,
+ tokenCustomized: !!ack.tmdb_token_customized,
+ language: ack.tmdb_language || '',
+ });
+ setVideoRoot(ack.video_root || '');
// Changed while we are connected, by an operator who may be someone
// else entirely. Without this the button stays until a reconnection,
// and a button that is still there is a button people press.
transport.onUploadPolicy = (allowed) => setMemberUpload(allowed);
transport.onAppsEnabled = (apps) => setEnabledApps(apps);
+ transport.onTmdbConfig = (cfg) => setTmdbConfig(cfg);
+ transport.onVideoRoot = (path) => setVideoRoot(path);
// The node's own scan (a root added while we were already connected,
// or reconcile catching one back up) — never the entries, just
// enough to animate the sidebar dot. Guaranteed a final push at the
@@ -390,6 +408,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
onRefreshIndex: refreshIndex, onActivity: touchActivity,
+ videoRoot, onVideoRoot: (path) => setVideoRoot(path),
+ tmdbConfig,
};
return html`
@@ -503,6 +523,11 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
onEnabledApps=${(keys) => setEnabledApps(keys)}
scanSettings=${scanSettings}
onScanSettings=${(s) => setScanSettings(s)}
+ tmdbConfig=${tmdbConfig}
+ onTmdbConfig=${(cfg) => setTmdbConfig(cfg)}
+ entries=${entries} nodeDirs=${nodeDirs}
+ videoRoot=${videoRoot}
+ onVideoRoot=${(path) => setVideoRoot(path)}
onLeft=${onLeft}
onPaired=${() => setOperatorPaired(true)} />
`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 0b64a03..22532fa 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -1,12 +1,22 @@
import {
- html, useState, useEffect, useCallback,
+ html, useState, useEffect, useCallback, useMemo, useRef,
} from './vendor/htm-preact.js';
-import { t } from './i18n.js';
+import { t, getLocale, LOCALES } from './i18n.js';
import { Icon } from './icon.js';
import { hubFetch, navigate } from './hub-client.js';
import { APPS } from './apps.js';
import * as platform from './platform.js';
+// MeshBay's own locale codes (i18n.js LOCALES) to the language tag TMDB
+// expects — the two don't share a format (MeshBay's "en" vs TMDB's
+// required region, "en-US"). Used only to pre-fill the TMDB language field
+// with the operator's own current UI language, a reasonable default they
+// can still change; the node never guesses this on its own.
+const TMDB_LANGUAGE_BY_LOCALE = {
+ en: 'en-US', fr: 'fr-FR', es: 'es-ES', 'pt-BR': 'pt-BR', 'zh-CN': 'zh-CN',
+ ja: 'ja-JP', de: 'de-DE', it: 'it-IT', nl: 'nl-NL', pl: 'pl-PL',
+};
+
// ── Members Panel ────────────────────────────────────────────────────────
/**
@@ -23,6 +33,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
memberUpload, onMemberUpload,
enabledApps, onEnabledApps,
scanSettings, onScanSettings,
+ tmdbConfig, onTmdbConfig,
+ entries, nodeDirs, videoRoot, onVideoRoot,
onPaired, onLeft }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
@@ -244,6 +256,138 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}
}, [transportRef, onScanSettings, reconcileMinutes, debounceSeconds]);
+ const [tmdbBusy, setTmdbBusy] = useState(false);
+ const [tmdbMsg, setTmdbMsg] = useState('');
+ const [tmdbTokenDraft, setTmdbTokenDraft] = useState('');
+ const tmdbEnabled = tmdbConfig ? tmdbConfig.enabled : true;
+ // Pre-filled from the operator's own current UI language the first time
+ // this renders with nothing configured yet — a sensible default, not a
+ // claim about what the node is actually using until they hit Save.
+ const [tmdbLanguage, setTmdbLanguage] = useState(
+ () => (tmdbConfig && tmdbConfig.language)
+ || TMDB_LANGUAGE_BY_LOCALE[getLocale()] || 'en-US');
+ useEffect(() => {
+ if (tmdbConfig && tmdbConfig.language) setTmdbLanguage(tmdbConfig.language);
+ }, [tmdbConfig && tmdbConfig.language]);
+
+ /**
+ * TMDB on/off, an optional custom API token, and the language TMDB is
+ * queried in — node-wide, not per-group (docs/mediacenter.md §5.5). Same
+ * shape as saveScanSettings: signed, and the toggle does not claim
+ * success until the node confirms it. The token field is cleared after a
+ * save either way: it is never echoed back by the node (tmdb_config_ack
+ * carries only whether one is set, never the value), so there is
+ * nothing to keep showing.
+ */
+ const saveTmdbConfig = useCallback(async (nextEnabled) => {
+ const transport = transportRef && transportRef.current;
+ setTmdbMsg('');
+ setTmdbBusy(true);
+ try {
+ if (!transport || !transport.connected) {
+ throw new Error('Not connected to the node');
+ }
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ const token = tmdbTokenDraft.trim();
+ await transport.setTmdbConfig(nextEnabled, token || undefined, tmdbLanguage, signFn);
+ setTmdbTokenDraft('');
+ if (onTmdbConfig) {
+ onTmdbConfig({
+ enabled: nextEnabled,
+ tokenCustomized: token
+ ? true
+ : (tmdbConfig ? tmdbConfig.tokenCustomized : false),
+ language: tmdbLanguage,
+ });
+ }
+ setTmdbMsg(t('settings_node.scan_saved'));
+ } catch (err) {
+ setTmdbMsg(err.message);
+ } finally {
+ setTmdbBusy(false);
+ }
+ }, [transportRef, onTmdbConfig, tmdbTokenDraft, tmdbConfig, tmdbLanguage]);
+
+ // A node that has never had a language explicitly set would otherwise
+ // query TMDB with none at all — which TMDB itself resolves to English,
+ // regardless of who the operator is — even though this form already
+ // *suggests* their own UI language as the value. Applied once,
+ // automatically, the first time the operator (the only one who can sign
+ // this) is actually connected to see it: a real default tied to whoever
+ // runs this particular node, never a single hardcoded language for every
+ // node. `tmdbConfig.language` being set at all — from this or from an
+ // explicit save — is what stops it from ever firing again, so "unless
+ // manually changed" holds regardless of which of the two set it first.
+ const autoLanguageSetRef = useRef(false);
+ useEffect(() => {
+ if (!isNodeAdmin || !connected || !tmdbConfig || tmdbConfig.language) return;
+ if (autoLanguageSetRef.current) return;
+ autoLanguageSetRef.current = true;
+ saveTmdbConfig(tmdbEnabled);
+ }, [isNodeAdmin, connected, tmdbConfig, tmdbEnabled, saveTmdbConfig]);
+
+ // Every folder anywhere in the group's shared index, deepest included —
+ // `entries[].path` is each file's containing directory (files-app.js's own
+ // convention), so every ancestor prefix of it is a real folder, and
+ // `nodeDirs` covers ones with nothing in them yet. A flat, depth-indented
+ // <select> rather than a live folder browser: choosing the Videos root is
+ // a rare, one-off decision, not something worth a whole navigable tree for.
+ const videoRootFolders = useMemo(() => {
+ const set = new Set();
+ const addAncestors = (path) => {
+ if (!path) return;
+ const parts = path.split('/');
+ for (let i = 1; i <= parts.length; i++) set.add(parts.slice(0, i).join('/'));
+ };
+ for (const e of (entries || [])) addAncestors(e.path);
+ for (const d of (nodeDirs || [])) addAncestors(d);
+ return [...set].sort();
+ }, [entries, nodeDirs]);
+
+ const [videoRootDraft, setVideoRootDraft] = useState(videoRoot || '');
+ useEffect(() => { setVideoRootDraft(videoRoot || ''); }, [videoRoot]);
+ const [videoRootBusy, setVideoRootBusy] = useState(false);
+ const [videoRootMsg, setVideoRootMsg] = useState('');
+
+ /**
+ * Which folder is the Videos app's entry point for this group — same
+ * shape as toggleApp/saveScanSettings: signed, and the picker does not
+ * claim success until the node confirms it.
+ *
+ * Changing an *already-set* root is destructive to every member's Videos
+ * tab (a different set of files, possibly none in common) — the operator
+ * confirms that explicitly. Setting it for the first time is not: there is
+ * nothing yet to lose.
+ */
+ const saveVideoRoot = useCallback(async () => {
+ const next = videoRootDraft;
+ const current = videoRoot || '';
+ if (next === current) return;
+ if (current && !confirm(t('settings_node.video_root_change_confirm'))) return;
+ const transport = transportRef && transportRef.current;
+ setVideoRootMsg('');
+ setVideoRootBusy(true);
+ try {
+ if (!transport || !transport.connected) {
+ throw new Error('Not connected to the node');
+ }
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ await transport.setVideoRoot(next, signFn);
+ if (onVideoRoot) onVideoRoot(next);
+ setVideoRootMsg(t('settings_node.scan_saved'));
+ } catch (err) {
+ setVideoRootMsg(err.message);
+ } finally {
+ setVideoRootBusy(false);
+ }
+ }, [transportRef, onVideoRoot, videoRootDraft, videoRoot]);
+
const [removing, setRemoving] = useState('');
/**
@@ -463,6 +607,87 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
+ ${/* TMDB on/off + custom token, node-wide (docs/mediacenter.md §5.5) —
+ new outbound third-party traffic the node did not have before
+ the Videos app, so it is a signed operator setting like the
+ rest, not a display preference. */
+ isNodeAdmin && connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings_node.tmdb_title')}</h3>
+ <p class="settings-hint">${t('settings_node.tmdb_hint')}</p>
+ <div class="settings-row">
+ <label class="settings-label">
+ <input type="checkbox" checked=${tmdbEnabled} disabled=${tmdbBusy}
+ onChange=${(e) => saveTmdbConfig(e.target.checked)} />
+ ${' '}${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')}
+ </label>
+ </div>
+ <div class="settings-row">
+ <label class="settings-label">
+ ${t('settings_node.tmdb_token_label')}
+ <input type="password" placeholder=${t('settings_node.tmdb_token_placeholder')}
+ value=${tmdbTokenDraft} disabled=${tmdbBusy}
+ onInput=${e => setTmdbTokenDraft(e.target.value)} />
+ </label>
+ <p class="settings-hint">
+ ${tmdbConfig && tmdbConfig.tokenCustomized
+ ? t('settings_node.tmdb_token_customized')
+ : t('settings_node.tmdb_token_default')}
+ </p>
+ </div>
+ <div class="settings-row">
+ <label class="settings-label">
+ ${t('settings_node.tmdb_language_label')}
+ <select value=${tmdbLanguage} disabled=${tmdbBusy}
+ onChange=${e => setTmdbLanguage(e.target.value)}>
+ ${LOCALES.map(l => html`
+ <option key=${l.code} value=${TMDB_LANGUAGE_BY_LOCALE[l.code]}>
+ ${l.name}
+ </option>
+ `)}
+ </select>
+ </label>
+ <p class="settings-hint">${t('settings_node.tmdb_language_hint')}</p>
+ </div>
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${tmdbBusy} onClick=${() => saveTmdbConfig(tmdbEnabled)}>
+ ${tmdbBusy ? t('settings_node.scan_saving') : t('settings_node.tmdb_save')}
+ </button>
+ ${tmdbMsg && html`<p class="settings-hint">${tmdbMsg}</p>`}
+ </div>
+ `}
+
+ ${/* Which folder is the Videos app's entry point for this group —
+ per-group like uploads, not node-wide like TMDB (mediacenter.md
+ §5.6). Until one is chosen, the Videos tab says so instead of
+ listing anything, and the node runs no TMDB/thumbnail work for
+ this group at all (daemon.py's _enrich_new_video_entries). */
+ isNodeAdmin && connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings_node.video_root_title')}</h3>
+ <p class="settings-hint">${t('settings_node.video_root_hint')}</p>
+ <div class="settings-row">
+ <label class="settings-label">
+ <select value=${videoRootDraft} disabled=${videoRootBusy}
+ onChange=${e => setVideoRootDraft(e.target.value)}>
+ <option value="">${t('settings_node.video_root_none')}</option>
+ ${videoRootFolders.map(p => html`
+ <option key=${p} value=${p}>
+ ${'  '.repeat(p.split('/').length - 1)}${p.split('/').pop()}
+ </option>
+ `)}
+ </select>
+ </label>
+ </div>
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${videoRootBusy || videoRootDraft === (videoRoot || '')}
+ onClick=${saveVideoRoot}>
+ ${videoRootBusy ? t('settings_node.scan_saving') : t('settings_node.video_root_save')}
+ </button>
+ ${videoRootMsg && html`<p class="settings-hint">${videoRootMsg}</p>`}
+ </div>
+ `}
+
${/* Roots management (Electron-only, when node is local) */
nodeDetected && nodeRoots.length > 0 && html`
<div class="settings-section">
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
index 4dc24c0..84a5195 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
@@ -69,6 +69,8 @@ const ICON_PATHS = {
cast: ['M2 16.1A5 5 0 0 1 6.9 21', 'M2 12.05A9 9 0 0 1 12.95 21',
'M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6',
'M2 21h.01'],
+ video: ['M3.5 6.5a1.5 1.5 0 0 1 1.5-1.5h14a1.5 1.5 0 0 1 1.5 1.5v11a1.5 1.5 0 0 1-1.5 1.5h-14a1.5 1.5 0 0 1-1.5-1.5z',
+ 'M10 9.5v5l4.5-2.5z'],
};
// The M of the wordmark is a picture; the rest is text. Resolved from this
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 866212f..7f05441 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -76,6 +76,7 @@ export default {
'group.default_name': 'Gruppe',
'group.tab_files': 'Dateien',
'group.tab_chat': 'Chat',
+ 'group.tab_video': 'Videos',
'group.tab_members': 'Mitglieder',
'group.tab_settings': "Einstellungen",
'members.danger_leave_hint': "Sie verlieren den Zugriff auf die Dateien und den Chat dieser Gruppe.",
@@ -156,6 +157,24 @@ export default {
'group.upload_indexing': 'wird indiziert …',
'video.err_transport': 'Transport nicht verbunden',
'video.err_mse': 'Codec wird für das Streaming nicht unterstützt: {codec}',
+ 'video.mode_poster': 'Poster',
+ 'video.mode_flat': 'Flache Liste',
+ 'video.empty': 'Keine Videos gefunden.',
+ 'video.no_root_configured': 'Für diese Gruppe ist noch kein Videos-Stammordner festgelegt — ein Operator kann in den Einstellungen einen auswählen.',
+ 'video.director': 'Regie',
+ 'video.specials': 'Specials',
+ 'video.season_n': 'Staffel {n}',
+ 'video.episode_n': 'Episode {n}',
+ 'video.n_episodes': {
+ one: '{n} Episode',
+ other: '{n} Episoden',
+ },
+ 'video.fix_match': 'Übereinstimmung korrigieren…',
+ 'video.search_title': 'TMDB-Übereinstimmung korrigieren',
+ 'video.search_placeholder': 'TMDB durchsuchen…',
+ 'video.search_button': 'Suchen',
+ 'video.search_no_results': 'Keine Treffer gefunden.',
+ 'video.search_apply_hint': 'Gilt für alle Dateien, die derzeit unter diesem Titel gruppiert sind.',
// LAN-Cast
'cast.start': 'Auf Gerät übertragen',
@@ -572,6 +591,22 @@ export default {
'settings_node.scan_save': 'Speichern',
'settings_node.scan_saving': 'Wird gespeichert…',
'settings_node.scan_saved': 'Gespeichert.',
+ 'settings_node.tmdb_title': 'TMDB-Metadaten',
+ 'settings_node.tmdb_hint': 'Ermöglicht der Videos-App, Poster, Beschreibungen und Besetzung von TMDB anzuzeigen. Deaktiviert bedeutet: nur Vorschaubilder, keine Anfrage an einen Drittanbieter.',
+ 'settings_node.tmdb_enabled': 'Aktiviert',
+ 'settings_node.tmdb_disabled': 'Deaktiviert',
+ 'settings_node.tmdb_token_label': 'Eigenes API-Token (optional)',
+ 'settings_node.tmdb_token_placeholder': 'Leer lassen, um das mitgelieferte Standard-Token zu verwenden',
+ 'settings_node.tmdb_token_customized': 'Ein eigenes Token ist gesetzt.',
+ 'settings_node.tmdb_token_default': 'Verwendet das mitgelieferte Standard-Token.',
+ 'settings_node.tmdb_save': 'Speichern',
+ 'settings_node.tmdb_language_label': 'Sprache',
+ 'settings_node.tmdb_language_hint': 'Gilt für alle — ein gemeinsamer Cache, keine Anfrage pro Betrachter.',
+ 'settings_node.video_root_title': 'Videos-Stammordner',
+ 'settings_node.video_root_hint': 'Welcher Ordner (oder Unterordner) als Einstiegspunkt der Videos-App für diese Gruppe dient. In Videos wird nichts angezeigt, und es werden keine TMDB-Abfragen ausgeführt, bis einer ausgewählt wurde.',
+ 'settings_node.video_root_none': '— keiner ausgewählt —',
+ 'settings_node.video_root_save': 'Speichern',
+ 'settings_node.video_root_change_confirm': 'Das Ändern des Videos-Stammordners ersetzt, was jedes Mitglied im Videos-Tab sieht. Fortfahren?',
// Create-group wizard
'wizard.title': 'Gruppe erstellen',
@@ -591,6 +626,7 @@ export default {
'wizard.setting_up': 'Ihre Gruppe wird eingerichtet…',
'wizard.step_create_hub': 'Gruppe auf dem Hub erstellen',
'wizard.step_attach': 'An Node anbinden',
+ 'wizard.step_apps': 'Anwendungen auswählen',
'wizard.step_add_roots': 'Verzeichnisse hinzufügen',
'wizard.step_gek': 'Verschlüsselungsschlüssel initialisieren',
'wizard.step_pair': 'Kopplung einrichten',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index a83b067..7f31bb5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -77,6 +77,7 @@ export default {
'group.default_name': 'Group',
'group.tab_files': 'Files',
'group.tab_chat': 'Chat',
+ 'group.tab_video': 'Videos',
'group.tab_members': 'Members',
'group.tab_settings': "Settings",
'members.danger_leave_hint': "You will lose access to this group's files and chat.",
@@ -154,6 +155,24 @@ export default {
'group.upload_indexing': 'indexing…',
'video.err_transport': 'Transport not connected',
'video.err_mse': 'Codec not supported for streaming: {codec}',
+ 'video.mode_poster': 'Posters',
+ 'video.mode_flat': 'Flat list',
+ 'video.empty': 'No videos found.',
+ 'video.no_root_configured': 'No Videos root folder is set for this group yet — an operator can choose one in Settings.',
+ 'video.director': 'Director',
+ 'video.specials': 'Specials',
+ 'video.season_n': 'Season {n}',
+ 'video.episode_n': 'Episode {n}',
+ 'video.n_episodes': {
+ one: '{n} episode',
+ other: '{n} episodes',
+ },
+ 'video.fix_match': 'Fix match…',
+ 'video.search_title': 'Correct the TMDB match',
+ 'video.search_placeholder': 'Search TMDB…',
+ 'video.search_button': 'Search',
+ 'video.search_no_results': 'No matches found.',
+ 'video.search_apply_hint': 'Applies to every file currently grouped under this title.',
// LAN cast
'cast.start': 'Cast to device',
@@ -361,6 +380,7 @@ export default {
'wizard.setting_up': 'Setting up your group...',
'wizard.step_create_hub': 'Creating group on hub',
'wizard.step_attach': 'Attaching to node',
+ 'wizard.step_apps': 'Choosing applications',
'wizard.step_add_roots': 'Adding directories',
'wizard.step_gek': 'Initializing encryption key',
'wizard.step_pair': 'Setting up pairing',
@@ -396,6 +416,22 @@ export default {
'settings_node.scan_save': 'Save',
'settings_node.scan_saving': 'Saving…',
'settings_node.scan_saved': 'Saved.',
+ 'settings_node.tmdb_title': 'TMDB metadata',
+ 'settings_node.tmdb_hint': 'Lets the Videos app show posters, overviews and cast from TMDB. Off means thumbnail-only browsing, with no request to a third party.',
+ 'settings_node.tmdb_enabled': 'Enabled',
+ 'settings_node.tmdb_disabled': 'Disabled',
+ 'settings_node.tmdb_token_label': 'Custom API token (optional)',
+ 'settings_node.tmdb_token_placeholder': 'Leave blank to use the shipped default',
+ 'settings_node.tmdb_token_customized': 'A custom token is set.',
+ 'settings_node.tmdb_token_default': 'Using the shipped default token.',
+ 'settings_node.tmdb_save': 'Save',
+ 'settings_node.tmdb_language_label': 'Language',
+ 'settings_node.tmdb_language_hint': 'Applies to everyone — one shared cache, not a per-viewer request.',
+ 'settings_node.video_root_title': 'Videos root folder',
+ 'settings_node.video_root_hint': 'Which folder (or subfolder) the Videos app treats as its entry point for this group. Nothing shows in Videos, and no TMDB lookups run, until one is chosen.',
+ 'settings_node.video_root_none': '— none chosen —',
+ 'settings_node.video_root_save': 'Save',
+ 'settings_node.video_root_change_confirm': 'Changing the Videos root replaces what every member sees in the Videos tab. Continue?',
// Members
'members.col_role': 'Role',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 1621182..d7f0f1e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -74,6 +74,7 @@ export default {
'group.default_name': 'Grupo',
'group.tab_files': 'Archivos',
'group.tab_chat': 'Chat',
+ 'group.tab_video': 'Vídeos',
'group.tab_members': 'Miembros',
'group.tab_settings': "Ajustes",
'members.danger_leave_hint': "Perderá el acceso a los archivos y al chat de este grupo.",
@@ -154,6 +155,24 @@ export default {
'group.upload_indexing': 'indexando…',
'video.err_transport': 'Transporte no conectado',
'video.err_mse': 'Códec no compatible con la reproducción en continuo: {codec}',
+ 'video.mode_poster': 'Pósteres',
+ 'video.mode_flat': 'Lista plana',
+ 'video.empty': 'No se encontraron vídeos.',
+ 'video.no_root_configured': 'Aún no se ha definido una carpeta raíz de Vídeos para este grupo — un operador puede elegir una en Configuración.',
+ 'video.director': 'Director',
+ 'video.specials': 'Especiales',
+ 'video.season_n': 'Temporada {n}',
+ 'video.episode_n': 'Episodio {n}',
+ 'video.n_episodes': {
+ one: '{n} episodio',
+ other: '{n} episodios',
+ },
+ 'video.fix_match': 'Corregir coincidencia…',
+ 'video.search_title': 'Corregir la coincidencia de TMDB',
+ 'video.search_placeholder': 'Buscar en TMDB…',
+ 'video.search_button': 'Buscar',
+ 'video.search_no_results': 'No se encontraron coincidencias.',
+ 'video.search_apply_hint': 'Se aplica a todos los archivos agrupados actualmente bajo este título.',
// LAN cast
'cast.start': 'Enviar a dispositivo',
@@ -567,6 +586,22 @@ export default {
'settings_node.scan_save': 'Guardar',
'settings_node.scan_saving': 'Guardando…',
'settings_node.scan_saved': 'Guardado.',
+ 'settings_node.tmdb_title': 'Metadatos de TMDB',
+ 'settings_node.tmdb_hint': 'Permite que la aplicación de Vídeos muestre pósteres, sinopsis y reparto desde TMDB. Desactivado significa solo miniaturas, sin ninguna solicitud a un tercero.',
+ 'settings_node.tmdb_enabled': 'Activado',
+ 'settings_node.tmdb_disabled': 'Desactivado',
+ 'settings_node.tmdb_token_label': 'Token de API personalizado (opcional)',
+ 'settings_node.tmdb_token_placeholder': 'Déjelo en blanco para usar el token predeterminado',
+ 'settings_node.tmdb_token_customized': 'Hay un token personalizado configurado.',
+ 'settings_node.tmdb_token_default': 'Usando el token predeterminado.',
+ 'settings_node.tmdb_save': 'Guardar',
+ 'settings_node.tmdb_language_label': 'Idioma',
+ 'settings_node.tmdb_language_hint': 'Se aplica a todos — una caché compartida, no una solicitud por espectador.',
+ 'settings_node.video_root_title': 'Carpeta raíz de Vídeos',
+ 'settings_node.video_root_hint': 'Qué carpeta (o subcarpeta) trata la app Vídeos como su punto de entrada para este grupo. No se muestra nada en Vídeos, ni se realizan búsquedas en TMDB, hasta que se elija una.',
+ 'settings_node.video_root_none': '— ninguna elegida —',
+ 'settings_node.video_root_save': 'Guardar',
+ 'settings_node.video_root_change_confirm': 'Cambiar la raíz de Vídeos reemplaza lo que ve cada miembro en la pestaña Vídeos. ¿Continuar?',
// Create group wizard
'wizard.title': 'Crear grupo',
@@ -586,6 +621,7 @@ export default {
'wizard.setting_up': 'Configurando su grupo…',
'wizard.step_create_hub': 'Creando grupo en el hub',
'wizard.step_attach': 'Conectando al node',
+ 'wizard.step_apps': 'Eligiendo aplicaciones',
'wizard.step_add_roots': 'Añadiendo directorios',
'wizard.step_gek': 'Inicializando clave de cifrado',
'wizard.step_pair': 'Configurando emparejamiento',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 6e21a06..2befdb9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -75,6 +75,7 @@ export default {
'group.default_name': 'Groupe',
'group.tab_files': 'Fichiers',
'group.tab_chat': 'Discussion',
+ 'group.tab_video': 'Vidéos',
'group.tab_members': 'Membres',
'group.tab_settings': "Paramètres",
'members.danger_leave_hint': "Vous perdrez l’accès aux fichiers et à la discussion de ce groupe.",
@@ -155,6 +156,24 @@ export default {
'group.upload_indexing': 'indexation…',
'video.err_transport': 'Transport non connecté',
'video.err_mse': 'Codec non pris en charge pour la diffusion : {codec}',
+ 'video.mode_poster': 'Affiches',
+ 'video.mode_flat': 'Liste à plat',
+ 'video.empty': 'Aucune vidéo trouvée.',
+ 'video.no_root_configured': "Aucun dossier racine des Vidéos n'est encore défini pour ce groupe — un opérateur peut en choisir un dans les Paramètres.",
+ 'video.director': 'Réalisateur',
+ 'video.specials': 'Bonus',
+ 'video.season_n': 'Saison {n}',
+ 'video.episode_n': 'Épisode {n}',
+ 'video.n_episodes': {
+ one: '{n} épisode',
+ other: '{n} épisodes',
+ },
+ 'video.fix_match': 'Corriger la correspondance…',
+ 'video.search_title': 'Corriger la correspondance TMDB',
+ 'video.search_placeholder': 'Rechercher sur TMDB…',
+ 'video.search_button': 'Rechercher',
+ 'video.search_no_results': 'Aucune correspondance trouvée.',
+ 'video.search_apply_hint': 'S’applique à tous les fichiers actuellement regroupés sous ce titre.',
// LAN cast
'cast.start': 'Diffuser sur un appareil',
@@ -583,6 +602,22 @@ export default {
'settings_node.scan_save': 'Enregistrer',
'settings_node.scan_saving': 'Enregistrement…',
'settings_node.scan_saved': 'Enregistré.',
+ 'settings_node.tmdb_title': 'Métadonnées TMDB',
+ 'settings_node.tmdb_hint': "Permet à l'application Vidéos d'afficher affiches, résumés et distribution depuis TMDB. Désactivé signifie navigation par vignettes uniquement, sans aucune requête vers un tiers.",
+ 'settings_node.tmdb_enabled': 'Activé',
+ 'settings_node.tmdb_disabled': 'Désactivé',
+ 'settings_node.tmdb_token_label': "Jeton d'API personnalisé (facultatif)",
+ 'settings_node.tmdb_token_placeholder': 'Laisser vide pour utiliser le jeton fourni par défaut',
+ 'settings_node.tmdb_token_customized': 'Un jeton personnalisé est défini.',
+ 'settings_node.tmdb_token_default': 'Utilise le jeton fourni par défaut.',
+ 'settings_node.tmdb_save': 'Enregistrer',
+ 'settings_node.tmdb_language_label': 'Langue',
+ 'settings_node.tmdb_language_hint': "S'applique à tout le monde — un cache partagé, pas une requête par personne.",
+ 'settings_node.video_root_title': 'Dossier racine des Vidéos',
+ 'settings_node.video_root_hint': "Quel dossier (ou sous-dossier) sert de point d'entrée à l'application Vidéos pour ce groupe. Rien ne s'affiche dans Vidéos, et aucune recherche TMDB n'est effectuée, tant qu'aucun n'est choisi.",
+ 'settings_node.video_root_none': '— aucun choisi —',
+ 'settings_node.video_root_save': 'Enregistrer',
+ 'settings_node.video_root_change_confirm': "Changer la racine des Vidéos remplace ce que chaque membre voit dans l'onglet Vidéos. Continuer ?",
// Create group wizard
'wizard.title': 'Créer un groupe',
@@ -602,6 +637,7 @@ export default {
'wizard.setting_up': 'Configuration de votre groupe…',
'wizard.step_create_hub': 'Création du groupe sur le hub',
'wizard.step_attach': 'Rattachement au node',
+ 'wizard.step_apps': 'Choix des applications',
'wizard.step_add_roots': 'Ajout des répertoires',
'wizard.step_gek': 'Initialisation de la clé de chiffrement',
'wizard.step_pair': 'Mise en place de l\'appariement',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index edd15ab..bdfda75 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -75,6 +75,7 @@ export default {
'group.default_name': 'Gruppo',
'group.tab_files': 'File',
'group.tab_chat': 'Chat',
+ 'group.tab_video': 'Video',
'group.tab_members': 'Membri',
'group.tab_settings': "Impostazioni",
'members.danger_leave_hint': "Perderai l’accesso ai file e alla chat di questo gruppo.",
@@ -155,6 +156,24 @@ export default {
'group.upload_indexing': 'indicizzazione…',
'video.err_transport': 'Trasporto non connesso',
'video.err_mse': 'Codec non supportato per lo streaming: {codec}',
+ 'video.mode_poster': 'Locandine',
+ 'video.mode_flat': 'Elenco semplice',
+ 'video.empty': 'Nessun video trovato.',
+ 'video.no_root_configured': 'Per questo gruppo non è ancora impostata una cartella radice di Video — un operatore può sceglierne una nelle Impostazioni.',
+ 'video.director': 'Regista',
+ 'video.specials': 'Speciali',
+ 'video.season_n': 'Stagione {n}',
+ 'video.episode_n': 'Episodio {n}',
+ 'video.n_episodes': {
+ one: '{n} episodio',
+ other: '{n} episodi',
+ },
+ 'video.fix_match': 'Correggi corrispondenza…',
+ 'video.search_title': 'Correggi la corrispondenza TMDB',
+ 'video.search_placeholder': 'Cerca su TMDB…',
+ 'video.search_button': 'Cerca',
+ 'video.search_no_results': 'Nessuna corrispondenza trovata.',
+ 'video.search_apply_hint': 'Si applica a tutti i file attualmente raggruppati sotto questo titolo.',
// LAN cast
'cast.start': 'Trasmetti al dispositivo',
@@ -581,6 +600,22 @@ export default {
'settings_node.scan_save': 'Salva',
'settings_node.scan_saving': 'Salvataggio…',
'settings_node.scan_saved': 'Salvato.',
+ 'settings_node.tmdb_title': 'Metadati TMDB',
+ 'settings_node.tmdb_hint': "Permette all'app Video di mostrare locandine, trame e cast da TMDB. Disattivato significa solo miniature, senza alcuna richiesta verso terzi.",
+ 'settings_node.tmdb_enabled': 'Attivato',
+ 'settings_node.tmdb_disabled': 'Disattivato',
+ 'settings_node.tmdb_token_label': 'Token API personalizzato (facoltativo)',
+ 'settings_node.tmdb_token_placeholder': 'Lascia vuoto per usare il token predefinito',
+ 'settings_node.tmdb_token_customized': 'È impostato un token personalizzato.',
+ 'settings_node.tmdb_token_default': 'In uso il token predefinito.',
+ 'settings_node.tmdb_save': 'Salva',
+ 'settings_node.tmdb_language_label': 'Lingua',
+ 'settings_node.tmdb_language_hint': 'Vale per tutti — una cache condivisa, non una richiesta per spettatore.',
+ 'settings_node.video_root_title': 'Cartella radice di Video',
+ 'settings_node.video_root_hint': "Quale cartella (o sottocartella) l'app Video considera come punto di ingresso per questo gruppo. In Video non viene mostrato nulla, e non viene eseguita alcuna ricerca TMDB, finché non ne viene scelta una.",
+ 'settings_node.video_root_none': '— nessuna scelta —',
+ 'settings_node.video_root_save': 'Salva',
+ 'settings_node.video_root_change_confirm': 'Cambiare la radice di Video sostituisce ciò che ogni membro vede nella scheda Video. Continuare?',
// Create-group wizard
'wizard.title': 'Crea gruppo',
@@ -600,6 +635,7 @@ export default {
'wizard.setting_up': 'Configurazione del gruppo…',
'wizard.step_create_hub': 'Creazione del gruppo sul hub',
'wizard.step_attach': 'Collegamento al node',
+ 'wizard.step_apps': 'Scelta delle applicazioni',
'wizard.step_add_roots': 'Aggiunta delle directory',
'wizard.step_gek': 'Inizializzazione della chiave di cifratura',
'wizard.step_pair': 'Configurazione del pairing',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 00fffed..f00a0ec 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -73,6 +73,7 @@ export default {
'group.default_name': 'グループ',
'group.tab_files': 'ファイル',
'group.tab_chat': 'チャット',
+ 'group.tab_video': '動画',
'group.tab_members': 'メンバー',
'group.tab_settings': "設定",
'members.danger_leave_hint': "このグループのファイルとチャットにアクセスできなくなります。",
@@ -152,6 +153,24 @@ export default {
'group.upload_indexing': 'インデックスを作成中…',
'video.err_transport': 'トランスポートが接続されていません',
'video.err_mse': 'ストリーミング再生に対応していないコーデックです:{codec}',
+ 'video.mode_poster': 'ポスター表示',
+ 'video.mode_flat': 'フラット表示',
+ 'video.empty': '動画が見つかりません。',
+ 'video.no_root_configured': 'このグループにはまだ動画のルートフォルダが設定されていません — 操作者が設定画面で選択できます。',
+ 'video.director': '監督',
+ 'video.specials': '特別編',
+ 'video.season_n': 'シーズン{n}',
+ 'video.episode_n': '第{n}話',
+ 'video.n_episodes': {
+ one: '{n}話',
+ other: '{n}話',
+ },
+ 'video.fix_match': '一致を修正…',
+ 'video.search_title': 'TMDBの一致を修正',
+ 'video.search_placeholder': 'TMDBを検索…',
+ 'video.search_button': '検索',
+ 'video.search_no_results': '一致する結果が見つかりません。',
+ 'video.search_apply_hint': '現在このタイトルでグループ化されているすべてのファイルに適用されます。',
// LAN cast
'cast.start': 'デバイスにキャスト',
@@ -565,6 +584,22 @@ export default {
'settings_node.scan_save': '保存',
'settings_node.scan_saving': '保存中…',
'settings_node.scan_saved': '保存しました。',
+ 'settings_node.tmdb_title': 'TMDBメタデータ',
+ 'settings_node.tmdb_hint': '動画アプリでTMDBのポスター、あらすじ、キャストを表示できるようにします。無効にするとサムネイルのみの表示になり、第三者への通信は発生しません。',
+ 'settings_node.tmdb_enabled': '有効',
+ 'settings_node.tmdb_disabled': '無効',
+ 'settings_node.tmdb_token_label': 'カスタムAPIトークン(任意)',
+ 'settings_node.tmdb_token_placeholder': '空欄にすると同梱のデフォルトトークンを使用します',
+ 'settings_node.tmdb_token_customized': 'カスタムトークンが設定されています。',
+ 'settings_node.tmdb_token_default': '同梱のデフォルトトークンを使用しています。',
+ 'settings_node.tmdb_save': '保存',
+ 'settings_node.tmdb_language_label': '言語',
+ 'settings_node.tmdb_language_hint': '全員に適用されます — 共有キャッシュであり、視聴者ごとのリクエストではありません。',
+ 'settings_node.video_root_title': '動画のルートフォルダ',
+ 'settings_node.video_root_hint': 'このグループで動画アプリの起点とするフォルダ(またはサブフォルダ)です。選択されるまで、動画には何も表示されず、TMDB への問い合わせも行われません。',
+ 'settings_node.video_root_none': '— 未選択 —',
+ 'settings_node.video_root_save': '保存',
+ 'settings_node.video_root_change_confirm': '動画のルートフォルダを変更すると、全メンバーの動画タブの表示内容が変わります。続行しますか?',
// Wizard
'wizard.title': 'グループを作成',
@@ -584,6 +619,7 @@ export default {
'wizard.setting_up': 'グループをセットアップ中…',
'wizard.step_create_hub': 'hub 上にグループを作成中',
'wizard.step_attach': 'node に接続中',
+ 'wizard.step_apps': 'アプリを選択中',
'wizard.step_add_roots': 'ディレクトリを追加中',
'wizard.step_gek': '暗号化鍵を初期化中',
'wizard.step_pair': 'ペアリングをセットアップ中',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 2837ebb..d9ed12e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -76,6 +76,7 @@ export default {
'group.default_name': 'Groep',
'group.tab_files': 'Bestanden',
'group.tab_chat': 'Chat',
+ 'group.tab_video': "Video's",
'group.tab_members': 'Leden',
'group.tab_settings': "Instellingen",
'members.danger_leave_hint': "U verliest de toegang tot de bestanden en de chat van deze groep.",
@@ -156,6 +157,24 @@ export default {
'group.upload_indexing': 'indexeren…',
'video.err_transport': 'Transport niet verbonden',
'video.err_mse': 'Codec wordt niet ondersteund voor streamen: {codec}',
+ 'video.mode_poster': 'Posters',
+ 'video.mode_flat': 'Platte lijst',
+ 'video.empty': "Geen video's gevonden.",
+ 'video.no_root_configured': "Er is nog geen hoofdmap voor Video's ingesteld voor deze groep — een operator kan er een kiezen bij Instellingen.",
+ 'video.director': 'Regisseur',
+ 'video.specials': "Extra's",
+ 'video.season_n': 'Seizoen {n}',
+ 'video.episode_n': 'Aflevering {n}',
+ 'video.n_episodes': {
+ one: '{n} aflevering',
+ other: '{n} afleveringen',
+ },
+ 'video.fix_match': 'Overeenkomst corrigeren…',
+ 'video.search_title': 'TMDB-overeenkomst corrigeren',
+ 'video.search_placeholder': 'Zoeken in TMDB…',
+ 'video.search_button': 'Zoeken',
+ 'video.search_no_results': 'Geen overeenkomsten gevonden.',
+ 'video.search_apply_hint': 'Geldt voor alle bestanden die momenteel onder deze titel zijn gegroepeerd.',
// LAN cast
'cast.start': 'Naar apparaat casten',
@@ -583,6 +602,22 @@ export default {
'settings_node.scan_save': 'Opslaan',
'settings_node.scan_saving': 'Bezig met opslaan…',
'settings_node.scan_saved': 'Opgeslagen.',
+ 'settings_node.tmdb_title': 'TMDB-metadata',
+ 'settings_node.tmdb_hint': "Laat de Video's-app posters, samenvattingen en cast van TMDB tonen. Uit betekent alleen miniaturen, zonder enig verzoek aan een derde partij.",
+ 'settings_node.tmdb_enabled': 'Ingeschakeld',
+ 'settings_node.tmdb_disabled': 'Uitgeschakeld',
+ 'settings_node.tmdb_token_label': 'Eigen API-token (optioneel)',
+ 'settings_node.tmdb_token_placeholder': 'Leeg laten om de meegeleverde standaard te gebruiken',
+ 'settings_node.tmdb_token_customized': 'Er is een eigen token ingesteld.',
+ 'settings_node.tmdb_token_default': 'Gebruikt de meegeleverde standaardtoken.',
+ 'settings_node.tmdb_save': 'Opslaan',
+ 'settings_node.tmdb_language_label': 'Taal',
+ 'settings_node.tmdb_language_hint': 'Geldt voor iedereen — één gedeelde cache, geen verzoek per kijker.',
+ 'settings_node.video_root_title': "Hoofdmap voor Video's",
+ 'settings_node.video_root_hint': "Welke map (of submap) de Video's-app als startpunt gebruikt voor deze groep. Er wordt niets getoond in Video's, en er worden geen TMDB-opzoekingen uitgevoerd, totdat er een gekozen is.",
+ 'settings_node.video_root_none': '— geen gekozen —',
+ 'settings_node.video_root_save': 'Opslaan',
+ 'settings_node.video_root_change_confirm': "Het wijzigen van de hoofdmap voor Video's vervangt wat elk lid ziet in het tabblad Video's. Doorgaan?",
// Create group wizard
'wizard.title': 'Groep aanmaken',
@@ -602,6 +637,7 @@ export default {
'wizard.setting_up': 'Uw groep wordt ingesteld…',
'wizard.step_create_hub': 'Groep aanmaken op hub',
'wizard.step_attach': 'Koppelen aan node',
+ 'wizard.step_apps': 'Apps kiezen',
'wizard.step_add_roots': 'Mappen toevoegen',
'wizard.step_gek': 'Versleutelingssleutel initialiseren',
'wizard.step_pair': 'Koppeling instellen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 760f29c..cbd27b1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -80,6 +80,7 @@ export default {
'group.default_name': 'Grupa',
'group.tab_files': 'Pliki',
'group.tab_chat': 'Czat',
+ 'group.tab_video': 'Wideo',
'group.tab_members': 'Członkowie',
'group.tab_settings': "Ustawienia",
'members.danger_leave_hint': "Utracisz dostęp do plików i czatu tej grupy.",
@@ -161,6 +162,26 @@ export default {
'group.upload_indexing': 'indeksowanie…',
'video.err_transport': 'Transport nie jest połączony',
'video.err_mse': 'Kodek nieobsługiwany przy odtwarzaniu strumieniowym: {codec}',
+ 'video.mode_poster': 'Plakaty',
+ 'video.mode_flat': 'Lista płaska',
+ 'video.empty': 'Nie znaleziono żadnych filmów.',
+ 'video.no_root_configured': 'Dla tej grupy nie wybrano jeszcze katalogu głównego Wideo — operator może go wybrać w Ustawieniach.',
+ 'video.director': 'Reżyser',
+ 'video.specials': 'Dodatki',
+ 'video.season_n': 'Sezon {n}',
+ 'video.episode_n': 'Odcinek {n}',
+ 'video.n_episodes': {
+ one: '{n} odcinek',
+ few: '{n} odcinki',
+ many: '{n} odcinków',
+ other: '{n} odcinka',
+ },
+ 'video.fix_match': 'Popraw dopasowanie…',
+ 'video.search_title': 'Popraw dopasowanie TMDB',
+ 'video.search_placeholder': 'Szukaj w TMDB…',
+ 'video.search_button': 'Szukaj',
+ 'video.search_no_results': 'Nie znaleziono dopasowań.',
+ 'video.search_apply_hint': 'Dotyczy wszystkich plików obecnie zgrupowanych pod tym tytułem.',
// LAN cast
'cast.start': 'Przesyłaj na urządzenie',
@@ -604,6 +625,22 @@ export default {
'settings_node.scan_save': 'Zapisz',
'settings_node.scan_saving': 'Zapisywanie…',
'settings_node.scan_saved': 'Zapisano.',
+ 'settings_node.tmdb_title': 'Metadane TMDB',
+ 'settings_node.tmdb_hint': 'Pozwala aplikacji Wideo pokazywać plakaty, opisy i obsadę z TMDB. Wyłączone oznacza przeglądanie tylko z miniaturami, bez żadnego żądania do zewnętrznego serwisu.',
+ 'settings_node.tmdb_enabled': 'Włączone',
+ 'settings_node.tmdb_disabled': 'Wyłączone',
+ 'settings_node.tmdb_token_label': 'Własny token API (opcjonalnie)',
+ 'settings_node.tmdb_token_placeholder': 'Pozostaw puste, aby użyć domyślnego tokenu',
+ 'settings_node.tmdb_token_customized': 'Ustawiono własny token.',
+ 'settings_node.tmdb_token_default': 'Używany jest domyślny token.',
+ 'settings_node.tmdb_save': 'Zapisz',
+ 'settings_node.tmdb_language_label': 'Język',
+ 'settings_node.tmdb_language_hint': 'Dotyczy wszystkich — jedna współdzielona pamięć podręczna, bez osobnego żądania dla każdego widza.',
+ 'settings_node.video_root_title': 'Katalog główny Wideo',
+ 'settings_node.video_root_hint': 'Który katalog (lub podkatalog) aplikacja Wideo traktuje jako punkt wejścia dla tej grupy. W Wideo nic się nie wyświetla i nie są wykonywane żadne zapytania do TMDB, dopóki nie zostanie wybrany.',
+ 'settings_node.video_root_none': '— nie wybrano —',
+ 'settings_node.video_root_save': 'Zapisz',
+ 'settings_node.video_root_change_confirm': 'Zmiana katalogu głównego Wideo zastępuje to, co widzi każdy członek w karcie Wideo. Kontynuować?',
// Create-group wizard
'wizard.title': 'Utwórz grupę',
@@ -623,6 +660,7 @@ export default {
'wizard.setting_up': 'Konfigurowanie grupy…',
'wizard.step_create_hub': 'Tworzenie grupy na hub',
'wizard.step_attach': 'Podłączanie do node',
+ 'wizard.step_apps': 'Wybieranie aplikacji',
'wizard.step_add_roots': 'Dodawanie katalogów',
'wizard.step_gek': 'Inicjalizacja klucza szyfrowania',
'wizard.step_pair': 'Konfigurowanie parowania',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 0001051..e7bf45c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -76,6 +76,7 @@ export default {
'group.default_name': 'Grupo',
'group.tab_files': 'Arquivos',
'group.tab_chat': 'Conversa',
+ 'group.tab_video': 'Vídeos',
'group.tab_members': 'Membros',
'group.tab_settings': "Configurações",
'members.danger_leave_hint': "Você perderá o acesso aos arquivos e ao chat deste grupo.",
@@ -156,6 +157,24 @@ export default {
'group.upload_indexing': 'indexando…',
'video.err_transport': 'Transporte não conectado',
'video.err_mse': 'Codec sem suporte para transmissão: {codec}',
+ 'video.mode_poster': 'Pôsteres',
+ 'video.mode_flat': 'Lista simples',
+ 'video.empty': 'Nenhum vídeo encontrado.',
+ 'video.no_root_configured': 'Ainda não há uma pasta raiz de Vídeos definida para este grupo — um operador pode escolher uma em Configurações.',
+ 'video.director': 'Diretor',
+ 'video.specials': 'Especiais',
+ 'video.season_n': 'Temporada {n}',
+ 'video.episode_n': 'Episódio {n}',
+ 'video.n_episodes': {
+ one: '{n} episódio',
+ other: '{n} episódios',
+ },
+ 'video.fix_match': 'Corrigir correspondência…',
+ 'video.search_title': 'Corrigir a correspondência do TMDB',
+ 'video.search_placeholder': 'Pesquisar no TMDB…',
+ 'video.search_button': 'Pesquisar',
+ 'video.search_no_results': 'Nenhuma correspondência encontrada.',
+ 'video.search_apply_hint': 'Aplica-se a todos os arquivos atualmente agrupados sob este título.',
// LAN cast
'cast.start': 'Transmitir para dispositivo',
@@ -568,6 +587,22 @@ export default {
'settings_node.scan_save': 'Salvar',
'settings_node.scan_saving': 'Salvando…',
'settings_node.scan_saved': 'Salvo.',
+ 'settings_node.tmdb_title': 'Metadados do TMDB',
+ 'settings_node.tmdb_hint': 'Permite que o app de Vídeos mostre pôsteres, sinopses e elenco do TMDB. Desativado significa navegação apenas com miniaturas, sem nenhuma solicitação a terceiros.',
+ 'settings_node.tmdb_enabled': 'Ativado',
+ 'settings_node.tmdb_disabled': 'Desativado',
+ 'settings_node.tmdb_token_label': 'Token de API personalizado (opcional)',
+ 'settings_node.tmdb_token_placeholder': 'Deixe em branco para usar o token padrão',
+ 'settings_node.tmdb_token_customized': 'Um token personalizado está definido.',
+ 'settings_node.tmdb_token_default': 'Usando o token padrão.',
+ 'settings_node.tmdb_save': 'Salvar',
+ 'settings_node.tmdb_language_label': 'Idioma',
+ 'settings_node.tmdb_language_hint': 'Aplica-se a todos — um cache compartilhado, não uma solicitação por espectador.',
+ 'settings_node.video_root_title': 'Pasta raiz de Vídeos',
+ 'settings_node.video_root_hint': 'Qual pasta (ou subpasta) o app Vídeos trata como ponto de entrada para este grupo. Nada é exibido em Vídeos, e nenhuma busca no TMDB é feita, até que uma seja escolhida.',
+ 'settings_node.video_root_none': '— nenhuma escolhida —',
+ 'settings_node.video_root_save': 'Salvar',
+ 'settings_node.video_root_change_confirm': 'Alterar a raiz de Vídeos substitui o que cada membro vê na aba Vídeos. Continuar?',
// Create group wizard
'wizard.title': 'Criar grupo',
@@ -587,6 +622,7 @@ export default {
'wizard.setting_up': 'Configurando seu grupo…',
'wizard.step_create_hub': 'Criando grupo no hub',
'wizard.step_attach': 'Anexando ao node',
+ 'wizard.step_apps': 'Escolhendo aplicativos',
'wizard.step_add_roots': 'Adicionando diretórios',
'wizard.step_gek': 'Inicializando chave de criptografia',
'wizard.step_pair': 'Configurando pareamento',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index ad8bf53..33a0e25 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -73,6 +73,7 @@ export default {
'group.default_name': '群组',
'group.tab_files': '文件',
'group.tab_chat': '聊天',
+ 'group.tab_video': '视频',
'group.tab_members': '成员',
'group.tab_settings': "设置",
'members.danger_leave_hint': "您将无法再访问该群组的文件和聊天。",
@@ -149,6 +150,24 @@ export default {
'group.upload_indexing': '建立索引中…',
'video.err_transport': '传输未连接',
'video.err_mse': '该编解码器不支持流式播放:{codec}',
+ 'video.mode_poster': '海报视图',
+ 'video.mode_flat': '平铺列表',
+ 'video.empty': '未找到视频。',
+ 'video.no_root_configured': '此群组尚未设置视频根目录 — 操作员可以在设置中选择一个。',
+ 'video.director': '导演',
+ 'video.specials': '特别篇',
+ 'video.season_n': '第 {n} 季',
+ 'video.episode_n': '第 {n} 集',
+ 'video.n_episodes': {
+ one: '{n} 集',
+ other: '{n} 集',
+ },
+ 'video.fix_match': '修正匹配…',
+ 'video.search_title': '更正 TMDB 匹配',
+ 'video.search_placeholder': '搜索 TMDB…',
+ 'video.search_button': '搜索',
+ 'video.search_no_results': '未找到匹配项。',
+ 'video.search_apply_hint': '将应用于当前归类在此标题下的所有文件。',
// LAN cast
'cast.start': '投射到设备',
@@ -551,6 +570,22 @@ export default {
'settings_node.scan_save': '保存',
'settings_node.scan_saving': '保存中…',
'settings_node.scan_saved': '已保存。',
+ 'settings_node.tmdb_title': 'TMDB 元数据',
+ 'settings_node.tmdb_hint': '允许视频应用显示来自 TMDB 的海报、简介和演职人员。关闭后仅显示缩略图浏览,不会向第三方发送任何请求。',
+ 'settings_node.tmdb_enabled': '已启用',
+ 'settings_node.tmdb_disabled': '已禁用',
+ 'settings_node.tmdb_token_label': '自定义 API 令牌(可选)',
+ 'settings_node.tmdb_token_placeholder': '留空以使用内置的默认令牌',
+ 'settings_node.tmdb_token_customized': '已设置自定义令牌。',
+ 'settings_node.tmdb_token_default': '正在使用内置的默认令牌。',
+ 'settings_node.tmdb_save': '保存',
+ 'settings_node.tmdb_language_label': '语言',
+ 'settings_node.tmdb_language_hint': '适用于所有人 — 共享同一个缓存,不是按观看者分别请求。',
+ 'settings_node.video_root_title': '视频根目录',
+ 'settings_node.video_root_hint': '该文件夹(或子文件夹)将作为此群组"视频"应用的入口。在选择之前,"视频"中不会显示任何内容,也不会执行任何 TMDB 查询。',
+ 'settings_node.video_root_none': '— 未选择 —',
+ 'settings_node.video_root_save': '保存',
+ 'settings_node.video_root_change_confirm': '更改视频根目录会替换每位成员在"视频"标签页中看到的内容。是否继续?',
// Create group wizard
'wizard.title': '创建群组',
@@ -571,6 +606,7 @@ export default {
'wizard.setting_up': '正在设置您的群组…',
'wizard.step_create_hub': '在 hub 上创建群组',
'wizard.step_attach': '关联到 node',
+ 'wizard.step_apps': '选择应用',
'wizard.step_add_roots': '添加目录',
'wizard.step_gek': '初始化加密密钥',
'wizard.step_pair': '设置配对',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 2cb27d0..446a751 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1000,6 +1000,20 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
color: var(--text);
font-size: 0.95em;
}
+.settings-label input[type="password"],
+.settings-label input[type="text"],
+.settings-label select {
+ display: block;
+ width: 100%;
+ max-width: 320px;
+ margin-top: 4px;
+ padding: 6px 8px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--bg-base);
+ color: var(--text);
+ font-size: 0.95em;
+}
.settings-value {
font-size: 0.9em;
@@ -2399,3 +2413,275 @@ a.transfer-name {
border-radius: 8px;
font-size: 0.85em;
}
+
+/* ── Videos app (video-app.js) ──────────────────────────────────────────────── */
+
+.video-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 14px;
+}
+.video-toolbar .tb-search { margin-left: auto; }
+
+/* Mode A — poster grid */
+
+.video-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ gap: 16px;
+}
+
+.video-tile-slot {
+ min-height: 260px;
+}
+
+.video-card {
+ cursor: pointer;
+ border-radius: 8px;
+ overflow: hidden;
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ transition: border-color 0.12s, transform 0.12s;
+}
+.video-card:hover { border-color: var(--accent); transform: translateY(-2px); }
+
+.video-poster {
+ width: 100%;
+ aspect-ratio: 2 / 3;
+ object-fit: cover;
+ display: block;
+ background: var(--bg-surface);
+}
+
+.video-poster-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.video-poster-slot { display: contents; }
+
+.video-thumb {
+ width: 100%;
+ aspect-ratio: 2 / 3;
+ object-fit: cover;
+ display: block;
+ background: var(--bg-surface);
+}
+.video-thumb-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-dim);
+}
+.video-thumb-empty .icon { width: 28px; height: 28px; }
+
+.video-card-info { padding: 8px 10px; }
+.video-card-title {
+ font-size: 0.88em;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.video-card-sub {
+ font-size: 0.78em;
+ color: var(--text-dim);
+ margin-top: 2px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Detail modal — sits inside the existing .video-overlay */
+
+.video-detail {
+ width: min(720px, 92vw);
+ max-height: calc(100vh - 100px);
+ margin-top: 60px;
+ background: var(--bg-surface);
+ border-radius: 10px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+.video-detail .video-top-bar {
+ position: static;
+ background: var(--bg-raised);
+ border-bottom: 1px solid var(--border);
+}
+.video-detail .video-title { color: var(--text); }
+.video-detail .video-close { background: var(--bg-surface); color: var(--text); }
+.video-detail .video-close:hover { background: var(--border); }
+
+.video-detail-body {
+ padding: 16px 20px;
+ overflow-y: auto;
+}
+.video-detail-overview {
+ font-size: 0.9em;
+ color: var(--text);
+ line-height: 1.5;
+}
+.video-detail-facts {
+ font-size: 0.82em;
+ color: var(--text-dim);
+ margin-top: 6px;
+}
+.video-detail-cast {
+ font-size: 0.82em;
+ color: var(--text-secondary);
+ margin-top: 6px;
+}
+
+.video-season-list { margin-top: 14px; }
+.video-season { margin-bottom: 14px; }
+.video-season-header {
+ font-weight: 600;
+ font-size: 0.85em;
+ color: var(--text-dim);
+ margin-bottom: 6px;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+.video-episode-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 8px 10px;
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+ font-size: 0.85em;
+ cursor: pointer;
+ text-align: left;
+ margin-bottom: 4px;
+}
+.video-episode-row:hover { border-color: var(--accent); color: var(--accent); }
+.video-episode-row .icon { width: 14px; height: 14px; flex-shrink: 0; }
+.video-episode-thumb-slot { width: 64px; height: 40px; flex-shrink: 0; }
+.video-episode-thumb {
+ width: 64px;
+ height: 40px;
+ object-fit: cover;
+ border-radius: 4px;
+ display: block;
+ background: var(--bg-raised);
+}
+.video-episode-thumb.video-thumb-empty { border: 1px solid var(--border); }
+.video-episode-label {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.video-episode-meta {
+ color: var(--text-dim);
+ font-size: 0.9em;
+ flex-shrink: 0;
+}
+
+/* Mode B — flat list */
+
+.video-flat-list { display: flex; flex-direction: column; gap: 4px; }
+
+.video-flat-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 6px 8px;
+ border-radius: 6px;
+ cursor: pointer;
+}
+.video-flat-row:hover { background: var(--bg-raised); }
+
+.video-flat-thumb-slot { width: 64px; height: 40px; flex-shrink: 0; }
+.video-flat-thumb {
+ width: 64px;
+ height: 40px;
+ object-fit: cover;
+ border-radius: 4px;
+ display: block;
+ background: var(--bg-raised);
+}
+.video-flat-thumb.video-thumb-empty { border: 1px solid var(--border); }
+
+.video-flat-info { min-width: 0; flex: 1; }
+.video-flat-title {
+ font-size: 0.88em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.video-flat-sub { font-size: 0.78em; color: var(--text-dim); margin-top: 1px; }
+
+.video-flat-folder { border-bottom: 1px solid var(--border); padding-bottom: 4px; margin-bottom: 4px; }
+.video-flat-chevron { transition: transform 0.12s; flex-shrink: 0; }
+.video-flat-chevron.open { transform: rotate(180deg); }
+.video-flat-season { padding-left: 24px; margin-bottom: 8px; }
+.video-flat-season .video-season-header { margin: 6px 0 4px; }
+
+/* Season tab bar — docs/mediacenter.md §5.4's per-season overview view */
+.video-season-tabs {
+ display: flex;
+ gap: 4px;
+ margin: 10px 0;
+ overflow-x: auto;
+}
+.video-season-tab {
+ padding: 5px 12px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--bg-surface);
+ color: var(--text-dim);
+ font-size: 0.85em;
+ white-space: nowrap;
+ cursor: pointer;
+}
+.video-season-tab:hover { border-color: var(--accent); color: var(--text); }
+.video-season-tab.active { border-color: var(--accent); color: var(--accent); background: var(--bg-raised); }
+
+.video-fix-match { margin: 8px 0; font-size: 0.85em; }
+
+/* Operator search-and-correct overlay, layered on top of the detail modal.
+ Must beat .video-overlay's own z-index: 200 — both are position: fixed,
+ so without an explicit higher value here this one loses the stacking
+ order despite being the later sibling in the DOM. */
+.video-search-overlay { z-index: 210; }
+.video-search-panel { max-width: 480px; }
+.video-search-form { display: flex; gap: 8px; }
+.video-search-form input {
+ flex: 1;
+ padding: 7px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-surface);
+ color: var(--text);
+}
+.video-search-hint { font-size: 0.78em; color: var(--text-dim); margin: 6px 0 2px; }
+.video-search-error { font-size: 0.85em; color: var(--error); }
+.video-search-results { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; }
+.video-search-result {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ background: none;
+ text-align: left;
+ cursor: pointer;
+}
+.video-search-result:hover:not(:disabled) { border-color: var(--accent); background: var(--bg-raised); }
+.video-search-result:disabled { opacity: 0.5; cursor: not-allowed; }
+.video-search-result-thumb { width: 46px; height: 68px; flex-shrink: 0; }
+.video-search-result-poster {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 4px;
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 4f6b656..b4bfe87 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -106,6 +106,8 @@ class MeshBayTransport {
set onIndexDelta(fn) { this._onIndexDelta = fn; }
set onUploadPolicy(fn) { this._onUploadPolicy = fn; }
set onAppsEnabled(fn) { this._onAppsEnabled = fn; }
+ set onTmdbConfig(fn) { this._onTmdbConfig = fn; }
+ set onVideoRoot(fn) { this._onVideoRoot = fn; }
set onIndexProgress(fn) { this._onIndexProgress = fn; }
get sessionKeys() { return this._sessionKeys; }
@@ -465,6 +467,121 @@ class MeshBayTransport {
return msg;
}
+ /**
+ * TMDB metadata for one file (Videos app, docs/mediacenter.md §5.4).
+ * `path` is root+relpath, exactly what index_sync/index_delta already
+ * gave this browser — never a raw filesystem path constructed here.
+ * `confidence: 0` (no tmdb_id, no fields) means no confident match —
+ * the caller falls back to a thumbnail-only card (§4.1), not an error.
+ */
+ async fetchMediaMeta(path) {
+ const msg = await this._sendAndWait({ type: 'media_meta_req', v: '0.5', path });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * One season's own overview/air_date/poster (docs/mediacenter.md §5.4's
+ * per-season view) — a show's own tmdb_meta is one static field that does
+ * not necessarily describe every season alike, found live: a 3-season
+ * show whose overview read as season-3-specific for every season.
+ * Keyed like media_meta_req: a season-tab bar can fire a request per tab
+ * before the previous one lands, and matching by arrival order would hand
+ * one season's data to a different season's tab whenever two responses
+ * reordered.
+ */
+ async fetchSeasonMeta(tmdbId, season) {
+ const msg = await this._sendAndWait({
+ type: 'season_meta_req', v: '0.6', tmdb_id: tmdbId, season,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * Raw TMDB search candidates for an operator correcting a wrong automatic
+ * match — unlike fetchMediaMeta, this never collapses to one best guess:
+ * a human picks from several, so several is the point. Read-only, not an
+ * admin op: it looks nothing up in this node's own state and changes
+ * nothing, so it needs no signature (mirrors why media_meta_req isn't
+ * signed either).
+ */
+ async searchTmdb(mediaType, query) {
+ const msg = await this._sendAndWait({
+ type: 'tmdb_search_req', v: '0.6', media_type: mediaType, query,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * Correct a wrong automatic TMDB match. Signed like setVideoRoot/
+ * setTmdbConfig: it replaces what every member sees for a show/movie,
+ * node-wide (media_cache is shared, not per-viewer) — an unsigned
+ * override would let any member vandalize another show's metadata.
+ * Applies to every file sharing the representative one's display_title,
+ * not just the file the operator happened to be looking at (webrtc_
+ * server.py's _admin_exec_tmdb_override).
+ */
+ async overrideTmdbMatch(path, tmdbId, mediaType, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'tmdb_override', v: '0.6', path, tmdb_id: tmdbId, media_type: mediaType,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ const subject = `path=${path},tmdb_id=${tmdbId},media_type=${mediaType}`;
+ return this._authorizeAdminOp(msg, 'tmdb_override', subject, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Turn TMDB lookups on/off node-wide, optionally set/clear a custom API
+ * token, and optionally set the language TMDB is queried in (e.g.
+ * "fr-FR") — one for the whole node, same reasoning as the token: one
+ * shared cache, not a per-viewer request. Signed like setAppsEnabled/
+ * setMemberUpload — an unsigned toggle would let any member turn on
+ * outbound third-party network traffic the operator never agreed to
+ * (docs/mediacenter.md §5.5, §8). `token: ''` explicitly clears a
+ * previously-set custom token; omit it (undefined/null), like
+ * `language`, to leave whatever is stored unchanged.
+ */
+ async setTmdbConfig(enabled, token, language, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'tmdb_config', v: '0.5', enabled: Boolean(enabled),
+ token: token === undefined ? null : token,
+ language: language === undefined ? null : language,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ // Must match the node's subject byte-for-byte (webrtc_server.py
+ // _do_tmdb_config): Python's f"{bool}" is "True"/"False", not JS's
+ // lowercase — and the token itself is never part of the subject
+ // (it would end up in the audit log in plaintext), only whether one
+ // was supplied. The language is not a secret, so it appears as-is.
+ const subject = `enabled=${enabled ? 'True' : 'False'},` +
+ `custom_token=${token ? 'yes' : 'no'},language=${language || 'default'}`;
+ return this._authorizeAdminOp(msg, 'tmdb_config', subject, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Which folder (possibly a subfolder of a shared root) the Videos app
+ * treats as its entry point for this group. `path: ''` means the whole
+ * group index. Signed like setAppsEnabled — it decides what every
+ * member's Videos tab shows.
+ */
+ async setVideoRoot(path, signFn) {
+ const clean = (path || '').replace(/^\/+|\/+$/g, '');
+ const msg = await this._sendAndWait({ type: 'video_root', v: '0.5', path: clean });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'video_root', clean, signFn);
+ }
+ return msg;
+ }
+
async fetchStreamSegment(fileId, segmentIndex, segmentDuration) {
const msg = await this._sendAndWait({
type: 'stream_seg',
@@ -1173,9 +1290,20 @@ class MeshBayTransport {
// *while* other traffic is in flight, so the fallback below would hand
// a pong to whatever was waiting — resolving a history request with a
// message that has no messages in it, and emptying the conversation.
+ // media_meta_req is the same shape as file_req: video-app.js fires
+ // one per visible poster-grid tile, several at a time — matching by
+ // arrival order handed one tile's TMDB result to a different tile
+ // whenever two responses reordered (reproduced live: which of two
+ // shows got the confident match flipped across reloads).
_key: obj.type === 'file_req'
? `chunk:${obj.file_id}:${obj.chunk_index}`
- : obj.type === 'ping' ? `ping:${obj.token}` : null,
+ : obj.type === 'ping' ? `ping:${obj.token}`
+ : obj.type === 'media_meta_req' ? `media_meta:${obj.path}`
+ // Same reordering hazard as media_meta_req: a season-tab bar or a
+ // search box can have more than one of these in flight at once.
+ : obj.type === 'season_meta_req' ? `season_meta:${obj.tmdb_id}:${obj.season}`
+ : obj.type === 'tmdb_search_req' ? `tmdb_search:${obj.media_type}:${obj.query}`
+ : null,
resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); },
reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); },
});
@@ -1276,6 +1404,34 @@ class MeshBayTransport {
this._onAppsEnabled(msg.apps || []);
}
+ // Node-wide (not per-group) — the operator changed whether TMDB is
+ // called at all, or supplied/cleared a custom token. `token_customized`
+ // only says whether one is set, never the token itself.
+ if (msg.type === 'tmdb_config_ack' && this._onTmdbConfig) {
+ this._onTmdbConfig({
+ enabled: Boolean(msg.enabled),
+ tokenCustomized: Boolean(msg.token_customized),
+ language: msg.language || '',
+ });
+ }
+
+ // Same shape: an operator corrected a wrong automatic TMDB match, and
+ // everyone connected needs to know their poster grid/detail modal for
+ // this show is now stale — falls through so the operator's own
+ // admin_response promise resolves on this same message, exactly like
+ // member_upload_ack/apps_enabled_ack above.
+ if (msg.type === 'tmdb_override_ack' && this._onTmdbOverride) {
+ this._onTmdbOverride({
+ path: msg.path || '', tmdbId: msg.tmdb_id || '', mediaType: msg.media_type || '',
+ });
+ }
+
+ // Same shape: the operator changed which folder is the Videos app's
+ // entry point for this group.
+ if (msg.type === 'video_root_ack' && this._onVideoRoot) {
+ this._onVideoRoot(msg.path || '');
+ }
+
// The operator's node is scanning — never the entries themselves, just
// enough to animate a presence dot. Pushed periodically while it runs,
// plus once more on the transition back to idle (daemon.py
@@ -1316,11 +1472,11 @@ class MeshBayTransport {
return;
}
- // Incremental update — additions/deletions only, never the whole index.
- // Only ever arrives after the full index this browser already has (the
- // node's first push to a newly connected peer is always index_sync, see
- // daemon.py _broadcast_index_change), so there is always a base to
- // apply it to.
+ // Incremental update — additions/deletions/updates, never the whole
+ // index. Only ever arrives after the full index this browser already
+ // has (the node's first push to a newly connected peer is always
+ // index_sync, see daemon.py _broadcast_index_change), so there is
+ // always a base to apply it to.
if (msg.type === 'index_delta') {
if (this._onIndexDelta) this._onIndexDelta(msg);
return;
@@ -1364,6 +1520,36 @@ class MeshBayTransport {
return;
}
+ if (msg.type === 'media_meta_resp') {
+ const key = `media_meta:${msg.path}`;
+ for (const [, handler] of this._pending) {
+ if (handler._key === key) { handler.resolve(msg); return; }
+ }
+ // Nobody asked for this path any more (tile scrolled out and a fresh
+ // request superseded it, most likely) — must not fall through to the
+ // oldest pending request, which would hand a different tile's promise
+ // a TMDB result for a path it never asked about.
+ return;
+ }
+
+ // Same reasoning as media_meta_resp: keyed, not arrival-order, and
+ // "nobody's waiting any more" must not fall through either.
+ if (msg.type === 'season_meta_resp') {
+ const key = `season_meta:${msg.tmdb_id}:${msg.season}`;
+ for (const [, handler] of this._pending) {
+ if (handler._key === key) { handler.resolve(msg); return; }
+ }
+ return;
+ }
+
+ if (msg.type === 'tmdb_search_resp') {
+ const key = `tmdb_search:${msg.media_type}:${msg.query}`;
+ for (const [, handler] of this._pending) {
+ if (handler._key === key) { handler.resolve(msg); return; }
+ }
+ return;
+ }
+
// chat_hist_resp answers a `chat_hist` request, but under a different
// type string — unlike index_sync, which is asked for and answered under
// the same name, so the generic fallback below happens to work for it by
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
new file mode 100644
index 0000000..00643f8
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -0,0 +1,816 @@
+import {
+ html, useState, useEffect, useRef, useMemo, useCallback,
+} from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+import { formatSize, pipelinedDownload } from './file-utils.js';
+
+// ── Videos ───────────────────────────────────────────────────────────────────
+//
+// A poster-grid (TMDB-enriched) or flat (thumbnail-only) browser for a
+// group's video files, per docs/mediacenter.md. Grouping: one card per movie,
+// one card per show — shows are grouped by `display_title` (already
+// resolved/corroborated at index time, §3.4), not by folder path, since a
+// client-side path convention would have to guess how many roots/subfolders
+// deep a show folder sits, which display_title already settled once.
+//
+// TMDB metadata is fetched lazily, only for a tile once it is actually
+// visible (LazyTile below) — apps.md §5's virtualization requirement for a
+// grid of many tiles. Thumbnails go through the same `file_req`/chunk path
+// as a real file (docs/mediacenter.md §5.3) via MediaThumb, reusing
+// chat-app.js's ChatImage pattern.
+
+const VIEW_MODE_KEY = 'meshbay_video_view_mode';
+
+function loadViewMode() {
+ try { return localStorage.getItem(VIEW_MODE_KEY) === 'flat' ? 'flat' : 'poster'; }
+ catch { return 'poster'; }
+}
+function saveViewMode(mode) {
+ try { localStorage.setItem(VIEW_MODE_KEY, mode); } catch { /* per-device convenience only */ }
+}
+
+function formatDuration(seconds) {
+ if (!seconds) return '';
+ const total = Math.round(seconds);
+ const h = Math.floor(total / 3600);
+ const m = Math.floor((total % 3600) / 60);
+ return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`;
+}
+
+function formatResolution(width, height) {
+ if (!width || !height) return '';
+ if (height >= 2100) return '4K';
+ if (height >= 1000) return `${height}p`;
+ return `${width}x${height}`;
+}
+
+function yearOf(dateStr) {
+ return dateStr ? String(dateStr).slice(0, 4) : '';
+}
+
+// ── grouping ─────────────────────────────────────────────────────────────────
+
+// Nothing shows until an operator has actually chosen a root in Settings
+// (§5.6/V-whatever this is now): the node itself runs no TMDB/thumbnail
+// work for this group before that either (daemon.py's
+// _enrich_new_video_entries), so falling back to "the whole index" here
+// would just show files nothing has enriched.
+function underVideoRoot(entry, videoRoot) {
+ if (!videoRoot) return false;
+ const p = entry.path || '';
+ return p === videoRoot || p.startsWith(videoRoot + '/');
+}
+
+function buildSeasons(episodes) {
+ const sorted = [...episodes].sort((a, b) => (a.season - b.season) || (a.episode - b.episode));
+ const bySeason = new Map();
+ for (const ep of sorted) {
+ if (!bySeason.has(ep.season)) bySeason.set(ep.season, []);
+ bySeason.get(ep.season).push(ep);
+ }
+ return [...bySeason.entries()].sort((a, b) => a[0] - b[0])
+ .map(([season, seasonEpisodes]) => ({ season, episodes: seasonEpisodes }));
+}
+
+function groupVideoEntries(entries, videoRoot) {
+ const movies = [];
+ const showsByTitle = new Map();
+ for (const e of entries) {
+ if (e.type !== 'video') continue;
+ if (!underVideoRoot(e, videoRoot)) continue;
+ if (e.season != null && e.episode != null) {
+ const title = e.display_title || e.name;
+ if (!showsByTitle.has(title)) showsByTitle.set(title, { title, episodes: [] });
+ showsByTitle.get(title).episodes.push(e);
+ } else {
+ movies.push(e);
+ }
+ }
+ movies.sort((a, b) => (a.display_title || a.name).localeCompare(b.display_title || b.name));
+ const shows = [...showsByTitle.values()].sort((a, b) => a.title.localeCompare(b.title));
+ for (const show of shows) {
+ show.episodes.sort((a, b) => (a.season - b.season) || (a.episode - b.episode));
+ show.seasons = buildSeasons(show.episodes);
+ }
+ return { movies, shows };
+}
+
+// ── lazy-mount tile (apps.md §5 virtualization) ─────────────────────────────
+
+const LAZY_TILE_MARGIN = 300;
+
+function LazyTile({ cls = 'video-tile-slot', children }) {
+ const ref = useRef(null);
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ if (visible || !ref.current) return;
+ // A tile that is already on screen (or within the margin) the moment
+ // it mounts — the overwhelmingly common case, since a merge (§V6) or a
+ // tab revisit mounts tiles into a grid that was already scrolled to
+ // wherever the operator was looking — doesn't need to wait for
+ // IntersectionObserver's own first callback at all: that first
+ // delivery is only a *microtask/next-paint* guarantee, not an
+ // immediate one, and was observed live taking upwards of 30 seconds
+ // (matching the browser's own periodic intersection-computation
+ // cadence exactly) — which read as "the poster never finishes
+ // loading" even though every fetch behind it had already completed.
+ // Checked synchronously so a genuinely below-the-fold tile still only
+ // mounts once actually scrolled near.
+ const rect = ref.current.getBoundingClientRect();
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
+ const alreadyNear = rect.bottom >= -LAZY_TILE_MARGIN && rect.top <= viewportHeight + LAZY_TILE_MARGIN;
+ if (alreadyNear) { setVisible(true); return; }
+ const obs = new IntersectionObserver((obsEntries) => {
+ if (obsEntries.some((oe) => oe.isIntersecting)) { setVisible(true); obs.disconnect(); }
+ }, { rootMargin: `${LAZY_TILE_MARGIN}px` });
+ obs.observe(ref.current);
+ return () => obs.disconnect();
+ }, [visible]);
+
+ return html`<div ref=${ref} class=${cls}>${visible ? children : null}</div>`;
+}
+
+// ── thumbnail/poster image, decrypted via the chunk path ────────────────────
+//
+// Cached per session by thumb_hash (a content hash, so it never goes stale):
+// the same poster reused across a season's worth of episode tiles is
+// decrypted once, not once per tile. Blob URLs are not revoked — the number
+// of distinct thumbnails one session ever visits is bounded by the library
+// size, and reference-counting revocation across many tile mounts/unmounts
+// would cost real complexity for a benefit that only matters in a very long
+// session.
+const _thumbBlobCache = new Map();
+
+function MediaThumb({
+ thumbHash, transportRef, gekRef, alt, cls = 'video-thumb', onReady,
+}) {
+ const [blobUrl, setBlobUrl] = useState(() => _thumbBlobCache.get(thumbHash) || null);
+
+ // Re-checks the cache by the CURRENT thumbHash on every change rather than
+ // trusting the `blobUrl` state variable — a PosterCard swaps this same
+ // component instance's thumbHash prop from the raw fallback frame to the
+ // TMDB poster once metadata resolves, and gating on "is blobUrl already
+ // set" (from the *previous* hash) would leave the fallback frame on
+ // screen forever instead of ever fetching the poster.
+ //
+ // `onReady` fires exactly once per settled thumbHash — cache hit, fetch
+ // success, fetch failure, or no hash at all — so a caller that hides this
+ // component until its image has actually arrived (PosterCard) always
+ // gets unstuck, even when there's nothing to show.
+ useEffect(() => {
+ const cached = _thumbBlobCache.get(thumbHash);
+ if (cached) { setBlobUrl(cached); if (onReady) onReady(cached); return; }
+ setBlobUrl(null);
+ if (!thumbHash) { if (onReady) onReady(null); return; }
+ let cancelled = false;
+ (async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) { if (onReady) onReady(null); return; }
+ try {
+ const chunks = await pipelinedDownload(transport, gekRef.current, thumbHash, 1);
+ if (cancelled) return;
+ const url = URL.createObjectURL(new Blob(chunks, { type: 'image/jpeg' }));
+ _thumbBlobCache.set(thumbHash, url);
+ setBlobUrl(url);
+ if (onReady) onReady(url);
+ } catch {
+ /* leave the placeholder — a transient fetch failure isn't an error state */
+ if (!cancelled && onReady) onReady(null);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [thumbHash]);
+
+ if (!blobUrl) return html`<div class="${cls} video-thumb-empty"><${Icon} name="video" /></div>`;
+ return html`<img class=${cls} src=${blobUrl} alt=${alt || ''} loading="lazy" />`;
+}
+
+// ── TMDB metadata, fetched once per visible tile ────────────────────────────
+
+// An operator correcting a wrong match (TmdbSearchOverlay below) changes
+// what `media_meta_req` returns for a path every already-mounted tile/modal
+// already has cached in its own useMediaMeta state — nothing would ever
+// refetch otherwise, since path/active don't change. Bumping this and
+// telling every subscribed hook to redo its fetch is simpler than trying to
+// know which paths a given override actually affects (that's server-side
+// knowledge — display_title grouping — this module doesn't have).
+const _mediaMetaListeners = new Set();
+function bumpMediaMetaGeneration() {
+ for (const fn of _mediaMetaListeners) fn();
+}
+
+function useMediaMeta(transportRef, path, active) {
+ const [meta, setMeta] = useState(null);
+ const [refetchToken, setRefetchToken] = useState(0);
+
+ useEffect(() => {
+ // Clears immediately (so the spinner shows right away, not only once
+ // the new fetch resolves) and bumps the token, which re-runs the fetch
+ // effect below regardless of whether path/active changed at all.
+ const listener = () => { setMeta(null); setRefetchToken((n) => n + 1); };
+ _mediaMetaListeners.add(listener);
+ return () => _mediaMetaListeners.delete(listener);
+ }, []);
+
+ useEffect(() => {
+ if (!active || !path) return;
+ let cancelled = false;
+ (async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+ try {
+ const resp = await transport.fetchMediaMeta(path);
+ if (!cancelled) setMeta(resp);
+ } catch { if (!cancelled) setMeta({ confidence: 0 }); }
+ })();
+ return () => { cancelled = true; };
+ }, [path, active, refetchToken]);
+ return meta;
+}
+
+// ── per-season TMDB metadata (overview/air_date/poster), season-tab view ───
+//
+// Found live: a show's own tmdb_meta.overview is one static field that does
+// not necessarily describe every season alike (a season-3-specific
+// promotional summary read as the synopsis for all three seasons). Cached
+// for the session by tmdb_id+season, mirroring _thumbBlobCache — the same
+// season is revisited every time its tab is reselected.
+const _seasonMetaCache = new Map();
+
+function useSeasonMeta(transportRef, tmdbId, season, active) {
+ const cacheKey = active && tmdbId != null && season != null ? `${tmdbId}:${season}` : null;
+ const [meta, setMeta] = useState(() => (cacheKey ? _seasonMetaCache.get(cacheKey) || null : null));
+ useEffect(() => {
+ if (!cacheKey) return;
+ const cached = _seasonMetaCache.get(cacheKey);
+ if (cached) { setMeta(cached); return; }
+ setMeta(null);
+ let cancelled = false;
+ (async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+ try {
+ const resp = await transport.fetchSeasonMeta(tmdbId, season);
+ if (cancelled) return;
+ _seasonMetaCache.set(cacheKey, resp);
+ setMeta(resp);
+ } catch { if (!cancelled) setMeta({ confidence: 0 }); }
+ })();
+ return () => { cancelled = true; };
+ }, [cacheKey]);
+ return meta;
+}
+
+// ── Mode A: poster grid ──────────────────────────────────────────────────────
+
+function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, groupKey, onMetaResolved }) {
+ const meta = useMediaMeta(transportRef, repEntry.path, true);
+ const confident = meta && meta.confidence && meta.tmdb_id;
+ const metaReady = meta !== null;
+
+ // Reports this tile's own resolution upward so PosterGrid can notice two
+ // differently-parsed folders (a show split across release groups that
+ // named its seasons inconsistently, §3.4/V6) resolving to the same TMDB
+ // id, and merge them into one card — never required for the generic
+ // per-folder display to work, only an enhancement once it's known safe.
+ useEffect(() => {
+ if (meta && onMetaResolved) onMetaResolved(groupKey, meta);
+ }, [meta, groupKey]);
+
+ const posterHash = confident && meta.poster_thumb_hash ? meta.poster_thumb_hash : repEntry.thumb_hash;
+
+ // Which hash MediaThumb has actually confirmed ready — compared against
+ // the *current* posterHash below, rather than a separate boolean reset
+ // by its own effect on posterHash change. That second-writer shape had a
+ // real bug: the instant metaReady flips true, posterHash jumps from the
+ // raw fallback frame to the resolved poster in the very same commit that
+ // mounts MediaThumb for it — and when that poster's bytes are already in
+ // MediaThumb's session cache (a revisit within the same tab, no reload),
+ // its onReady fires synchronously from that same mount effect. Effects
+ // run children-first, so the "reset on posterHash change" effect fired
+ // *after* it, in the same commit, unconditionally setting the flag back
+ // to false — with no later event left to ever set it true again. The
+ // card stayed a spinner forever despite the image already being loaded.
+ // Deriving readiness from a direct comparison has no such ordering to
+ // get wrong: whichever of the two fires, in whichever order, the render
+ // that follows sees the same answer.
+ const [readyHash, setReadyHash] = useState(null);
+ const handleImageReady = useCallback(() => setReadyHash(posterHash), [posterHash]);
+ const imageReady = readyHash === posterHash;
+
+ // Nothing is shown until BOTH the TMDB lookup and the final chosen image
+ // (the poster once matched, the file's own frame otherwise) have
+ // actually settled. Revealing the raw per-file frame first and swapping
+ // it for the poster a moment later — or showing a card that a moment
+ // later gets absorbed into a neighbour once §V6's merge kicks in — was
+ // exactly the flash an operator flagged as hard on the eyes. A slow
+ // lookup (a big, freshly-scanned library) just means the spinner stays a
+ // little longer, never a partially-drawn card.
+ const ready = metaReady && imageReady;
+
+ return html`
+ <div class="video-card" onClick=${onOpen}>
+ ${!ready && html`
+ <div class="video-poster video-poster-loading"><span class="spinner"></span></div>
+ `}
+ <div class="video-poster-slot" style=${ready ? '' : 'display:none'}>
+ ${metaReady && html`
+ <${MediaThumb} thumbHash=${posterHash} alt=${title}
+ cls="video-poster" transportRef=${transportRef} gekRef=${gekRef}
+ onReady=${handleImageReady} />
+ `}
+ </div>
+ ${ready && html`
+ <div class="video-card-info">
+ <div class="video-card-title">${(confident && meta.title) || title}</div>
+ <div class="video-card-sub">
+ ${confident && meta.release_date ? yearOf(meta.release_date) : ''}
+ ${confident && meta.first_air_date ? yearOf(meta.first_air_date) : ''}
+ ${subtitle ? ` · ${subtitle}` : ''}
+ </div>
+ </div>
+ `}
+ </div>
+ `;
+}
+
+// ── season tab bar (docs/mediacenter.md §5.4's fix for a mis-scoped overview) ─
+
+function SeasonTabs({ seasons, selected, onSelect }) {
+ return html`
+ <div class="video-season-tabs">
+ ${seasons.map((s) => html`
+ <button key=${s.season}
+ class="video-season-tab ${selected === s.season ? 'active' : ''}"
+ onClick=${() => onSelect(s.season)}>
+ ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })}
+ </button>
+ `)}
+ </div>
+ `;
+}
+
+// ── operator: correct a wrong automatic TMDB match ──────────────────────────
+
+// Same shape as group-settings.js's own signFn construction (setVideoRoot,
+// setTmdbConfig, ...) — there is no group-wide "sign this" helper to share,
+// each caller builds one from the connection it already has.
+function buildSignFn(transportRef) {
+ const transport = transportRef.current;
+ const sk = transport && transport.sessionKeys && transport.sessionKeys.skEdB64;
+ return (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+}
+
+function TmdbSearchOverlay({
+ initialQuery, mediaType, path, transportRef, gekRef, onClose, onApplied,
+}) {
+ const [query, setQuery] = useState(initialQuery || '');
+ const [results, setResults] = useState(null); // null = not searched yet
+ const [searching, setSearching] = useState(false);
+ const [applying, setApplying] = useState(false);
+ const [error, setError] = useState('');
+
+ const runSearch = useCallback(async (e) => {
+ if (e) e.preventDefault();
+ const q = query.trim();
+ if (!q || searching) return;
+ setSearching(true);
+ setError('');
+ try {
+ const transport = transportRef.current;
+ const resp = await transport.searchTmdb(mediaType, q);
+ setResults(resp.results || []);
+ } catch (err) {
+ setError(err.message);
+ setResults([]);
+ } finally {
+ setSearching(false);
+ }
+ }, [query, mediaType, transportRef, searching]);
+
+ const apply = useCallback(async (tmdbId) => {
+ if (applying) return;
+ setApplying(true);
+ setError('');
+ try {
+ const signFn = buildSignFn(transportRef);
+ await transportRef.current.overrideTmdbMatch(path, tmdbId, mediaType, signFn);
+ bumpMediaMetaGeneration();
+ onApplied();
+ } catch (err) {
+ setError(err.message);
+ setApplying(false);
+ }
+ }, [applying, path, mediaType, transportRef, onApplied]);
+
+ return html`
+ <div class="video-overlay video-search-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-search-overlay')) onClose();
+ }}>
+ <div class="video-detail video-search-panel">
+ <div class="video-top-bar">
+ <span class="video-title">${t('video.search_title')}</span>
+ <button class="video-close" onClick=${onClose} title=${t('video.close')}>
+ <${Icon} name="close" /></button>
+ </div>
+ <div class="video-detail-body">
+ <form class="video-search-form" onSubmit=${runSearch}>
+ <input type="text" value=${query} autofocus
+ placeholder=${t('video.search_placeholder')}
+ onInput=${(e) => setQuery(e.target.value)} />
+ <button class="admin-btn" type="submit" disabled=${searching || !query.trim()}>
+ ${searching ? html`<span class="spinner"></span>` : t('video.search_button')}
+ </button>
+ </form>
+ <p class="video-search-hint">${t('video.search_apply_hint')}</p>
+ ${error && html`<p class="video-search-error">${error}</p>`}
+ ${results && results.length === 0 && !searching && html`
+ <p class="page-message">${t('video.search_no_results')}</p>
+ `}
+ ${results && results.length > 0 && html`
+ <div class="video-search-results">
+ ${results.map((r) => html`
+ <button class="video-search-result" key=${r.tmdb_id}
+ disabled=${applying}
+ onClick=${() => apply(r.tmdb_id)}>
+ <div class="video-search-result-thumb">
+ <${MediaThumb} thumbHash=${r.poster_thumb_hash} alt=${r.title}
+ cls="video-search-result-poster" transportRef=${transportRef} gekRef=${gekRef} />
+ </div>
+ <div class="video-flat-info">
+ <div class="video-flat-title">${r.title}</div>
+ <div class="video-flat-sub">${r.year}</div>
+ </div>
+ </button>
+ `)}
+ </div>
+ `}
+ </div>
+ </div>
+ </div>
+ `;
+}
+
+function VideoDetailModal({
+ title, meta, repEntry, show, transportRef, gekRef, onClose, onPlay, isNodeAdmin,
+}) {
+ const confident = meta && meta.confidence && meta.tmdb_id;
+ const [searching, setSearching] = useState(false);
+ const mediaType = show ? 'tv' : 'movie';
+
+ // Reset whenever a different file/show is opened in this same modal
+ // instance — repEntry/show change identity, selectedSeason must not
+ // silently keep pointing at whatever the previous show's season 4 was.
+ const [selectedSeason, setSelectedSeason] = useState(null);
+ useEffect(() => {
+ if (!show) { setSelectedSeason(null); return; }
+ const preferred = repEntry.season != null && show.seasons.some((s) => s.season === repEntry.season)
+ ? repEntry.season
+ : (show.seasons.find((s) => s.season !== 0) || show.seasons[0]).season;
+ setSelectedSeason(preferred);
+ }, [show, repEntry]);
+
+ const showMultiSeason = Boolean(show && show.seasons.length > 1);
+ const seasonMeta = useSeasonMeta(
+ transportRef, confident ? meta.tmdb_id : null, selectedSeason,
+ showMultiSeason && Boolean(confident) && selectedSeason != null);
+ const seasonConfident = showMultiSeason && seasonMeta && seasonMeta.confidence;
+
+ return html`
+ <div class="video-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-overlay')) onClose();
+ }}>
+ <div class="video-detail">
+ <div class="video-top-bar">
+ <span class="video-title">${(confident && meta.title) || title}</span>
+ <button class="video-close" onClick=${onClose} title=${t('video.close')}>
+ <${Icon} name="close" /></button>
+ </div>
+ <div class="video-detail-body">
+ ${confident && html`
+ <p class="video-detail-overview">${(seasonConfident && seasonMeta.overview) || meta.overview}</p>
+ <p class="video-detail-facts">
+ ${meta.vote_average ? `★ ${meta.vote_average.toFixed(1)}` : ''}
+ ${meta.genres && meta.genres.length ? ` · ${meta.genres.join(', ')}` : ''}
+ ${meta.director ? ` · ${t('video.director')}: ${meta.director}` : ''}
+ ${seasonConfident && seasonMeta.air_date ? ` · ${yearOf(seasonMeta.air_date)}` : ''}
+ </p>
+ ${meta.cast && meta.cast.length > 0 && html`
+ <p class="video-detail-cast">
+ ${meta.cast.slice(0, 6).map((c) => c.name).join(', ')}
+ </p>
+ `}
+ `}
+ ${isNodeAdmin && html`
+ <button class="admin-btn video-fix-match" onClick=${() => setSearching(true)}>
+ ${t('video.fix_match')}
+ </button>
+ `}
+ ${showMultiSeason && html`
+ <${SeasonTabs} seasons=${show.seasons} selected=${selectedSeason}
+ onSelect=${setSelectedSeason} />
+ `}
+ ${!show && html`
+ <button class="admin-btn" onClick=${() => onPlay(repEntry)}>
+ <${Icon} name="play" /> ${t('group.play')}
+ ${repEntry.duration ? ` (${formatDuration(repEntry.duration)})` : ''}
+ </button>
+ `}
+ ${show && html`
+ <div class="video-season-list">
+ ${(showMultiSeason ? show.seasons.filter((s) => s.season === selectedSeason) : show.seasons)
+ .map((s) => html`
+ <div class="video-season" key=${s.season}>
+ ${!showMultiSeason && html`
+ <div class="video-season-header">
+ ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })}
+ </div>
+ `}
+ ${s.episodes.map((ep) => html`
+ <button class="video-episode-row" key=${ep.id} onClick=${() => onPlay(ep)}>
+ <${LazyTile} cls="video-episode-thumb-slot">
+ <${MediaThumb} thumbHash=${ep.thumb_hash} alt=${ep.display_title || ep.name}
+ cls="video-episode-thumb" transportRef=${transportRef} gekRef=${gekRef} />
+ </${LazyTile}>
+ <span class="video-episode-label">
+ S${ep.season}E${String(ep.episode).padStart(2, '0')}
+ ${' · '}${ep.display_title || ep.name}
+ </span>
+ <span class="video-episode-meta">
+ ${formatDuration(ep.duration)} ${formatResolution(ep.width, ep.height)}
+ </span>
+ </button>
+ `)}
+ </div>
+ `)}
+ </div>
+ `}
+ </div>
+ </div>
+ </div>
+ ${searching && html`
+ <${TmdbSearchOverlay} initialQuery=${(confident && meta.title) || title} mediaType=${mediaType}
+ path=${repEntry.path} transportRef=${transportRef} gekRef=${gekRef}
+ onClose=${() => setSearching(false)}
+ onApplied=${() => setSearching(false)} />
+ `}
+ `;
+}
+
+function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnabled, isNodeAdmin }) {
+ const [detail, setDetail] = useState(null); // { title, repEntry, show? }
+ // raw (per-folder-parsed-title) show title -> its own resolved media_meta_resp.
+ const [metaByGroup, setMetaByGroup] = useState({});
+
+ const handleMetaResolved = useCallback((groupKey, meta) => {
+ setMetaByGroup((prev) => (prev[groupKey] === meta ? prev : { ...prev, [groupKey]: meta }));
+ }, []);
+
+ // Two raw groups (grouped by parsed display_title, §4.1) resolving to the
+ // same confident TMDB id are almost certainly one show whose seasons
+ // were released under differently-named folders — confirmed live: one
+ // operator's show had its two seasons parsed as "Ovni" and "OVNIs" by
+ // two different release groups, showing as two identical-looking cards
+ // once both matched the same real show (§3.4/V6). Merged here once both
+ // are actually known — never required for the fallback to work: a group
+ // with no confident match yet, or ever, still shows on its own, exactly
+ // the generic per-folder display needs.
+ const mergedShows = useMemo(() => {
+ const byTmdbId = new Map();
+ const standalone = [];
+ for (const s of shows) {
+ const meta = metaByGroup[s.title];
+ const tmdbId = meta && meta.confidence && meta.tmdb_id;
+ if (tmdbId) {
+ if (!byTmdbId.has(tmdbId)) byTmdbId.set(tmdbId, []);
+ byTmdbId.get(tmdbId).push(s);
+ } else {
+ standalone.push([s]);
+ }
+ }
+ return [...byTmdbId.values(), ...standalone].map((groups) => {
+ const episodes = groups.flatMap((g) => g.episodes);
+ return {
+ // groups[0].title, not a joined string of every constituent's
+ // title: a fresh key here would make this a brand-new PosterCard
+ // (and LazyTile) the instant a second raw group merges into an
+ // already-visible one — throwing away its already-fired
+ // IntersectionObserver and already-resolved metadata/poster for no
+ // reason, and reintroducing exactly the flash the "ready" gating
+ // above exists to prevent. groups[0].title is already unique
+ // (raw titles are, via groupVideoEntries' showsByTitle) and, for
+ // the overwhelmingly common unmerged case, is the same key the
+ // card already had — so nothing about this changes when no merge
+ // ever happens.
+ key: groups[0].title,
+ title: groups[0].title,
+ episodes,
+ seasons: buildSeasons(episodes),
+ };
+ });
+ }, [shows, metaByGroup]);
+
+ const openDetail = (title, repEntry, show) => setDetail({ title, repEntry, show });
+ const detailMeta = useMediaMeta(transportRef, detail ? detail.repEntry.path : null, !!detail);
+
+ return html`
+ <div class="video-grid">
+ ${movies.map((e) => html`
+ <${LazyTile} key=${e.id}>
+ <${PosterCard} title=${e.display_title || e.name}
+ subtitle=${formatDuration(e.duration)} repEntry=${e}
+ groupKey=${`movie:${e.id}`}
+ transportRef=${transportRef} gekRef=${gekRef}
+ onOpen=${() => (tmdbEnabled
+ // With TMDB off there is nothing the detail modal would show
+ // for a movie (no overview, no season list to pick from,
+ // unlike a show) — so it would just be an extra click in
+ // front of a Play button. Straight to the player instead.
+ ? openDetail(e.display_title || e.name, e, null)
+ : onPreview(e))} />
+ </${LazyTile}>
+ `)}
+ ${mergedShows.map((s) => {
+ // Prefer an episode that actually has a thumbnail over blindly
+ // episodes[0]: if that specific file's enrichment hasn't produced
+ // one yet (or failed), the card showed an empty placeholder even
+ // though sibling episodes — visible right there in Flat list —
+ // have one. The TMDB path (or its absence) is the same regardless
+ // of which episode's own file supplies the fallback frame.
+ const repEntry = s.episodes.find((e) => e.thumb_hash) || s.episodes[0];
+ // Known up front from the already-parsed index fields (§3.4), no
+ // TMDB needed: a card covering exactly one season says so before
+ // a click, rather than an anonymous episode count — the generic
+ // "N episodes" stays for a merged, multi-season, or special-only
+ // card, where a single number would misrepresent it.
+ const singleSeason = s.seasons.length === 1 ? s.seasons[0].season : null;
+ const subtitle = singleSeason != null
+ ? (singleSeason === 0 ? t('video.specials') : t('video.season_n', { n: singleSeason }))
+ : t('video.n_episodes', { n: s.episodes.length });
+ return html`
+ <${LazyTile} key=${s.key}>
+ <${PosterCard} title=${s.title}
+ subtitle=${subtitle}
+ repEntry=${repEntry}
+ groupKey=${s.title}
+ onMetaResolved=${handleMetaResolved}
+ transportRef=${transportRef} gekRef=${gekRef}
+ onOpen=${() => openDetail(s.title, repEntry, s)} />
+ </${LazyTile}>
+ `; })}
+ </div>
+ ${detail && html`
+ <${VideoDetailModal} title=${detail.title} meta=${detailMeta}
+ repEntry=${detail.repEntry} show=${detail.show}
+ transportRef=${transportRef} gekRef=${gekRef} isNodeAdmin=${isNodeAdmin}
+ onClose=${() => setDetail(null)}
+ onPlay=${(entry) => { setDetail(null); onPreview(entry); }} />
+ `}
+ `;
+}
+
+// ── Mode B: flat, thumbnail-only, no TMDB ────────────────────────────────────
+
+// Within a season group, every episode's own display_title is usually
+// just the show name again (guessit rarely finds a per-episode subtitle
+// for this kind of release) — repeating "OVNI" twelve times in a row said
+// nothing an episode number wouldn't say better. Shown only when this row
+// is actually inside a season group (`seasonContext` set); a real,
+// distinct per-episode title (a show that *does* carry one) still wins
+// over the generic "Episode N" label.
+function FlatMovieRow({ entry, transportRef, gekRef, onPreview, seasonContext }) {
+ const isEpisode = seasonContext && entry.season != null && entry.episode != null;
+ const hasOwnTitle = entry.display_title && entry.display_title !== seasonContext;
+ const label = isEpisode
+ ? (hasOwnTitle ? `${t('video.episode_n', { n: entry.episode })} · ${entry.display_title}`
+ : t('video.episode_n', { n: entry.episode }))
+ : (entry.display_title || entry.name);
+
+ return html`
+ <div class="video-flat-row" onClick=${() => onPreview(entry)}>
+ <${LazyTile} cls="video-flat-thumb-slot">
+ <${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.display_title || entry.name}
+ cls="video-flat-thumb" transportRef=${transportRef} gekRef=${gekRef} />
+ </${LazyTile}>
+ <div class="video-flat-info">
+ <div class="video-flat-title">${label}</div>
+ <div class="video-flat-sub">
+ ${formatDuration(entry.duration)} ${formatResolution(entry.width, entry.height)}
+ ${' · '}${formatSize(entry.size)}
+ </div>
+ </div>
+ </div>
+ `;
+}
+
+function FlatShowFolder({ show, transportRef, gekRef, onPreview }) {
+ const [open, setOpen] = useState(false);
+ return html`
+ <div class="video-flat-folder">
+ <div class="video-flat-row" onClick=${() => setOpen((v) => !v)}>
+ <div class="video-flat-thumb video-thumb-empty"><${Icon} name="folder" /></div>
+ <div class="video-flat-info">
+ <div class="video-flat-title">${show.title}</div>
+ <div class="video-flat-sub">${t('video.n_episodes', { n: show.episodes.length })}</div>
+ </div>
+ <${Icon} name="chevron" cls=${open ? 'video-flat-chevron open' : 'video-flat-chevron'} />
+ </div>
+ ${open && show.seasons.map((s) => html`
+ <div class="video-flat-season" key=${s.season}>
+ <div class="video-season-header">
+ ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })}
+ </div>
+ ${s.episodes.map((ep) => html`
+ <${FlatMovieRow} key=${ep.id} entry=${ep} seasonContext=${show.title}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />
+ `)}
+ </div>
+ `)}
+ </div>
+ `;
+}
+
+function FlatList({ movies, shows, transportRef, gekRef, onPreview }) {
+ const items = [
+ ...movies.map((e) => ({ key: e.display_title || e.name, kind: 'movie', entry: e })),
+ ...shows.map((s) => ({ key: s.title, kind: 'show', show: s })),
+ ].sort((a, b) => a.key.localeCompare(b.key));
+
+ return html`
+ <div class="video-flat-list">
+ ${items.map((it) => it.kind === 'movie'
+ ? html`<${FlatMovieRow} key=${it.entry.id} entry=${it.entry}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`
+ : html`<${FlatShowFolder} key=${it.show.title} show=${it.show}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`)}
+ </div>
+ `;
+}
+
+// ── shell ────────────────────────────────────────────────────────────────────
+
+function VideoApp({
+ groupId, transportRef, gekRef, status, entries, onPreview, videoRoot, tmdbConfig, isNodeAdmin,
+}) {
+ const [mode, setMode] = useState(loadViewMode);
+ const [filter, setFilter] = useState('');
+ const tmdbEnabled = tmdbConfig ? tmdbConfig.enabled : true;
+
+ useEffect(() => { setMode(loadViewMode()); }, [groupId]);
+ useEffect(() => { setFilter(''); }, [groupId]);
+
+ const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
+
+ const { movies, shows } = useMemo(
+ () => groupVideoEntries(entries, videoRoot), [entries, videoRoot]);
+
+ const needle = filter.trim().toLowerCase();
+ const filteredMovies = useMemo(() => (!needle ? movies : movies.filter(
+ (e) => (e.display_title || e.name).toLowerCase().includes(needle))), [movies, needle]);
+ const filteredShows = useMemo(() => (!needle ? shows : shows.filter(
+ (s) => s.title.toLowerCase().includes(needle))), [shows, needle]);
+
+ return html`
+ ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`
+ <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
+ `}
+ ${status === 'offline' && html`
+ <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p>
+ `}
+ ${status === 'connected' && !videoRoot && html`
+ <p class="page-message">${t('video.no_root_configured')}</p>
+ `}
+ ${status === 'connected' && videoRoot && html`
+ <div class="video-toolbar">
+ <button class="tb-btn ${mode === 'poster' ? 'active' : ''}"
+ onClick=${() => setModeAndSave('poster')}>
+ ${t('video.mode_poster')}
+ </button>
+ <button class="tb-btn ${mode === 'flat' ? 'active' : ''}"
+ onClick=${() => setModeAndSave('flat')}>
+ ${t('video.mode_flat')}
+ </button>
+ <div class="tb-search">
+ <${Icon} name="search" />
+ <input type="text" placeholder="${t('group.filter')}"
+ value=${filter} onInput=${(e) => setFilter(e.target.value)} />
+ </div>
+ </div>
+ ${filteredMovies.length === 0 && filteredShows.length === 0 && html`
+ <p class="page-message">${needle ? t('group.empty_filter') : t('video.empty')}</p>
+ `}
+ ${mode === 'poster'
+ ? html`<${PosterGrid} movies=${filteredMovies} shows=${filteredShows}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview}
+ tmdbEnabled=${tmdbEnabled} isNodeAdmin=${isNodeAdmin} />`
+ : html`<${FlatList} movies=${filteredMovies} shows=${filteredShows}
+ transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`}
+ `}
+ `;
+}
+
+export { VideoApp };
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index dac1357..d7b7fff 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -34,7 +34,7 @@ APP = STATIC / "app.js"
# the files named in this list.
STATIC_FILES = [
"app.js", "group-page.js", "chat-app.js", "files-app.js",
- "video-player.js", "group-settings.js",
+ "video-player.js", "video-app.js", "group-settings.js",
]
pytestmark = pytest.mark.skipif(not APP.exists(), reason="SPA sources unavailable")
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index e014518..792aebd 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -28,7 +28,8 @@ APP = STATIC / "app.js"
CHAT_APP = STATIC / "chat-app.js"
GROUP_PAGE = STATIC / "group-page.js"
SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js",
- STATIC / "video-player.js", STATIC / "group-settings.js"]
+ STATIC / "video-player.js", STATIC / "video-app.js",
+ STATIC / "group-settings.js"]
pytestmark = pytest.mark.skipif(
not TRANSPORT.exists(), reason="the SPA sources are not available")
diff --git a/packages/meshbay-node/pyproject.toml b/packages/meshbay-node/pyproject.toml
index 4999e3d..58f6f7f 100644
--- a/packages/meshbay-node/pyproject.toml
+++ b/packages/meshbay-node/pyproject.toml
@@ -18,6 +18,7 @@ dependencies = [
"websockets>=12.0", # hub→node revocation push
"aiortc>=1.9", # WebRTC DataChannel for browser P2P (Phase 9)
"aiosqlite>=0.20", # async SQLite for chat, audit, bundle stores
+ "guessit>=4.4", # filename parsing for the Videos app
]
[project.optional-dependencies]
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index af85340..bf6bd64 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -26,7 +26,7 @@ Usage:
"""
import asyncio
-from dataclasses import asdict
+from dataclasses import asdict, replace
import base64
import json
import logging
@@ -38,14 +38,17 @@ from pathlib import Path
import uvicorn
from meshbay_common import MNP_VERSION
-from meshbay_common.protocol import MNP
+from meshbay_common.protocol import MNP, index_entry_wire
from meshbay_node.audit import AuditStore
from meshbay_node.bundle_store import BundleStore
from meshbay_node.chat.store import ChatStore
from meshbay_node.config import Config, DEFAULT_CONFIG_PATH, load_config, write_example_config
-from meshbay_node.roots import RootSet, RootError
+from meshbay_node.roots import RootSet, RootError, entry_abs_path
from meshbay_node.hub_client import HubClient, HubConfig
from meshbay_node.indexer import DirectoryIndexer, IndexCache, GroupIndex
+from meshbay_node.indexer.enrich import Enricher
+from meshbay_node.media_cache import MediaCache
+from meshbay_node.tmdb import TmdbClient
from meshbay_node.keystore import create_keystore, load_keystore, load_or_create_keystore
from meshbay_node.roster import Roster
from meshbay_node.transport import (
@@ -62,6 +65,12 @@ if WEBRTC_AVAILABLE:
log = logging.getLogger(__name__)
+def _under_video_root(path: str, video_root: str) -> bool:
+ """Mirrors video-app.js's underVideoRoot: same folder, or a descendant."""
+ path = path or ""
+ return path == video_root or path.startswith(video_root + "/")
+
+
# ── Argon2id calibration ──────────────────────────────────────────────────────
def calibrate_argon2(target_ms: int = 500) -> None:
@@ -132,6 +141,15 @@ class NodeDaemon:
self._last_broadcast_snapshot: dict[str, tuple] = {}
self._audit_store: AuditStore | None = None
self._bundle_store: BundleStore | None = None
+ self._media_cache: MediaCache | None = None
+ self._enricher: Enricher | None = None
+ self._tmdb_client: TmdbClient | None = None
+ # A file id attempted at most once per daemon run, success or
+ # 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).
+ self._enriched_attempted: set[str] = set()
self._roster: Roster | None = None
self._indexers: list[DirectoryIndexer] = []
self._tasks: list[asyncio.Task] = []
@@ -312,6 +330,10 @@ class NodeDaemon:
# by the signed operation that changes it.
"enabled_apps": await self._roster.enabled_apps(
group_cfg.id) if self._roster else list(Roster.DEFAULT_APPS),
+ # Which folder is the Videos app's entry point for this
+ # group — "" means the whole group index.
+ "video_root": await self._roster.video_root(
+ group_cfg.id) if self._roster else "",
}
if not groups_ctx:
@@ -333,6 +355,26 @@ class NodeDaemon:
await self._audit_store.open()
log.info("Audit store opened: %s", audit_db)
+ # 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).
+ media_cache_db = data_dir / "media_cache.db"
+ self._media_cache = MediaCache(db_path=media_cache_db)
+ await self._media_cache.open()
+ self._enricher = Enricher(self._media_cache)
+ self._tmdb_client = TmdbClient(roster=self._roster)
+ # Read once at load, like member_upload/enabled_apps — kept
+ # current in place by ops.set_tmdb_config (the signed op),
+ # exposed to every group's handshake ack via `daemon_state`
+ # (already wired to self._webrtc._ctx below) since this is
+ # node-wide, not per-group.
+ tmdb_enabled, tmdb_token, tmdb_language = await self._roster.tmdb_config()
+ self._state["tmdb_enabled"] = tmdb_enabled
+ self._state["tmdb_token_customized"] = bool(tmdb_token)
+ self._state["tmdb_language"] = tmdb_language or ""
+ log.info("Media cache opened: %s", media_cache_db)
+
# 5. Denylist
denylist = self._denylist
@@ -359,6 +401,8 @@ class NodeDaemon:
self._webrtc._ctx["node_user_id"] = session.user_id
self._webrtc._ctx["audit_store"] = self._audit_store
self._webrtc._ctx["bundle_store"] = self._bundle_store
+ self._webrtc._ctx["media_cache"] = self._media_cache
+ self._webrtc._ctx["tmdb_client"] = self._tmdb_client
self._webrtc._ctx["sk_x25519_raw"] = sk_x_raw
self._webrtc._ctx["pk_x25519_raw"] = pk_x_raw
self._webrtc._ctx["pk_x25519_b64"] = keys.pk_x25519_b64
@@ -468,6 +512,7 @@ class NodeDaemon:
self._state["quic_server"] = self._quic_server
self._state["hub"] = hub
self._state["reload_fn"] = self._reload_config
+ self._state["enrich_video_root_fn"] = self._enrich_video_root_now
# Rotating a key has to reach every transport holding a copy of it,
# and clearing the denylist has to reach the one the handshake
# consults — so both are published rather than reachable only
@@ -494,6 +539,16 @@ class NodeDaemon:
hashes = [e.id for e in gctx["index"].entries]
if hashes:
await self._register_swarm(hashes, endpoint)
+ # initial_scan() itself never calls on_change (it predates
+ # the concept — every existing caller only cared about the
+ # scan finishing, not about notifying anyone) — but Videos
+ # app enrichment (duration/thumb_hash/display_title/...)
+ # hangs entirely off that callback (_broadcast_index_change).
+ # Without this, every file already on disk at startup — the
+ # common case, an existing library — would never get
+ # enriched at all; only a file added later, while the node
+ # is already running, would trigger it via the watchdog.
+ await self._on_index_change(indexer)
for idx, group_cfg in zip(self._indexers, self._config.groups):
gctx = groups_ctx.get(group_cfg.id)
@@ -663,6 +718,9 @@ class NodeDaemon:
"enabled_apps": (
await self._roster.enabled_apps(group_cfg.id)
if self._roster else list(Roster.DEFAULT_APPS)),
+ "video_root": (
+ await self._roster.video_root(group_cfg.id)
+ if self._roster else ""),
"chat_store": store,
}
groups_ctx[group_cfg.id] = new_ctx
@@ -880,6 +938,7 @@ class NodeDaemon:
prev = self._last_broadcast_snapshot.get(group_id)
delta = None
+ previous = None
if prev is not None:
prev_version, prev_entries = prev
previous = GroupIndex._snapshot(
@@ -887,6 +946,34 @@ 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
+ # 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
+ # INDEX_DELTA update (_on_enriched below).
+ new_entries = delta.additions if delta is not None else list(idx.entries)
+ asyncio.ensure_future(self._enrich_new_video_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
+ # 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
+ # name/path alone and must not re-trigger itself forever.
+ if delta is not None and delta.updates and previous is not None:
+ asyncio.ensure_future(
+ self._reenrich_renamed_video_entries(indexer, delta.updates, previous))
+
+ # Videos app: a file that leaves the index also loses its thumbnail
+ # and file->tmdb mapping — the "real deletion obligation" docs/
+ # mediacenter.md §2/§8 calls out explicitly rather than leaving
+ # implicit. tmdb_meta rows are left alone (§2: shared across files).
+ if delta is not None and delta.deletions and self._media_cache:
+ for file_id in delta.deletions:
+ asyncio.ensure_future(self._media_cache.prune_file(file_id))
+
# 11.5 — Push to connected WebRTC peers in this group
if self._webrtc:
if delta is not None:
@@ -896,12 +983,9 @@ class NodeDaemon:
"group_id": idx.group_id,
"base_version": delta.base_version,
"version": delta.version,
- "additions": [
- {"id": e.id, "name": e.name, "path": e.path,
- "size": e.size, "type": e.type, "added_at": e.added_at}
- for e in delta.additions
- ],
+ "additions": [index_entry_wire(e) for e in delta.additions],
"deletions": delta.deletions,
+ "updates": [index_entry_wire(e) for e in delta.updates],
}
else:
msg = {
@@ -909,11 +993,7 @@ class NodeDaemon:
"v": MNP_VERSION,
"group_id": idx.group_id,
"version": idx.version,
- "entries": [
- {"id": e.id, "name": e.name, "path": e.path,
- "size": e.size, "type": e.type, "added_at": e.added_at}
- for e in idx.entries
- ],
+ "entries": [index_entry_wire(e) for e in idx.entries],
}
pushed = 0
for session in list(self._webrtc._sessions.values()):
@@ -942,6 +1022,103 @@ class NodeDaemon:
endpoint = f"webrtc:{self._config.node.quic_port}"
asyncio.ensure_future(self._register_swarm(hashes, endpoint))
+ async def _enrich_new_video_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
+ """
+ Videos app: fire (never await further) enrichment for unattempted
+ video entries under the group's configured video_root.
+
+ 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
+ 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 from ops.set_video_root)
+ separately sweeps whatever it already contains — this path alone only
+ ever sees entries new since the last broadcast.
+ """
+ if not self._enricher or not self._roster:
+ return
+ video_root = await self._roster.video_root(indexer.group_id)
+ if not video_root:
+ return
+ for entry in entries:
+ if entry.type != "video" or entry.id in self._enriched_attempted:
+ continue
+ if not _under_video_root(entry.path, video_root):
+ continue
+ file_path = entry_abs_path(indexer.roots, entry)
+ if not file_path or not file_path.exists():
+ continue
+ self._enriched_attempted.add(entry.id)
+
+ async def on_done(file_id: str, fields: dict, _indexer=indexer) -> None:
+ await self._on_enriched(_indexer, file_id, fields)
+
+ self._enricher.spawn(entry, file_path, on_done)
+
+ async def _enrich_video_root_now(self, group_id: str) -> None:
+ """
+ Videos app: sweep a group's existing index for enrichment right
+ after its video_root is set or changed (ops.set_video_root).
+
+ The ordinary path above only ever looks at entries new since the
+ last broadcast, so a folder that already had files sitting in it
+ before it became the video_root would otherwise never get enriched
+ at all — nothing else re-visits already-indexed entries once they
+ have been broadcast once.
+ """
+ indexer = self._state.get("indexers", {}).get(group_id)
+ if not indexer:
+ return
+ await self._enrich_new_video_entries(indexer, list(indexer.index.entries))
+
+ async def _reenrich_renamed_video_entries(
+ self, indexer: DirectoryIndexer, updates: list, previous: GroupIndex,
+ ) -> None:
+ """
+ Videos app: found live — a French-named episode file, renamed by
+ the operator to match its English-named siblings, kept showing as
+ its own separate poster-grid card (and its own row in Flat list)
+ indefinitely, because `_enriched_attempted` — there specifically to
+ stop enrichment's own field-fill from re-triggering itself forever
+ (see the caller) — also silently blocked the *new* filename from
+ ever being title-parsed at all. `entry.id in self._enriched_attempted`
+ is the same content, so simply discarding it here and re-running
+ the ordinary enrichment path is enough: a fresh ffprobe/thumbnail
+ for an unchanged file is redundant work, not a correctness issue,
+ and renames are rare enough that the redundancy is not worth a
+ separate "title-parse only" code path.
+ """
+ for entry in updates:
+ if entry.type != "video":
+ continue
+ old = previous.get_entry(entry.id)
+ if old is None or (old.name == entry.name and old.path == entry.path):
+ continue
+ self._enriched_attempted.discard(entry.id)
+ await self._enrich_new_video_entries(indexer, updates)
+
+ async def _on_enriched(self, indexer: DirectoryIndexer, file_id: str, fields: dict) -> None:
+ """
+ Merge enrichment fields into the live index and re-trigger a
+ broadcast so they reach connected clients as an INDEX_DELTA update
+ (GroupIndex.diff's `updates`, not `additions` — same id, new fields).
+
+ Builds a *new* IndexEntry via dataclasses.replace rather than
+ mutating the existing one in place: the diff mechanism compares
+ against a shallow snapshot of entry *references*, so an in-place
+ mutation would silently also change what "previous" looks like,
+ and the change would never show up as a diff (see group_index.py's
+ diff() docstring).
+ """
+ idx = indexer.index
+ entry = idx.get_entry(file_id)
+ if entry is None:
+ return # removed from the index while enrichment was in flight
+ idx.add_entry(replace(entry, **fields))
+ await self._on_index_change(indexer)
+
def _drop_group_sessions(self, group_id: str) -> None:
"""Close live sessions for a revoked group (H4)."""
if not self._webrtc or not group_id:
@@ -983,6 +1160,12 @@ class NodeDaemon:
if self._bundle_store:
await self._bundle_store.close()
+ if self._tmdb_client:
+ await self._tmdb_client.close()
+
+ if self._media_cache:
+ await self._media_cache.close()
+
if self._roster:
await self._roster.close()
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
new file mode 100644
index 0000000..784b2a3
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich.py
@@ -0,0 +1,166 @@
+"""
+Index-time enrichment for the Videos group app: technical probe (ffprobe),
+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_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
+the rest asynchronously and hands the result back via a callback.
+"""
+
+import asyncio
+import logging
+from pathlib import Path
+from typing import Awaitable, Callable
+
+import blake3
+
+from meshbay_common.protocol import IndexEntry
+from meshbay_node.indexer import title_parse
+from meshbay_node.indexer.indexer import MEDIA_EXTENSIONS
+from meshbay_node.media_cache import MediaCache
+from meshbay_node.media_probe import probe_video
+
+log = logging.getLogger(__name__)
+
+DEFAULT_MAX_CONCURRENT = 2
+PROBE_TIMEOUT_SECS = 30
+THUMB_TIMEOUT_SECS = 30
+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.
+MAX_SIBLINGS_CHECKED = 20
+
+
+def _season_from_ancestors(file_path: Path) -> int | None:
+ folder = file_path.parent
+ for _ in range(MAX_ANCESTOR_DEPTH):
+ if folder is None or folder == folder.parent:
+ break
+ season = title_parse.season_from_folder_name(folder.name)
+ if season is not None:
+ return season
+ folder = folder.parent
+ return 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).
+ """
+ try:
+ names = sorted(p.name for p in file_path.parent.iterdir() if p.is_file())
+ except OSError:
+ return None
+ checked = 0
+ for name in names:
+ if name == file_path.name:
+ continue
+ if Path(name).suffix.lower() not in MEDIA_EXTENSIONS["video"]:
+ continue
+ checked += 1
+ if checked > MAX_SIBLINGS_CHECKED:
+ break
+ parsed = title_parse.parse_episode_filename(name)
+ if parsed.display_title:
+ return parsed.display_title
+ return None
+
+
+async def _make_thumbnail(file_path: Path, duration: float | None) -> bytes | None:
+ """One ffmpeg frame grab at ~10% of duration (or 5s if unknown), scaled down."""
+ seek = max(0.0, (duration or 50.0) * 0.1)
+ proc = await asyncio.create_subprocess_exec(
+ "ffmpeg", "-v", "error", "-ss", str(seek), "-i", str(file_path),
+ "-frames:v", "1", "-vf", f"scale={THUMB_WIDTH}:-1",
+ "-f", "image2", "-c:v", "mjpeg", "pipe:1",
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
+ )
+ try:
+ stdout, _ = await asyncio.wait_for(proc.communicate(), THUMB_TIMEOUT_SECS)
+ except asyncio.TimeoutError:
+ proc.kill()
+ await proc.wait()
+ return None
+ return stdout or None
+
+
+class Enricher:
+ """Owns the node's bounded index-time enrichment pool."""
+
+ def __init__(self, media_cache: MediaCache, max_concurrent: int = DEFAULT_MAX_CONCURRENT):
+ self._media_cache = media_cache
+ self._sem = asyncio.Semaphore(max_concurrent)
+ self._tasks: set[asyncio.Task] = set()
+
+ def spawn(
+ self, entry: IndexEntry, file_path: Path,
+ on_done: Callable[[str, dict], Awaitable[None]],
+ ) -> asyncio.Task:
+ """
+ Fire-and-forget one file's enrichment. `on_done(file_id, fields)` is
+ awaited with the index fields to merge in once ready — never blocks
+ the caller (a scan or watchdog event). The reference this method
+ returns is what keeps the task alive; callers should hold it the
+ same way `WebRTCPeerSession._spawn` holds streaming tasks.
+ """
+ task = asyncio.ensure_future(self._run(entry, file_path, on_done))
+ self._tasks.add(task)
+
+ def _cleanup(t: asyncio.Task) -> None:
+ self._tasks.discard(t)
+ if not t.cancelled() and t.exception():
+ log.error("Enrichment failed for %s: %s", entry.id[:12], t.exception(),
+ exc_info=t.exception())
+ task.add_done_callback(_cleanup)
+ return task
+
+ async def _run(
+ self, entry: IndexEntry, file_path: Path,
+ on_done: Callable[[str, dict], Awaitable[None]],
+ ) -> None:
+ async with self._sem:
+ fields: dict = {}
+ duration: float | None = None
+ try:
+ _codec, duration, _has_audio, width, height = await asyncio.wait_for(
+ probe_video(str(file_path)), timeout=PROBE_TIMEOUT_SECS)
+ fields["duration"] = int(duration) if duration else None
+ fields["width"] = width
+ fields["height"] = height
+ except Exception as e:
+ log.warning("Probe failed for %s: %s", file_path, e)
+
+ ep = title_parse.parse_episode_filename(entry.name)
+ if ep.episode is not None:
+ title = ep.display_title or await asyncio.to_thread(
+ _title_from_siblings, file_path)
+ season = ep.season
+ if season is None:
+ season = await asyncio.to_thread(_season_from_ancestors, file_path)
+ fields["display_title"] = title or title_parse.naive_title(entry.name)
+ fields["season"] = season
+ fields["episode"] = ep.episode
+ else:
+ mv = title_parse.parse_movie_filename(entry.name)
+ fields["display_title"] = mv.display_title or mv.naive_title
+
+ try:
+ thumb = await _make_thumbnail(file_path, duration)
+ except Exception as e:
+ log.warning("Thumbnail generation failed for %s: %s", file_path, e)
+ thumb = None
+ if thumb:
+ thumb_hash = blake3.blake3(thumb).hexdigest()
+ await self._media_cache.put_thumb(thumb_hash, entry.id, thumb)
+ fields["thumb_hash"] = thumb_hash
+
+ await on_done(entry.id, fields)
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/group_index.py b/packages/meshbay-node/src/meshbay_node/indexer/group_index.py
index ec98667..1ce4e0a 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/group_index.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/group_index.py
@@ -78,6 +78,19 @@ class GroupIndex:
def get_entry(self, file_id: str) -> IndexEntry | None:
return self._entries.get(file_id)
+ def get_entry_by_path(self, path: str) -> IndexEntry | None:
+ """
+ Linear scan — entries are keyed by content id, not path, and nothing
+ before the Videos app needed to go the other way (a client always
+ already has the id from index_sync/index_delta). Fine for an
+ on-demand, per-tile lookup against a few thousand entries; revisit
+ if a future caller makes this hot.
+ """
+ for entry in self._entries.values():
+ if entry.path == path:
+ return entry
+ return None
+
@property
def entries(self) -> list[IndexEntry]:
return list(self._entries.values())
@@ -205,16 +218,34 @@ class GroupIndex:
# ── Delta ─────────────────────────────────────────────────────────────────
def diff(self, previous: "GroupIndex") -> IndexDelta:
- """Compute what changed since a previous version of this index."""
+ """
+ Compute what changed since a previous version of this index.
+
+ A shared id whose entry object now compares unequal (field-by-field,
+ via IndexEntry's dataclass-generated __eq__) is an update, not an
+ addition — the Videos app's async enrichment (duration, thumb_hash,
+ title, ...) replaces an existing entry's fields after the fact via
+ `add_entry`, which never introduces a new id. This only works
+ because that replacement always constructs a *new* IndexEntry object
+ (`dataclasses.replace`, never in-place attribute mutation) — mutating
+ the same object in place would also mutate `previous`'s copy, since
+ entries_by_id() is a shallow dict copy, and the two would always
+ compare equal.
+ """
prev_ids = set(previous._entries)
curr_ids = set(self._entries)
additions = [self._entries[i] for i in curr_ids - prev_ids]
deletions = list(prev_ids - curr_ids)
+ updates = [
+ self._entries[i] for i in curr_ids & prev_ids
+ if self._entries[i] != previous._entries[i]
+ ]
return IndexDelta(
base_version=previous.version,
version=self.version,
additions=additions,
deletions=deletions,
+ updates=updates,
)
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
new file mode 100644
index 0000000..ef522ad
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
@@ -0,0 +1,174 @@
+"""
+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
+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
+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
+directory listing; this module only parses strings it's handed.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+
+from guessit import guessit
+
+# French edition/release vocabulary guessit's (English-centric) edition list
+# doesn't recognize — left stuck to the title instead of stripped as a tag.
+_EDITION_PHRASES = (
+ r"version\s+longue",
+ r"version\s+int[ée]grale",
+ r"remasteris[ée]e?",
+ r"non[\s._-]*censur[ée]e?",
+ r"original\s+version",
+)
+_EDITION_RE = re.compile("|".join(_EDITION_PHRASES), re.IGNORECASE)
+
+# A season-like ancestor folder: the English/French words plus a number or
+# Roman numeral. Vocabulary is a plain tuple so a deployment can extend it
+# per locale without touching the regex-building logic.
+SEASON_WORDS = ("season", "saison")
+_SEASON_RE = re.compile(
+ r"(?:" + "|".join(SEASON_WORDS) + r")\s*([0-9]+|[ivxlc]+)\b",
+ re.IGNORECASE,
+)
+_SPECIALS_RE = re.compile(r"\b(?:bonus|extras?|specials?)\b", re.IGNORECASE)
+
+_ROMAN_NUMERALS = {
+ 2: "II", 3: "III", 4: "IV", 5: "V", 6: "VI",
+ 7: "VII", 8: "VIII", 9: "IX", 10: "X",
+}
+
+
+def _roman_to_int(s: str) -> int | None:
+ values = {"i": 1, "v": 5, "x": 10, "l": 50, "c": 100}
+ s = s.lower()
+ if not s or any(c not in values for c in s):
+ return None
+ total = 0
+ prev = 0
+ for c in reversed(s):
+ v = values[c]
+ total += v if v >= prev else -v
+ prev = v
+ return total or None
+
+
+def _strip_editions(title: str) -> str:
+ return re.sub(r"\s+", " ", _EDITION_RE.sub(" ", title)).strip()
+
+
+def naive_title(filename: str) -> str:
+ """
+ The mandated fallback (§3.6, §4.1): 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.
+ """
+ stem = filename.rsplit(".", 1)[0] if "." in filename else filename
+ stem = re.sub(r"[._-]+", " ", stem)
+ stem = re.sub(r"\(\s*(19|20)\d{2}\s*\)", " ", stem)
+ stem = _strip_editions(stem)
+ return re.sub(r"\s+", " ", stem).strip()
+
+
+def sequel_variants(title: str) -> list[str]:
+ """
+ A trailing sequel digit sometimes has no equivalent in the real TMDB
+ title, or the real title uses a Roman numeral instead (§3.3 row 4).
+ Returns extra candidates to try — empty if `title` has no trailing digit.
+ """
+ m = re.match(r"^(.*\S)\s+([2-9])$", title)
+ if not m:
+ return []
+ base, digit = m.group(1), int(m.group(2))
+ variants = [base]
+ roman = _ROMAN_NUMERALS.get(digit)
+ if roman:
+ variants.append(f"{base} {roman}")
+ return variants
+
+
+def season_from_folder_name(name: str) -> int | None:
+ """
+ §3.4: 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.
+ """
+ if _SPECIALS_RE.search(name):
+ return 0
+ m = _SEASON_RE.search(name)
+ if not m:
+ return None
+ token = m.group(1)
+ if token.isdigit():
+ return int(token)
+ return _roman_to_int(token)
+
+
+@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)
+ naive_title: str = "" # always available, fully punctuation-normalized fallback
+ year: int | None = None
+ season: int | None = None
+ episode: int | None = None
+ confidence: bool = False # True only when display_title is set and structurally corroborated
+
+
+def parse_movie_filename(filename: str) -> ParsedName:
+ """Parse a standalone movie filename."""
+ g = guessit(filename)
+ title = g.get("title")
+ title = str(title).strip() if title else None
+ if title:
+ title = _strip_editions(title)
+ alt = g.get("alternative_title")
+ alt = _strip_editions(str(alt).strip()) if alt else None
+ year = g.get("year")
+ nt = naive_title(filename)
+ confidence = bool(title) and len(title) >= 2 and year is not None
+ return ParsedName(
+ display_title=title or None, alt_title=alt, naive_title=nt,
+ year=year, confidence=confidence,
+ )
+
+
+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
+ indexer then supplies the show title from a representative sibling
+ filename in the same folder rather than the folder name itself (§3.4).
+ """
+ g = guessit(filename)
+ title = g.get("title")
+ title = str(title).strip() if title else None
+ if title:
+ title = _strip_editions(title)
+ season = g.get("season")
+ episode = g.get("episode")
+ # guessit returns a list when it finds more than one candidate (e.g. a
+ # multi-episode file); take the first as the representative one.
+ if isinstance(season, list):
+ season = season[0] if season else None
+ if isinstance(episode, list):
+ episode = episode[0] if episode else None
+ nt = naive_title(filename)
+ confidence = bool(title) and len(title) >= 2 and season is not None and episode is not None
+ return ParsedName(
+ display_title=title or None, naive_title=nt,
+ season=season, episode=episode, confidence=confidence,
+ )
diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py
new file mode 100644
index 0000000..6daae3f
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/media_cache.py
@@ -0,0 +1,188 @@
+"""
+MeshBay Node — TMDB metadata and thumbnail cache for the Videos group app.
+
+Node-wide (not per-group, `data_dir/media_cache.db`), same rationale as
+`tmdb_enabled`/`tmdb_api_token` living in `group_settings` under the
+`group_id=""` sentinel (docs/mediacenter.md §5.5): TMDB is one operator's
+budget and credential, and a thumbnail is the same bytes regardless of which
+group happens to share the file.
+
+Disposable and rebuildable, like the rest of the file index (§1, §2) — 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.
+"""
+
+import json
+import logging
+import time
+from pathlib import Path
+
+import aiosqlite
+
+log = logging.getLogger(__name__)
+
+_SCHEMA = """
+CREATE TABLE IF NOT EXISTS file_tmdb (
+ file_id TEXT PRIMARY KEY,
+ tmdb_id TEXT NOT NULL,
+ media_type TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS tmdb_meta (
+ tmdb_id TEXT NOT NULL,
+ media_type TEXT NOT NULL,
+ json TEXT NOT NULL,
+ fetched_at REAL NOT NULL,
+ PRIMARY KEY (tmdb_id, media_type)
+);
+CREATE TABLE IF NOT EXISTS thumbs (
+ thumb_hash TEXT PRIMARY KEY,
+ file_id TEXT NOT NULL,
+ jpeg BLOB NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_thumbs_file ON thumbs(file_id);
+CREATE TABLE IF NOT EXISTS season_meta (
+ tmdb_id TEXT NOT NULL,
+ season INTEGER NOT NULL,
+ json TEXT NOT NULL,
+ fetched_at REAL NOT NULL,
+ PRIMARY KEY (tmdb_id, season)
+);
+"""
+
+# 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).
+TMDB_META_TTL_SECS = 30 * 86400
+
+
+class MediaCache:
+ """Async SQLite cache for TMDB lookups and generated thumbnails."""
+
+ def __init__(self, db_path: Path):
+ self._db_path = db_path
+ self._db: aiosqlite.Connection | None = None
+
+ async def open(self) -> None:
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
+ self._db = await aiosqlite.connect(str(self._db_path))
+ await self._db.executescript(_SCHEMA)
+ await self._db.commit()
+
+ async def close(self) -> None:
+ if self._db:
+ await self._db.close()
+ self._db = None
+
+ # ── file -> tmdb id mapping ──────────────────────────────────────────────
+
+ async def get_file_tmdb(self, file_id: str) -> tuple[str, str] | None:
+ """Returns (tmdb_id, media_type), or None if this file was never resolved."""
+ async with self._db.execute(
+ "SELECT tmdb_id, media_type FROM file_tmdb WHERE file_id = ?",
+ (file_id,),
+ ) as cur:
+ row = await cur.fetchone()
+ return (row[0], row[1]) if row else None
+
+ async def set_file_tmdb(self, file_id: str, tmdb_id: str, media_type: str) -> None:
+ await self._db.execute(
+ "INSERT OR REPLACE INTO file_tmdb (file_id, tmdb_id, media_type) "
+ "VALUES (?, ?, ?)",
+ (file_id, tmdb_id, media_type),
+ )
+ await self._db.commit()
+
+ # ── tmdb id -> metadata json ─────────────────────────────────────────────
+
+ async def get_tmdb_meta(self, tmdb_id: str, media_type: str) -> dict | None:
+ """Returns None on a miss or on an entry older than TMDB_META_TTL_SECS."""
+ async with self._db.execute(
+ "SELECT json, fetched_at FROM tmdb_meta WHERE tmdb_id = ? AND media_type = ?",
+ (tmdb_id, media_type),
+ ) as cur:
+ row = await cur.fetchone()
+ if not row:
+ return None
+ raw_json, fetched_at = row
+ if time.time() - fetched_at > TMDB_META_TTL_SECS:
+ return None
+ return json.loads(raw_json)
+
+ async def set_tmdb_meta(self, tmdb_id: str, media_type: str, meta: dict) -> None:
+ await self._db.execute(
+ "INSERT OR REPLACE INTO tmdb_meta (tmdb_id, media_type, json, fetched_at) "
+ "VALUES (?, ?, ?, ?)",
+ (tmdb_id, media_type, json.dumps(meta), time.time()),
+ )
+ await self._db.commit()
+
+ # ── 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.
+
+ async def get_season_meta(self, tmdb_id: str, season: int) -> dict | None:
+ async with self._db.execute(
+ "SELECT json, fetched_at FROM season_meta WHERE tmdb_id = ? AND season = ?",
+ (tmdb_id, season),
+ ) as cur:
+ row = await cur.fetchone()
+ if not row:
+ return None
+ raw_json, fetched_at = row
+ if time.time() - fetched_at > TMDB_META_TTL_SECS:
+ return None
+ return json.loads(raw_json)
+
+ async def set_season_meta(self, tmdb_id: str, season: int, meta: dict) -> None:
+ await self._db.execute(
+ "INSERT OR REPLACE INTO season_meta (tmdb_id, season, json, fetched_at) "
+ "VALUES (?, ?, ?, ?)",
+ (tmdb_id, season, json.dumps(meta), time.time()),
+ )
+ await self._db.commit()
+
+ # ── thumbnails ────────────────────────────────────────────────────────────
+
+ async def get_thumb(self, thumb_hash: str) -> bytes | None:
+ async with self._db.execute(
+ "SELECT jpeg FROM thumbs WHERE thumb_hash = ?", (thumb_hash,),
+ ) as cur:
+ row = await cur.fetchone()
+ return bytes(row[0]) if row else None
+
+ async def get_thumb_hash_by_file_id(self, file_id: str) -> str | None:
+ """
+ A TMDB poster/backdrop is stored under a synthetic file_id
+ (`tmdb:{poster_path}`, stable across requests for the same image) —
+ this is how `_fetch_and_cache_poster` recognizes "already fetched"
+ without knowing the content hash up front (that's only known once
+ the bytes are downloaded).
+ """
+ async with self._db.execute(
+ "SELECT thumb_hash FROM thumbs WHERE file_id = ?", (file_id,),
+ ) as cur:
+ row = await cur.fetchone()
+ return row[0] if row else None
+
+ async def put_thumb(self, thumb_hash: str, file_id: str, jpeg: bytes) -> None:
+ await self._db.execute(
+ "INSERT OR REPLACE INTO thumbs (thumb_hash, file_id, jpeg) VALUES (?, ?, ?)",
+ (thumb_hash, file_id, jpeg),
+ )
+ await self._db.commit()
+
+ # ── pruning ───────────────────────────────────────────────────────────────
+
+ async def prune_file(self, file_id: str) -> None:
+ """
+ Called when a file leaves the index (deletion, unshared root). Removes
+ its thumbnail and its file->tmdb mapping. `tmdb_meta` rows are left
+ alone — they're keyed by tmdb_id, not file_id, and other files (other
+ episodes of the same show) may still reference the same entry.
+ """
+ await self._db.execute("DELETE FROM thumbs WHERE file_id = ?", (file_id,))
+ await self._db.execute("DELETE FROM file_tmdb WHERE file_id = ?", (file_id,))
+ await self._db.commit()
diff --git a/packages/meshbay-node/src/meshbay_node/media_probe.py b/packages/meshbay-node/src/meshbay_node/media_probe.py
new file mode 100644
index 0000000..8ad883d
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/media_probe.py
@@ -0,0 +1,70 @@
+"""
+ffprobe wrapper shared by stream-time codec detection (transport/webrtc_server.py)
+and index-time technical-field enrichment (indexer/enrich.py).
+
+Split out of webrtc_server.py so the indexer package (which webrtc_server.py
+already imports from) can call it too without a circular import.
+"""
+
+import asyncio
+import json
+
+_H264_PROFILES = {"Baseline": "42", "Main": "4d", "High": "64", "High 10": "6e"}
+
+
+async def probe_video(path: str) -> tuple[str | None, float, bool, int | None, int | None]:
+ """
+ Probe video file with ffprobe, return (MSE codec string, duration,
+ has_audio, width, height).
+
+ The audio half of the codec string is always "mp4a.40.2" (AAC-LC) or
+ absent — never the source's real audio codec — because the streaming
+ path always transcodes audio to AAC and never copies it: MSE in every
+ mainstream browser only decodes AAC/Opus, and a source codec outside
+ that (AC-3, E-AC-3, DTS, ...) is at best silently unplayable and at
+ worst, for E-AC-3 at least, makes ffmpeg itself refuse to write the
+ fragmented MP4 header ("Cannot write moov atom before EAC3 packets
+ parsed" — reproduced against a real 5.1 E-AC-3 WEB-DL). Video stays
+ whatever it actually is: it is always copied, never transcoded.
+
+ 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).
+ """
+ proc = await asyncio.create_subprocess_exec(
+ "ffprobe", "-v", "error",
+ "-show_entries", "stream=codec_name,profile,level,codec_type,width,height",
+ "-show_entries", "format=duration",
+ "-of", "json", path,
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, _ = await proc.communicate()
+ info = json.loads(stdout)
+ duration = float(info.get("format", {}).get("duration", 0))
+
+ v_codec = ""
+ has_audio = False
+ width: int | None = None
+ height: int | None = None
+ for s in info.get("streams", []):
+ if s.get("codec_type") == "video" and not v_codec:
+ cn = s.get("codec_name", "")
+ if cn == "h264":
+ p = _H264_PROFILES.get(s.get("profile", "High"), "64")
+ lvl = int(s.get("level", 40))
+ v_codec = f"avc1.{p}00{lvl:02x}"
+ elif cn == "hevc":
+ v_codec = "hev1.1.6.L93.B0"
+ elif cn == "vp9":
+ v_codec = "vp09.00.10.08"
+ elif cn == "av1":
+ v_codec = "av01.0.01M.08"
+ width = s.get("width")
+ height = s.get("height")
+ elif s.get("codec_type") == "audio":
+ has_audio = True
+
+ if not v_codec:
+ return None, duration, has_audio, width, height
+ codec = f"{v_codec},mp4a.40.2" if has_audio else v_codec
+ return codec, duration, has_audio, width, height
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index 154813d..6848e3a 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -731,6 +731,68 @@ async def set_enabled_apps(state: dict, group_id: str, apps: list[str]) -> dict:
return {"apps": apps, "group_id": group_id}
+# ── TMDB config (Videos app) ─────────────────────────────────────────────────
+
+async def set_tmdb_config(state: dict, enabled: bool, token: str | None = None,
+ language: str | None = None) -> dict:
+ """
+ Whether the node calls TMDB at all, whether it uses a custom API token
+ instead of the shipped default, and in what language it queries TMDB
+ (docs/mediacenter.md §5.5).
+
+ Node-wide (roster.py group_settings, group_id="") rather than per-group
+ like set_member_upload/set_enabled_apps: TMDB is one operator's budget,
+ one credential and one shared cache, not a per-group or per-viewer
+ concern. `token=""` explicitly clears a previously-set custom token
+ (reverts to the shipped default); `token=None` leaves whatever was
+ there unchanged. Same discipline for `language`.
+ """
+ roster = _roster(state)
+ await roster.set_tmdb_config(enabled, token, language, set_by=state.get("node_user_id", ""))
+ state["tmdb_enabled"] = enabled
+ # `token=None` means "leave whatever was there" (§ set_tmdb_config's own
+ # docstring) — so the customized flag only changes when a value (a real
+ # token, or "" to clear one) was actually given.
+ if token is not None:
+ state["tmdb_token_customized"] = bool(token)
+ if language is not None:
+ state["tmdb_language"] = language
+ log.info("TMDB config: enabled=%s custom_token=%s language=%s",
+ enabled, bool(token), language or state.get("tmdb_language", ""))
+ return {
+ "enabled": enabled,
+ "token_customized": state.get("tmdb_token_customized", False),
+ "language": state.get("tmdb_language", ""),
+ }
+
+
+async def set_video_root(state: dict, group_id: str, path: str) -> dict:
+ """
+ Which folder (possibly a subfolder of a shared root) is the Videos app's
+ entry point for this group. Same shape as set_enabled_apps: lives on the
+ node (roster.db), takes effect without a restart, signed by the operator.
+ `path=""` clears it — the Videos tab then asks for one to be chosen
+ before anything (including TMDB enrichment, docs/mediacenter.md §5.2)
+ runs, rather than defaulting to the whole shared index.
+
+ A non-empty path fires (never awaits) a sweep of whatever that folder
+ already contains: the ordinary per-change enrichment path only ever
+ looks at files new since the last broadcast, so anything already sitting
+ in a folder before it became the video_root would otherwise never be
+ picked up.
+ """
+ roster = _roster(state)
+ ctx = _group_ctx(state, group_id)
+ await roster.set_video_root(group_id, path, set_by=state.get("node_user_id", ""))
+ ctx["video_root"] = path
+ log.info("Videos root for group %s: %r", group_id[:8], path)
+ if path:
+ enrich_fn = state.get("enrich_video_root_fn")
+ if enrich_fn:
+ asyncio.ensure_future(enrich_fn(group_id))
+ return {"path": path, "group_id": group_id}
+
+
# ── Scan settings ────────────────────────────────────────────────────────────
async def set_scan_settings(state: dict, group_id: str, reconcile_interval_secs: float,
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index 6eb7651..701462f 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -599,6 +599,52 @@ class Roster:
json.dumps(sorted(apps)), set_by)
return apps
+ # TMDB is one operator's budget and credential, not a per-group concern
+ # (docs/mediacenter.md §5.5) — 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 "on, using
+ # the shipped default token" — the same "absent means the old behaviour"
+ # discipline member_upload/enabled_apps already follow.
+ SETTING_TMDB_ENABLED = "tmdb_enabled"
+ SETTING_TMDB_TOKEN = "tmdb_api_token"
+ # A TMDB language tag (e.g. "fr-FR") — one for the whole node, same
+ # reasoning as the token: one shared cache, not a per-viewer request.
+ # Unset means TMDB's own default (English) rather than this node
+ # guessing one.
+ SETTING_TMDB_LANGUAGE = "tmdb_language"
+ NODE_WIDE_GROUP_ID = ""
+
+ async def tmdb_config(self) -> tuple[bool, str | None, str | None]:
+ """Returns (enabled, custom_token_or_None, language_or_None)."""
+ enabled = (await self.get_setting(
+ self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_ENABLED, "1")) != "0"
+ token = await self.get_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_TOKEN)
+ language = await self.get_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_LANGUAGE)
+ return enabled, (token or None), (language or None)
+
+ async def set_tmdb_config(self, enabled: bool, token: str | None = None,
+ language: str | None = None, set_by: str = "") -> None:
+ await self.set_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_ENABLED,
+ "1" if enabled else "0", set_by)
+ if token is not None:
+ await self.set_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_TOKEN,
+ token, set_by)
+ if language is not None:
+ await self.set_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_TMDB_LANGUAGE,
+ language, set_by)
+
+ # Which folder is the Videos app's entry point for this group — per-group
+ # (unlike tmdb_config above), since different groups share different
+ # trees. Empty/unset means the whole group index, exactly as today.
+ SETTING_VIDEO_ROOT = "video_root"
+
+ async def video_root(self, group_id: str) -> str:
+ return await self.get_setting(group_id, self.SETTING_VIDEO_ROOT, "") or ""
+
+ async def set_video_root(self, group_id: str, path: str, set_by: str = "") -> str:
+ await self.set_setting(group_id, self.SETTING_VIDEO_ROOT, path or "", set_by)
+ return path or ""
+
# How often the indexer's reconciliation backstop runs, and how long it
# waits after the last change on a file before hashing it. Unset means
# the indexer's own defaults — an existing group's behaviour must not
diff --git a/packages/meshbay-node/src/meshbay_node/tmdb.py b/packages/meshbay-node/src/meshbay_node/tmdb.py
new file mode 100644
index 0000000..448a2b9
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/tmdb.py
@@ -0,0 +1,177 @@
+"""
+TMDB (themoviedb.org) client for the Videos group app.
+
+Called only by the node, never by a client (docs/mediacenter.md §2): the
+node holds the one credential and makes the one request per unique title,
+shared by every member. Token resolution order (§5.5):
+
+ 1. an operator-supplied token (roster.py group_settings, group_id="")
+ 2. the MESHBAY_TMDB_DEFAULT_TOKEN environment variable
+ 3. none — TMDB lookups are inert (callers get an empty result, never an
+ 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.
+
+Results also come back in whatever language the operator configured
+(roster.py's `tmdb_language`, e.g. "fr-FR") — one language for the whole
+node, same reasoning as the token: one shared cache, not a per-viewer
+request. Omitted entirely when unset, which lets TMDB fall back to its own
+default (English) rather than this client guessing one.
+"""
+
+import difflib
+import logging
+import os
+import re
+
+import httpx
+
+from meshbay_node.roster import Roster
+
+log = logging.getLogger(__name__)
+
+_BASE_URL = "https://api.themoviedb.org/3/"
+_IMAGE_BASE = "https://image.tmdb.org/t/p/w500"
+_TIMEOUT = 10.0
+_DEFAULT_TOKEN_ENV = "MESHBAY_TMDB_DEFAULT_TOKEN"
+
+
+def _normalize(s: str) -> str:
+ s = s.lower()
+ s = re.sub(r"[^a-z0-9àâäéèêëïîôöùûüçñ ]+", " ", s)
+ return re.sub(r"\s+", " ", s).strip()
+
+
+def _best_match(query_title: str, results: list[dict], keys: tuple[str, ...]) -> tuple[dict | None, float]:
+ """
+ Trusts TMDB's own ranking (§3.3's last row — a locally-recomputed
+ re-rank picked a coincidentally-closer-looking wrong show once): only
+ the top result is considered. The similarity ratio is returned purely
+ as a confidence signal for the caller's fallback decision, never used
+ to pick a different candidate.
+ """
+ if not results:
+ return None, 0.0
+ top = results[0]
+ qn = _normalize(query_title)
+ best_ratio = 0.0
+ for k in keys:
+ val = top.get(k)
+ if val:
+ best_ratio = max(best_ratio, difflib.SequenceMatcher(None, qn, _normalize(str(val))).ratio())
+ return top, best_ratio
+
+
+class TmdbClient:
+ """One instance per node, holding the resolved token and an httpx client."""
+
+ def __init__(self, roster: Roster | None = None,
+ transport: httpx.AsyncBaseTransport | None = None):
+ self._roster = roster
+ # `transport` is a test-only seam (httpx.MockTransport) — production
+ # callers never pass it, and httpx.AsyncClient defaults to real
+ # network I/O when it's None.
+ self._client = httpx.AsyncClient(timeout=_TIMEOUT, transport=transport)
+
+ async def close(self) -> None:
+ await self._client.aclose()
+
+ async def _resolve(self) -> tuple[bool, str | None, str | None]:
+ """Returns (enabled, token, language). token/language are None when unset."""
+ if self._roster is not None:
+ enabled, custom_token, language = await self._roster.tmdb_config()
+ else:
+ enabled, custom_token, language = True, None, None
+ token = custom_token or os.environ.get(_DEFAULT_TOKEN_ENV) or None
+ return enabled and bool(token), token, language
+
+ async def _get(self, path: str, params: dict) -> dict | None:
+ enabled, token, language = await self._resolve()
+ if not enabled:
+ return None
+ if language and "language" not in params:
+ params = {**params, "language": language}
+ try:
+ resp = await self._client.get(
+ _BASE_URL + path, params=params,
+ headers={"Authorization": f"Bearer {token}", "accept": "application/json"},
+ )
+ resp.raise_for_status()
+ return resp.json()
+ except httpx.HTTPError as e:
+ log.warning("TMDB request failed (%s): %s", path, e)
+ return None
+
+ @staticmethod
+ def poster_url(path: str | None) -> str | None:
+ return f"{_IMAGE_BASE}{path}" if path else None
+
+ async def fetch_image(self, url: str) -> bytes | None:
+ """Fetches a poster/backdrop image. Unauthenticated — image.tmdb.org needs no token."""
+ try:
+ resp = await self._client.get(url, timeout=_TIMEOUT)
+ resp.raise_for_status()
+ return resp.content
+ except httpx.HTTPError as e:
+ log.warning("TMDB image fetch failed (%s): %s", url, e)
+ return None
+
+ async def search_movie(self, title: str, year: int | None = None) -> tuple[dict | None, float]:
+ params = {"query": title, "include_adult": "false"}
+ if year:
+ params["year"] = year
+ data = await self._get("search/movie", params)
+ results = (data or {}).get("results", [])
+ return _best_match(title, results, ("title", "original_title"))
+
+ async def search_tv(self, title: str) -> tuple[dict | None, float]:
+ data = await self._get("search/tv", {"query": title})
+ results = (data or {}).get("results", [])
+ return _best_match(title, results, ("name", "original_name"))
+
+ async def search_movie_results(self, title: str) -> list[dict]:
+ """
+ The raw candidate list (capped), for an operator correcting a wrong
+ automatic match (§ webrtc_server.py's tmdb_search_req) — unlike
+ `search_movie`, this doesn't collapse to TMDB's own top result: a
+ human picks from several, so several is the point.
+ """
+ data = await self._get("search/movie", {"query": title, "include_adult": "false"})
+ return (data or {}).get("results", [])[:8]
+
+ async def search_tv_results(self, title: str) -> list[dict]:
+ data = await self._get("search/tv", {"query": title})
+ return (data or {}).get("results", [])[:8]
+
+ async def tv_season(self, tmdb_id: str | int, season: int,
+ language: str | None = None) -> dict | None:
+ """`language`, when given, overrides the configured one — same
+ English-fallback use as `movie_details`/`tv_details`."""
+ params = {"language": language} if language else {}
+ return await self._get(f"tv/{tmdb_id}/season/{season}", params)
+
+ async def movie_details(self, tmdb_id: str | int, language: str | None = None) -> dict | None:
+ """
+ Full details, not the search result: search/movie doesn't return
+ `runtime` or genre names (only `genre_ids`) at all.
+
+ `language`, when given, overrides the configured one — used for the
+ English fallback fetch (§ below): TMDB itself doesn't fall back
+ server-side for an untranslated field, it just returns "" for it,
+ the same gap the TMDB website itself papers over client-side.
+ """
+ params = {"language": language} if language else {}
+ return await self._get(f"movie/{tmdb_id}", params)
+
+ async def tv_details(self, tmdb_id: str | int, language: str | None = None) -> dict | None:
+ params = {"language": language} if language else {}
+ return await self._get(f"tv/{tmdb_id}", params)
+
+ async def movie_credits(self, tmdb_id: str | int) -> dict | None:
+ return await self._get(f"movie/{tmdb_id}/credits", {})
+
+ async def tv_credits(self, tmdb_id: str | int) -> dict | None:
+ return await self._get(f"tv/{tmdb_id}/credits", {})
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 aaf1ecf..f4d1dee 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -33,6 +33,7 @@ import time
from pathlib import Path
from typing import Any
+import blake3
import jwt
import msgpack
from aiortc import RTCPeerConnection, RTCSessionDescription, RTCDataChannel
@@ -64,6 +65,9 @@ from meshbay_common.adminop import (
OP_MEMBER_UPLOAD,
OP_APPS_ENABLED,
OP_SET_SCAN_SETTINGS,
+ OP_TMDB_CONFIG,
+ OP_VIDEO_ROOT,
+ OP_TMDB_OVERRIDE,
OP_ROOT_ADD,
OP_ROOT_REMOVE,
OP_GROUP_ATTACH,
@@ -84,10 +88,15 @@ from meshbay_common.join import (
join_transcript,
)
from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes
-from meshbay_common.protocol import MNP
+from meshbay_common.protocol import MNP, index_entry_wire
from meshbay_node.indexer import GroupIndex
from meshbay_node.indexer.indexer import DirectoryIndexer
from meshbay_node import ops
+# Re-imported under its original name: every call site and existing test in
+# this module still refers to it as `_probe_video`. The implementation lives
+# in media_probe.py so the indexer package (imported just above) can call it
+# too, for index-time enrichment, without a circular import.
+from meshbay_node.media_probe import probe_video as _probe_video
from meshbay_node.roots import (
RootSet, entry_abs_path, SAFE_UPLOAD_NAME, safe_subdir, _free_name,
)
@@ -163,59 +172,6 @@ STREAM_CREDIT_TIMEOUT = 120
# charged for a slot within this, rather than within the timeout.
STREAM_CREDIT_POLL = 3
-_H264_PROFILES = {"Baseline": "42", "Main": "4d", "High": "64", "High 10": "6e"}
-
-
-async def _probe_video(path: str) -> tuple[str | None, float, bool]:
- """
- Probe video file with ffprobe, return (MSE codec string, duration,
- has_audio).
-
- The audio half of the codec string is always "mp4a.40.2" (AAC-LC) or
- absent — never the source's real audio codec — because _stream_video_
- inner always transcodes audio to AAC and never copies it: MSE in every
- mainstream browser only decodes AAC/Opus, and a source codec outside
- that (AC-3, E-AC-3, DTS, ...) is at best silently unplayable and at
- worst, for E-AC-3 at least, makes ffmpeg itself refuse to write the
- fragmented MP4 header ("Cannot write moov atom before EAC3 packets
- parsed" — reproduced against a real 5.1 E-AC-3 WEB-DL). Video stays
- whatever it actually is: it is always copied, never transcoded.
- """
- import json as _json
- proc = await asyncio.create_subprocess_exec(
- "ffprobe", "-v", "error",
- "-show_entries", "stream=codec_name,profile,level,codec_type",
- "-show_entries", "format=duration",
- "-of", "json", path,
- stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
- )
- stdout, _ = await proc.communicate()
- info = _json.loads(stdout)
- duration = float(info.get("format", {}).get("duration", 0))
-
- v_codec = ""
- has_audio = False
- for s in info.get("streams", []):
- if s.get("codec_type") == "video" and not v_codec:
- cn = s.get("codec_name", "")
- if cn == "h264":
- p = _H264_PROFILES.get(s.get("profile", "High"), "64")
- lvl = int(s.get("level", 40))
- v_codec = f"avc1.{p}00{lvl:02x}"
- elif cn == "hevc":
- v_codec = "hev1.1.6.L93.B0"
- elif cn == "vp9":
- v_codec = "vp09.00.10.08"
- elif cn == "av1":
- v_codec = "av01.0.01M.08"
- elif s.get("codec_type") == "audio":
- has_audio = True
-
- if not v_codec:
- return None, duration, has_audio
- codec = f"{v_codec},mp4a.40.2" if has_audio else v_codec
- return codec, duration, has_audio
-
def _pack(obj: dict) -> bytes:
data = msgpack.packb(obj, use_bin_type=True)
@@ -417,6 +373,18 @@ class WebRTCPeerSession:
self._do_apps_enabled(msg)
elif mtype == MNP.SET_SCAN_SETTINGS:
self._do_set_scan_settings(msg)
+ elif mtype == MNP.TMDB_CONFIG:
+ self._do_tmdb_config(msg)
+ elif mtype == MNP.VIDEO_ROOT:
+ self._do_video_root(msg)
+ elif mtype == MNP.MEDIA_META_REQ:
+ self._spawn(self._do_media_meta_request(msg))
+ elif mtype == MNP.SEASON_META_REQ:
+ self._spawn(self._do_season_meta_request(msg))
+ elif mtype == MNP.TMDB_SEARCH_REQ:
+ self._spawn(self._do_tmdb_search_request(msg))
+ elif mtype == MNP.TMDB_OVERRIDE:
+ self._do_tmdb_override(msg)
elif mtype == MNP.MEMBER_UNPIN:
self._do_member_unpin(msg)
elif mtype == MNP.GEK_ROTATE:
@@ -667,6 +635,20 @@ class WebRTCPeerSession:
# setting (or one whose context has not loaded it yet) hides
# nothing.
"enabled_apps": list(self._group_ctx().get("enabled_apps") or []),
+ # Which folder the Videos app treats as its entry point for
+ # this group — "" means the whole group index.
+ "video_root": self._group_ctx().get("video_root") or "",
+ # Node-wide (not per-group), same "read once, kept current by
+ # the signed op" shape — surfaced here rather than only via
+ # tmdb_config_ack so a client that connects after the operator
+ # already configured it does not have to wait for a live change
+ # to find out (docs/mediacenter.md §5.5).
+ "tmdb_enabled": bool(
+ self._ctx.get("daemon_state", {}).get("tmdb_enabled", True)),
+ "tmdb_token_customized": bool(
+ self._ctx.get("daemon_state", {}).get("tmdb_token_customized", False)),
+ "tmdb_language": str(
+ self._ctx.get("daemon_state", {}).get("tmdb_language") or ""),
# So a client that connects mid-scan shows the indexing state
# immediately, instead of waiting for the next periodic
# INDEX_PROGRESS push. Never a path or filename — see
@@ -1623,10 +1605,13 @@ class WebRTCPeerSession:
except Exception:
pass
- # Every "application" a group can show — Chat and Files today. Videos,
- # Music, Photos join this set (and apps.js's registry, client-side) when
- # they land; nothing else about this handler changes.
- ALLOWED_APPS = frozenset({"chat", "files"})
+ # Every "application" a group can show. Music, Photos join this set (and
+ # apps.js's registry, client-side) when they land; nothing else about
+ # this handler changes. DEFAULT_APPS (roster.py) deliberately does not
+ # include "video" — it is the first app with outbound third-party
+ # network calls (once TMDB is on), so an operator opts a group in
+ # explicitly rather than getting it for free (docs/mediacenter.md §5.6).
+ ALLOWED_APPS = frozenset({"chat", "files", "video"})
def _do_apps_enabled(self, msg: dict) -> None:
"""
@@ -1677,6 +1662,125 @@ class WebRTCPeerSession:
except Exception:
pass
+ def _do_tmdb_config(self, msg: dict) -> None:
+ """
+ Turn TMDB lookups on/off node-wide, optionally set (or clear) a
+ custom API token, and optionally set the language TMDB is queried
+ in (e.g. "fr-FR") — one for the whole node, same reasoning as the
+ token: one shared cache, not a per-viewer request. Signed like the
+ rest: this turns on outbound third-party network traffic the node
+ did not have before the Videos app (docs/mediacenter.md §5.5, §8)
+ — an unsigned toggle would let any member turn on egress the
+ operator never agreed to.
+ """
+ enabled = msg.get("enabled")
+ if not isinstance(enabled, bool):
+ self._send({"type": "error", "detail": "Missing or invalid 'enabled'"})
+ return
+ token = msg.get("token")
+ if token is not None and not isinstance(token, str):
+ self._send({"type": "error", "detail": "Invalid 'token'"})
+ return
+ language = msg.get("language")
+ if language is not None and not isinstance(language, str):
+ self._send({"type": "error", "detail": "Invalid 'language'"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ # The subject is the signed, audited, human-shown string — it must
+ # never contain the token itself (it would end up in the audit log
+ # in plaintext). The actual token travels only in `payload`, which
+ # is node-side context, never re-sent or re-verified from the wire.
+ # The language is not a secret, so it travels in the subject itself.
+ subject = (f"enabled={enabled},custom_token={'yes' if token else 'no'},"
+ f"language={language or 'default'}")
+ self._issue_admin_challenge(
+ OP_TMDB_CONFIG, subject,
+ payload={"enabled": enabled, "token": token, "language": language},
+ group_id="")
+
+ async def _admin_exec_tmdb_config(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"tmdb_config:{pending['subject']}")
+ return
+ p = pending.get("payload") or {}
+ try:
+ result = await self._run_op(
+ ops.set_tmdb_config, p.get("enabled", True), p.get("token"), p.get("language"))
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("tmdb_config", pending["subject"])
+
+ # Node-wide setting: every connected peer in every group is told, not
+ # just this group's peers (unlike apps_enabled/member_upload).
+ notice = {
+ "type": MNP.TMDB_CONFIG_ACK, "v": MNP_VERSION,
+ "enabled": result["enabled"], "token_customized": result["token_customized"],
+ "language": result["language"],
+ }
+ for gctx in self._ctx.get("groups", {}).values():
+ for session in list(gctx.get("_peers", {}).values()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
+ def _do_video_root(self, msg: dict) -> None:
+ """
+ Which folder (possibly a subfolder of a shared root) the Videos app
+ treats as its entry point for this group. Signed like apps_enabled:
+ it decides what every member's Videos tab shows.
+
+ An empty path is always accepted (it means "the whole group index",
+ today's behaviour). A non-empty path must resolve to a real,
+ currently-readable directory — validated against the group's own
+ roots the same way directory creation/deletion already is, so a
+ stale or mistyped path is refused before a signature is even asked
+ for.
+ """
+ path = msg.get("path")
+ if not isinstance(path, str):
+ self._send({"type": "error", "detail": "Missing or invalid 'path'"})
+ return
+ path = path.strip("/")
+ if path:
+ ctx = self._group_ctx()
+ resolved = ctx["roots"].resolve(path) if ctx.get("roots") else None
+ if not resolved or not resolved.is_dir():
+ self._send({"type": "error", "detail": "Not a directory in this group"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ self._issue_admin_challenge(OP_VIDEO_ROOT, path)
+
+ async def _admin_exec_video_root(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ path = pending["subject"]
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"video_root:{path}")
+ return
+ try:
+ await self._run_op(ops.set_video_root, self._group_id or "", path)
+ except ops.OpError as e:
+ self._send({"type": "error", "detail": e.message})
+ return
+ self._audit("video_root", path)
+
+ notice = {"type": MNP.VIDEO_ROOT_ACK, "v": MNP_VERSION, "path": path}
+ for uid, session in list(self._peer_registry().items()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
# Reconcile's backstop and the watchdog debounce (indexer.py
# DirectoryIndexer) — how hard the node works on the operator's own
# disk, not a member-facing permission. Signed for the same reason as
@@ -2138,14 +2242,7 @@ class WebRTCPeerSession:
def _do_index_sync(self) -> None:
ctx = self._group_ctx()
idx = ctx["index"]
- entries = [
- {
- "id": e.id, "name": e.name, "path": e.path,
- "size": e.size, "type": e.type, "added_at": e.added_at,
- "uploader_id": e.uploader_id,
- }
- for e in idx.entries
- ]
+ entries = [index_entry_wire(e) for e in idx.entries]
self._send({
"type": MNP.INDEX_SYNC,
"v": MNP_VERSION,
@@ -2189,12 +2286,38 @@ class WebRTCPeerSession:
continue
return sorted(out)[:2000]
+ async def _try_serve_thumbnail(
+ self, thumb_hash: str, chunk_index: int, gek: bytes | None,
+ ) -> dict | None:
+ """
+ docs/mediacenter.md §5.3: 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. Always
+ a single chunk in practice (a thumbnail-sized JPEG never approaches
+ CHUNK_SIZE) — a request for any chunk beyond 0 is just a miss.
+ """
+ media_cache = self._ctx.get("media_cache")
+ if media_cache is None or chunk_index != 0:
+ return None
+ jpeg = await media_cache.get_thumb(thumb_hash)
+ if jpeg is None:
+ return None
+ return _encrypt_chunk_bytes(
+ self._ctx["sk_node"], gek, jpeg, 0,
+ bytes.fromhex(thumb_hash), thumb_hash,
+ )
+
async def _do_file_request(self, msg: dict) -> None:
ctx = self._group_ctx()
file_id = msg["file_id"]
chunk_index = msg["chunk_index"]
entry = ctx["index"].get_entry(file_id)
if not entry:
+ thumb = await self._try_serve_thumbnail(file_id, chunk_index, ctx.get("gek"))
+ if thumb is not None:
+ log.debug("file_req file_id=%s chunk=%s: served as thumbnail", file_id[:16], chunk_index)
+ self._send(thumb)
+ return
log.warning("File not found: %s", file_id[:16])
self._send({"type": "error", "detail": "File not found"})
return
@@ -2235,6 +2358,344 @@ class WebRTCPeerSession:
if chunk_index == 0:
self._audit("file_download", entry.name)
+ @staticmethod
+ 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
+ returns the hash a client then fetches via the normal file_req/
+ chunk path (§5.3) — no client ever contacts image.tmdb.org directly.
+
+ Checked by the synthetic `tmdb:{poster_path}` id *before* touching
+ the network: without this, every `media_meta_req` for an
+ already-cached file re-downloaded the same poster from TMDB (found
+ live — a poster grid re-fetched both a show's poster and backdrop
+ from TMDB on every single visit, real added latency and needless
+ outbound traffic for an image that never changes).
+ """
+ if not poster_path:
+ return None
+ synthetic_id = f"tmdb:{poster_path}"
+ cached_hash = await media_cache.get_thumb_hash_by_file_id(synthetic_id)
+ if cached_hash is not None:
+ return cached_hash
+ content = await tmdb_client.fetch_image(tmdb_client.poster_url(poster_path))
+ if content is None:
+ return None
+ thumb_hash = blake3.blake3(content).hexdigest()
+ await media_cache.put_thumb(thumb_hash, synthetic_id, content)
+ return thumb_hash
+
+ async def _do_media_meta_request(self, msg: dict) -> None:
+ """
+ docs/mediacenter.md §5.4: TMDB metadata for one path, resolved from
+ the group's index (root+relpath the client already knows from
+ index_sync/index_delta — never a raw filesystem path off the wire).
+ """
+ path = msg.get("path")
+ log.debug("media_meta_req path=%r", path)
+ if not isinstance(path, str) or not path:
+ self._send({"type": "error", "detail": "Missing path"})
+ return
+ ctx = self._group_ctx()
+ entry = ctx["index"].get_entry_by_path(path)
+ if not entry:
+ self._send({"type": "error", "detail": "File not found"})
+ return
+
+ media_cache = self._ctx.get("media_cache")
+ tmdb_client = self._ctx.get("tmdb_client")
+ if media_cache is None or tmdb_client is None:
+ self._send({"type": MNP.MEDIA_META_RESP, "v": MNP_VERSION,
+ "path": path, "confidence": 0})
+ return
+
+ is_show = entry.season is not None and entry.episode is not None
+ media_type = "tv" if is_show else "movie"
+
+ cached = await media_cache.get_file_tmdb(entry.id)
+ meta = None
+ tmdb_id = None
+ if cached is not None:
+ tmdb_id, media_type = cached
+ meta = await media_cache.get_tmdb_meta(tmdb_id, media_type)
+
+ if meta is None:
+ result, ratio = await self._tmdb_search(tmdb_client, entry, is_show)
+ if result is None or ratio < 0.6:
+ self._send({"type": MNP.MEDIA_META_RESP, "v": MNP_VERSION,
+ "path": path, "confidence": 0})
+ return
+ tmdb_id = str(result["id"])
+ meta = await self._tmdb_build_meta(tmdb_client, tmdb_id, media_type, result)
+ await media_cache.set_file_tmdb(entry.id, tmdb_id, media_type)
+ await media_cache.set_tmdb_meta(tmdb_id, media_type, meta)
+
+ poster_thumb_hash = await self._fetch_and_cache_poster(
+ media_cache, tmdb_client, meta.get("poster_path"))
+ backdrop_thumb_hash = await self._fetch_and_cache_poster(
+ media_cache, tmdb_client, meta.get("backdrop_path"))
+ log.debug("media_meta_req path=%r: replying tmdb_id=%s poster=%s backdrop=%s",
+ path, tmdb_id, poster_thumb_hash, backdrop_thumb_hash)
+
+ resp = {
+ "type": MNP.MEDIA_META_RESP, "v": MNP_VERSION, "path": path,
+ "tmdb_id": tmdb_id, "title": meta.get("title"),
+ "original_title": meta.get("original_title"),
+ "overview": meta.get("overview"),
+ "poster_thumb_hash": poster_thumb_hash,
+ "backdrop_thumb_hash": backdrop_thumb_hash,
+ "release_date": meta.get("release_date"),
+ "first_air_date": meta.get("first_air_date"),
+ "genres": meta.get("genres", []),
+ "vote_average": meta.get("vote_average"),
+ "runtime": meta.get("runtime"),
+ "cast": meta.get("cast", []),
+ "director": meta.get("director"),
+ "confidence": meta.get("confidence", 1.0),
+ }
+ if is_show:
+ resp["season"] = entry.season
+ resp["episode"] = entry.episode
+ self._send(resp)
+
+ async def _do_season_meta_request(self, msg: dict) -> None:
+ """
+ Per-season TMDB overview/poster/air_date for a multi-season show —
+ found live: `media_meta_resp`'s one static show-level overview does
+ not necessarily describe every season alike (a season-3-specific
+ promotional summary applied to all three seasons of a show).
+ `tmdb_id` is whatever the client's own prior `media_meta_resp`
+ already resolved — never re-derived from a path here, so this
+ never re-runs a TMDB search of its own.
+ """
+ tmdb_id = msg.get("tmdb_id")
+ season = msg.get("season")
+ if not isinstance(tmdb_id, str) or not tmdb_id or not isinstance(season, int):
+ self._send({"type": "error", "detail": "Missing tmdb_id or season"})
+ return
+ media_cache = self._ctx.get("media_cache")
+ tmdb_client = self._ctx.get("tmdb_client")
+ if media_cache is None or tmdb_client is None:
+ self._send({"type": MNP.SEASON_META_RESP, "v": MNP_VERSION,
+ "tmdb_id": tmdb_id, "season": season, "confidence": 0})
+ return
+
+ details = await media_cache.get_season_meta(tmdb_id, season)
+ if details is None:
+ fetched = await tmdb_client.tv_season(tmdb_id, season)
+ if fetched is None:
+ self._send({"type": MNP.SEASON_META_RESP, "v": MNP_VERSION,
+ "tmdb_id": tmdb_id, "season": season, "confidence": 0})
+ return
+ # Same per-field English fallback as _tmdb_build_meta: TMDB
+ # returns "" for an untranslated field rather than falling back
+ # itself.
+ if not fetched.get("overview"):
+ fallback = await tmdb_client.tv_season(tmdb_id, season, language="en-US") or {}
+ fetched = {**fallback, **{k: v for k, v in fetched.items() if v not in (None, "", [])}}
+ await media_cache.set_season_meta(tmdb_id, season, fetched)
+ details = fetched
+
+ poster_thumb_hash = await self._fetch_and_cache_poster(
+ media_cache, tmdb_client, details.get("poster_path"))
+ self._send({
+ "type": MNP.SEASON_META_RESP, "v": MNP_VERSION,
+ "tmdb_id": tmdb_id, "season": season, "confidence": 1.0,
+ "name": details.get("name"),
+ "overview": details.get("overview"),
+ "air_date": details.get("air_date"),
+ "poster_thumb_hash": poster_thumb_hash,
+ })
+
+ 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
+ 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
+ behalf. Only `tmdb_override` actually changes what everyone sees.
+ """
+ query = msg.get("query")
+ media_type = msg.get("media_type")
+ if not isinstance(query, str) or not query.strip() or media_type not in ("movie", "tv"):
+ self._send({"type": "error", "detail": "Missing query or media_type"})
+ return
+ media_cache = self._ctx.get("media_cache")
+ tmdb_client = self._ctx.get("tmdb_client")
+ if media_cache is None or tmdb_client is None:
+ self._send({"type": MNP.TMDB_SEARCH_RESP, "v": MNP_VERSION,
+ "query": query, "media_type": media_type, "results": []})
+ return
+
+ raw = (await tmdb_client.search_movie_results(query) if media_type == "movie"
+ else await tmdb_client.search_tv_results(query))
+ results = []
+ for r in raw:
+ poster_thumb_hash = await self._fetch_and_cache_poster(
+ media_cache, tmdb_client, r.get("poster_path"))
+ results.append({
+ "tmdb_id": str(r.get("id")),
+ "title": r.get("title") or r.get("name"),
+ "year": (r.get("release_date") or r.get("first_air_date") or "")[:4],
+ "poster_thumb_hash": poster_thumb_hash,
+ })
+ # media_type echoed back, not just query: a client can fire a "war"
+ # tv search and a "war" movie search close together, and without it
+ # the two responses are indistinguishable for keyed matching
+ # (transport.js's tmdb_search_resp handler).
+ self._send({"type": MNP.TMDB_SEARCH_RESP, "v": MNP_VERSION,
+ "query": query, "media_type": media_type, "results": results})
+
+ def _do_tmdb_override(self, msg: dict) -> None:
+ """
+ An operator correcting a wrong automatic TMDB match. Signed like
+ video_root/tmdb_config: it replaces what every member sees for a
+ show/movie, node-wide (media_cache is shared, not per-viewer).
+
+ Applied to every entry sharing the representative file's
+ display_title — the same grouping the poster grid itself uses
+ (§3.4/§V6) — not just the one file the operator happened to be
+ looking at, so the correction actually sticks regardless of which
+ episode a future render picks as representative.
+ """
+ path = msg.get("path")
+ tmdb_id = msg.get("tmdb_id")
+ media_type = msg.get("media_type")
+ if not isinstance(path, str) or not path:
+ self._send({"type": "error", "detail": "Missing path"})
+ return
+ if not isinstance(tmdb_id, str) or not tmdb_id or media_type not in ("movie", "tv"):
+ self._send({"type": "error", "detail": "Missing tmdb_id or media_type"})
+ return
+ ctx = self._group_ctx()
+ entry = ctx["index"].get_entry_by_path(path)
+ if not entry:
+ self._send({"type": "error", "detail": "File not found"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ subject = f"path={path},tmdb_id={tmdb_id},media_type={media_type}"
+ self._issue_admin_challenge(OP_TMDB_OVERRIDE, subject)
+
+ async def _admin_exec_tmdb_override(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ subject = pending["subject"]
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"tmdb_override:{subject}")
+ return
+ fields = dict(part.split("=", 1) for part in subject.split(","))
+ path, tmdb_id, media_type = fields["path"], fields["tmdb_id"], fields["media_type"]
+
+ ctx = self._group_ctx()
+ entry = ctx["index"].get_entry_by_path(path)
+ media_cache = self._ctx.get("media_cache")
+ if entry is None or media_cache is None:
+ self._send({"type": "error", "detail": "File or media cache not available"})
+ return
+ target_title = entry.display_title or entry.name
+ matched = [e for e in ctx["index"].entries
+ if e.type == "video" and (e.display_title or e.name) == target_title]
+ for e in matched:
+ await media_cache.set_file_tmdb(e.id, tmdb_id, media_type)
+ self._audit("tmdb_override", subject)
+
+ notice = {"type": MNP.TMDB_OVERRIDE_ACK, "v": MNP_VERSION, "path": path,
+ "tmdb_id": tmdb_id, "media_type": media_type}
+ for uid, session in list(self._peer_registry().items()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
+ async def _tmdb_search(self, tmdb_client, entry, is_show: bool):
+ """
+ §3.3's retry ladder: the parsed title first, then a couple of
+ generic, non-per-title fallbacks — never re-ranking TMDB's own
+ top result locally (§3.3's last row).
+ """
+ from meshbay_node.indexer import title_parse
+
+ if is_show:
+ title = entry.display_title or title_parse.naive_title(entry.name)
+ result, ratio = await tmdb_client.search_tv(title)
+ if result is None or ratio < 0.6:
+ naive = title_parse.naive_title(entry.name)
+ if naive != title:
+ result, ratio = await tmdb_client.search_tv(naive)
+ return result, ratio
+
+ parsed = title_parse.parse_movie_filename(entry.name)
+ title = entry.display_title or parsed.display_title or parsed.naive_title
+ result, ratio = await tmdb_client.search_movie(title, parsed.year)
+ if result is not None and ratio >= 0.6:
+ return result, ratio
+ for candidate in filter(None, [parsed.alt_title, parsed.naive_title,
+ *title_parse.sequel_variants(title)]):
+ if candidate == title:
+ continue
+ result2, ratio2 = await tmdb_client.search_movie(candidate, parsed.year)
+ if result2 is not None and ratio2 > ratio:
+ result, ratio = result2, ratio2
+ if ratio >= 0.6:
+ break
+ return result, ratio
+
+ @staticmethod
+ async def _tmdb_build_meta(tmdb_client, tmdb_id: str, media_type: str, result: dict) -> dict:
+ """
+ `result` (the search hit) only carries `genre_ids` and no `runtime`
+ at all — the full details endpoint is the actual source for those,
+ falling back to the search result for anything details somehow
+ lacks (never expected in practice, just avoids a KeyError-shaped
+ surprise if TMDB's response ever varies).
+ """
+ details = (await tmdb_client.tv_details(tmdb_id) if media_type == "tv"
+ else await tmdb_client.movie_details(tmdb_id)) or result
+ # TMDB doesn't fall back server-side for a field with no translation
+ # in the configured language — it returns "" (or an empty list) for
+ # it, not the English text (confirmed live: a French query left
+ # `overview` empty for a title TMDB has no French translation for).
+ # The TMDB website covers exactly this gap client-side, by falling
+ # back to English per field rather than discarding an otherwise-good
+ # localized response over one empty one — mirrored here the same
+ # way, at field granularity, not by abandoning the whole response.
+ if not details.get("overview") or not details.get("poster_path") or not details.get("genres"):
+ fallback = (await tmdb_client.tv_details(tmdb_id, language="en-US") if media_type == "tv"
+ else await tmdb_client.movie_details(tmdb_id, language="en-US")) or {}
+ details = {**fallback, **{k: v for k, v in details.items() if v not in (None, "", [])}}
+ credits = (await tmdb_client.tv_credits(tmdb_id) if media_type == "tv"
+ else await tmdb_client.movie_credits(tmdb_id))
+ cast = [{"name": c.get("name"), "character": c.get("character")}
+ for c in (credits or {}).get("cast", [])[:10]]
+ director = None
+ if media_type == "movie":
+ director = next(
+ (c.get("name") for c in (credits or {}).get("crew", [])
+ if c.get("job") == "Director"), None)
+ runtime = details.get("runtime")
+ if runtime is None and media_type == "tv":
+ episode_run_times = details.get("episode_run_time") or []
+ runtime = episode_run_times[0] if episode_run_times else None
+ return {
+ "title": details.get("title") or details.get("name"),
+ "original_title": details.get("original_title") or details.get("original_name"),
+ "overview": details.get("overview"),
+ "poster_path": details.get("poster_path"),
+ "backdrop_path": details.get("backdrop_path"),
+ "release_date": details.get("release_date"),
+ "first_air_date": details.get("first_air_date"),
+ "genres": [g.get("name") for g in details.get("genres", []) if g.get("name")],
+ "vote_average": details.get("vote_average"),
+ "runtime": runtime,
+ "cast": cast,
+ "director": director,
+ }
+
def _do_stream_segment(self, msg: dict) -> None:
self._spawn(self._do_stream_segment_async(msg))
@@ -2738,6 +3199,15 @@ class WebRTCPeerSession:
elif pending["op"] == OP_SET_SCAN_SETTINGS:
self._spawn(
self._admin_exec_set_scan_settings(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_TMDB_CONFIG:
+ self._spawn(
+ self._admin_exec_tmdb_config(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_VIDEO_ROOT:
+ self._spawn(
+ self._admin_exec_video_root(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_TMDB_OVERRIDE:
+ self._spawn(
+ self._admin_exec_tmdb_override(pending, transcript, sig_bytes))
elif pending["op"] == OP_ROOT_ADD:
self._spawn(
self._admin_exec_root_add(pending, transcript, sig_bytes))
@@ -3009,7 +3479,7 @@ class WebRTCPeerSession:
file_hash = bytes.fromhex(entry.id)
try:
- codec_str, duration, has_audio = await _probe_video(str(file_path))
+ codec_str, duration, has_audio, _width, _height = await _probe_video(str(file_path))
except Exception as e:
self._send({"type": "error", "detail": f"Probe failed: {e}"})
return
@@ -3220,18 +3690,14 @@ class WebRTCPeerSession:
await self._pc.close()
-def _read_and_encrypt(
+def _encrypt_chunk_bytes(
sk_node: Ed25519PrivateKey,
gek: bytes,
- file_path: Path,
+ plaintext: bytes,
chunk_index: int,
file_hash: bytes,
file_id: str = "",
) -> dict:
- with open(file_path, "rb") as f:
- f.seek(chunk_index * CHUNK_SIZE)
- plaintext = f.read(CHUNK_SIZE)
-
ckey = chunk_key_aes(gek, file_hash, chunk_index)
nonce, ct = encrypt_chunk_aes(ckey, plaintext)
@@ -3249,6 +3715,20 @@ def _read_and_encrypt(
}
+def _read_and_encrypt(
+ sk_node: Ed25519PrivateKey,
+ gek: bytes,
+ file_path: Path,
+ chunk_index: int,
+ file_hash: bytes,
+ file_id: str = "",
+) -> dict:
+ with open(file_path, "rb") as f:
+ f.seek(chunk_index * CHUNK_SIZE)
+ plaintext = f.read(CHUNK_SIZE)
+ return _encrypt_chunk_bytes(sk_node, gek, plaintext, chunk_index, file_hash, file_id)
+
+
class WebRTCTransport:
"""
Manages WebRTC peer connections for browser clients.
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index d5b3f94..18764e8 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -20,7 +20,7 @@ import time
from html import escape
from pathlib import Path
-from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
+from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Query
from fastapi.responses import HTMLResponse, JSONResponse
from meshbay_node import __version__
@@ -361,6 +361,20 @@ def create_ui_app(state: dict) -> FastAPI:
state, group_id, bool(payload.get("allowed", False)),
))
+ # ── Enabled apps (operator only, localhost) ────────────────────────────
+ #
+ # Same loopback shape as member-upload: the Create Group wizard sets this
+ # once, right after creating the group and before the (potentially long)
+ # initial scan, so an operator narrowing this down to just Files+Videos
+ # never briefly has Chat live for other members to notice.
+
+ @app.put("/api/groups/{group_id}/apps")
+ async def set_enabled_apps(group_id: str, payload: dict):
+ apps = payload.get("apps")
+ if not isinstance(apps, list) or not apps:
+ raise HTTPException(400, "apps must be a non-empty list")
+ return await _op(lambda: ops.set_enabled_apps(state, group_id, apps))
+
# ── Scan settings (operator only, localhost) ──────────────────────────
@app.put("/api/groups/{group_id}/scan-settings")
diff --git a/packages/meshbay-node/tests/test_enrich.py b/packages/meshbay-node/tests/test_enrich.py
new file mode 100644
index 0000000..cff4d50
--- /dev/null
+++ b/packages/meshbay-node/tests/test_enrich.py
@@ -0,0 +1,130 @@
+"""Tests for indexer/enrich.py — season/title corroboration and the end-to-end pool."""
+
+import asyncio
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from meshbay_common.protocol import IndexEntry
+from meshbay_node.indexer.enrich import Enricher, _season_from_ancestors, _title_from_siblings
+from meshbay_node.media_cache import MediaCache
+
+_HAVE_FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe")
+
+
+# ── pure helpers, no ffmpeg needed ───────────────────────────────────────────
+
+def test_season_from_ancestors_finds_season_folder(tmp_path):
+ folder = tmp_path / "Some Show" / "Season 2"
+ folder.mkdir(parents=True)
+ ep = folder / "01 - Episode Title.mkv"
+ ep.touch()
+
+ assert _season_from_ancestors(ep) == 2
+
+
+def test_season_from_ancestors_none_when_no_season_folder(tmp_path):
+ folder = tmp_path / "Movies"
+ folder.mkdir()
+ f = folder / "Some Movie 2015.mkv"
+ f.touch()
+
+ assert _season_from_ancestors(f) is None
+
+
+def test_title_from_siblings_borrows_from_a_titled_sibling(tmp_path):
+ folder = tmp_path / "Acronym Show"
+ folder.mkdir()
+ titled = folder / "Some.Show.Name.S01E01.720p.mkv"
+ untitled = folder / "S01E02.SUBFRENCH.720p.mkv"
+ titled.touch()
+ untitled.touch()
+
+ assert _title_from_siblings(untitled) == "Some Show Name"
+
+
+def test_title_from_siblings_none_when_no_titled_sibling(tmp_path):
+ folder = tmp_path / "Acronym Show"
+ folder.mkdir()
+ (folder / "S01E02.mkv").touch()
+
+ assert _title_from_siblings(folder / "S01E02.mkv") is None
+
+
+# ── end-to-end against a real (tiny, synthetic) video file ──────────────────
+
+pytestmark_ffmpeg = pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed")
+
+
+def _make_clip(path: Path) -> None:
+ subprocess.run(
+ ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
+ "-f", "lavfi", "-i", "testsrc=size=320x240:rate=25:duration=1",
+ "-c:v", "libx264", "-preset", "ultrafast", "-an", str(path)],
+ check=True, capture_output=True,
+ )
+
+
+@pytest.fixture
+async def media_cache(tmp_path):
+ c = MediaCache(db_path=tmp_path / "media_cache.db")
+ await c.open()
+ yield c
+ await c.close()
+
+
+@pytestmark_ffmpeg
+@pytest.mark.asyncio
+async def test_enricher_populates_fields_and_stores_thumbnail(tmp_path, media_cache):
+ clip = tmp_path / "Some.Movie.2015.1080p.mkv"
+ _make_clip(clip)
+ entry = IndexEntry(id="fileid1", name=clip.name, path=clip.name,
+ size=clip.stat().st_size, type="video", added_at=0)
+
+ enricher = Enricher(media_cache)
+ done = asyncio.get_event_loop().create_future()
+
+ async def on_done(file_id, fields):
+ done.set_result((file_id, fields))
+
+ enricher.spawn(entry, clip, on_done)
+ file_id, fields = await asyncio.wait_for(done, timeout=30)
+
+ assert file_id == "fileid1"
+ assert fields["width"] == 320
+ assert fields["height"] == 240
+ assert fields["display_title"] == "Some Movie"
+ assert fields.get("thumb_hash")
+ stored = await media_cache.get_thumb(fields["thumb_hash"])
+ assert stored is not None and len(stored) > 0
+
+
+@pytestmark_ffmpeg
+@pytest.mark.asyncio
+async def test_enricher_handles_episode_with_season_from_folder(tmp_path, media_cache):
+ # Filename carries only a bare episode number, no SxxExx token — guessit
+ # confirmed (separately) not to find a season here at all — so the
+ # season must come from the ancestor folder (§3.4's non-standard case).
+ folder = tmp_path / "Some Show" / "Saison 3"
+ folder.mkdir(parents=True)
+ titled_sibling = folder / "Some.Show.Episode.06.mkv"
+ titled_sibling.touch()
+ clip = folder / "Episode.07.720p.mkv"
+ _make_clip(clip)
+ entry = IndexEntry(id="fileid2", name=clip.name, path=str(clip.relative_to(tmp_path)),
+ size=clip.stat().st_size, type="video", added_at=0)
+
+ enricher = Enricher(media_cache)
+ done = asyncio.get_event_loop().create_future()
+
+ async def on_done(file_id, fields):
+ done.set_result((file_id, fields))
+
+ enricher.spawn(entry, clip, on_done)
+ file_id, fields = await asyncio.wait_for(done, timeout=30)
+
+ assert fields["display_title"] == "Some Show"
+ assert fields["season"] == 3
+ assert fields["episode"] == 7
diff --git a/packages/meshbay-node/tests/test_media_cache.py b/packages/meshbay-node/tests/test_media_cache.py
new file mode 100644
index 0000000..b66c448
--- /dev/null
+++ b/packages/meshbay-node/tests/test_media_cache.py
@@ -0,0 +1,71 @@
+"""Tests for media_cache.py — TMDB/thumbnail cache and its pruning obligation."""
+
+import time
+
+import pytest
+
+from meshbay_node.media_cache import MediaCache, TMDB_META_TTL_SECS
+
+
+@pytest.fixture
+async def cache(tmp_path):
+ c = MediaCache(db_path=tmp_path / "media_cache.db")
+ await c.open()
+ yield c
+ await c.close()
+
+
+@pytest.mark.asyncio
+async def test_file_tmdb_round_trip(cache):
+ assert await cache.get_file_tmdb("file1") is None
+
+ await cache.set_file_tmdb("file1", "12345", "movie")
+
+ assert await cache.get_file_tmdb("file1") == ("12345", "movie")
+
+
+@pytest.mark.asyncio
+async def test_tmdb_meta_round_trip(cache):
+ assert await cache.get_tmdb_meta("12345", "movie") is None
+
+ await cache.set_tmdb_meta("12345", "movie", {"title": "A Movie", "vote_average": 7.5})
+
+ meta = await cache.get_tmdb_meta("12345", "movie")
+ assert meta == {"title": "A Movie", "vote_average": 7.5}
+
+
+@pytest.mark.asyncio
+async def test_tmdb_meta_expires_after_ttl(cache):
+ await cache._db.execute(
+ "INSERT INTO tmdb_meta (tmdb_id, media_type, json, fetched_at) VALUES (?, ?, ?, ?)",
+ ("999", "movie", '{"title": "Old"}', time.time() - TMDB_META_TTL_SECS - 1),
+ )
+ await cache._db.commit()
+
+ assert await cache.get_tmdb_meta("999", "movie") is None
+
+
+@pytest.mark.asyncio
+async def test_thumb_round_trip(cache):
+ assert await cache.get_thumb("thumbhash1") is None
+
+ await cache.put_thumb("thumbhash1", "file1", b"\xff\xd8fakejpeg")
+
+ assert await cache.get_thumb("thumbhash1") == b"\xff\xd8fakejpeg"
+
+
+@pytest.mark.asyncio
+async def test_prune_file_removes_thumb_and_mapping_but_not_shared_meta(cache):
+ # Two episodes of the same show share one tmdb_meta row (§2's stated case).
+ await cache.set_file_tmdb("ep1", "555", "tv")
+ await cache.set_file_tmdb("ep2", "555", "tv")
+ await cache.set_tmdb_meta("555", "tv", {"name": "A Show"})
+ await cache.put_thumb("thumb-ep1", "ep1", b"jpeg-bytes-1")
+
+ await cache.prune_file("ep1")
+
+ assert await cache.get_file_tmdb("ep1") is None
+ assert await cache.get_thumb("thumb-ep1") is None
+ # ep2's own mapping and the shared show metadata both survive
+ assert await cache.get_file_tmdb("ep2") == ("555", "tv")
+ assert await cache.get_tmdb_meta("555", "tv") == {"name": "A Show"}
diff --git a/packages/meshbay-node/tests/test_poster_cache.py b/packages/meshbay-node/tests/test_poster_cache.py
new file mode 100644
index 0000000..bbd824d
--- /dev/null
+++ b/packages/meshbay-node/tests/test_poster_cache.py
@@ -0,0 +1,88 @@
+"""
+Bug found live, 2026-08-24: `_fetch_and_cache_poster` downloaded a TMDB
+poster/backdrop from `image.tmdb.org` on *every* `media_meta_req`, even for
+a file whose TMDB match was already cached — the content-addressed
+`thumb_hash` isn't known until the bytes are downloaded, so nothing had
+ever checked "have I already fetched this poster_path" first. On a group
+with a show split across release folders (§V6), one Videos-tab visit
+triggered four to six redundant image downloads; compounded with TMDB
+latency (or a stall), this is what an operator saw as posters that "never
+finish loading" on a second visit.
+
+Fixed by keying the `thumbs` cache by a synthetic `tmdb:{poster_path}` id
+*before* the network call, mirroring the `file_id` convention `_do_file_request`
+already uses to resolve a thumbnail by id.
+"""
+
+import pytest
+
+from meshbay_node.media_cache import MediaCache
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+pytestmark = pytest.mark.asyncio
+
+
+@pytest.fixture
+async def media_cache(tmp_path):
+ c = MediaCache(db_path=tmp_path / "media_cache.db")
+ await c.open()
+ yield c
+ await c.close()
+
+
+class FakeTmdbClient:
+ def __init__(self):
+ self.fetch_calls = 0
+
+ @staticmethod
+ def poster_url(path):
+ return f"https://image.tmdb.org/t/p/w500{path}"
+
+ async def fetch_image(self, url):
+ self.fetch_calls += 1
+ return b"jpeg-bytes-for-" + url.encode()
+
+
+async def test_second_fetch_for_the_same_poster_path_skips_the_network(media_cache):
+ client = FakeTmdbClient()
+
+ first = await WebRTCPeerSession._fetch_and_cache_poster(
+ media_cache, client, "/poster.jpg")
+ second = await WebRTCPeerSession._fetch_and_cache_poster(
+ media_cache, client, "/poster.jpg")
+
+ assert first == second, "the same poster_path must yield the same thumb_hash"
+ assert client.fetch_calls == 1, (
+ "a poster already cached must never be re-downloaded from TMDB")
+
+
+async def test_different_poster_paths_are_each_fetched_once(media_cache):
+ client = FakeTmdbClient()
+
+ poster_hash = await WebRTCPeerSession._fetch_and_cache_poster(
+ media_cache, client, "/poster.jpg")
+ backdrop_hash = await WebRTCPeerSession._fetch_and_cache_poster(
+ media_cache, client, "/backdrop.jpg")
+ poster_hash_again = await WebRTCPeerSession._fetch_and_cache_poster(
+ media_cache, client, "/poster.jpg")
+
+ assert poster_hash != backdrop_hash
+ assert poster_hash == poster_hash_again
+ assert client.fetch_calls == 2, "one network fetch per distinct poster_path"
+
+
+async def test_none_path_is_a_no_op(media_cache):
+ client = FakeTmdbClient()
+ result = await WebRTCPeerSession._fetch_and_cache_poster(media_cache, client, None)
+ assert result is None
+ assert client.fetch_calls == 0
+
+
+async def test_cached_hash_actually_serves_the_downloaded_bytes(media_cache):
+ client = FakeTmdbClient()
+ thumb_hash = await WebRTCPeerSession._fetch_and_cache_poster(
+ media_cache, client, "/poster.jpg")
+ await WebRTCPeerSession._fetch_and_cache_poster(media_cache, client, "/poster.jpg")
+
+ stored = await media_cache.get_thumb(thumb_hash)
+ assert stored == b"jpeg-bytes-for-https://image.tmdb.org/t/p/w500/poster.jpg"
diff --git a/packages/meshbay-node/tests/test_rename_reenrichment.py b/packages/meshbay-node/tests/test_rename_reenrichment.py
new file mode 100644
index 0000000..87e0d1a
--- /dev/null
+++ b/packages/meshbay-node/tests/test_rename_reenrichment.py
@@ -0,0 +1,163 @@
+"""
+Bug found live, 2026-08-24: an episode file first named in French (its
+release folder mixed languages across seasons) was renamed by the operator
+to match its English-named siblings — but kept showing as its own
+separate poster-grid card, and its own row in Flat list, indefinitely.
+
+`_enriched_attempted` (daemon.py) exists so that enrichment's own
+field-fill (duration/thumb_hash/display_title/... landing back via
+`_on_enriched`) does not re-trigger itself forever — but it also silently
+blocked the *new* filename from ever being title-parsed at all, since the
+file's content (and so its id) is unchanged by a rename. A rename/move is
+exactly the case `_reenrich_renamed_video_entries` exists to detect: same
+id, but `name` or `path` differs from the version last broadcast.
+"""
+
+import asyncio
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.crypto import generate_gek
+from meshbay_node.config import Config, HubConfig, NodeConfig, GroupConfig, KeystoreConfig
+from meshbay_node.daemon import NodeDaemon
+from meshbay_node.indexer import DirectoryIndexer
+
+from conftest import one_root
+
+pytestmark = pytest.mark.asyncio
+
+
+def _free_port() -> int:
+ import socket
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+class _StubRoster:
+ async def video_root(self, group_id):
+ return "shared"
+
+
+class _SpyEnricher:
+ """Records which file ids were actually (re-)scheduled, without
+ needing a real ffmpeg/ffprobe pipeline for this test."""
+
+ def __init__(self):
+ self.spawned = []
+
+ def spawn(self, entry, file_path, on_done):
+ self.spawned.append(entry.id)
+
+ async def _noop():
+ return None
+
+ return asyncio.ensure_future(_noop())
+
+
+async def test_a_renamed_file_gets_re_enriched(tmp_path):
+ group_id = "a" * 32
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ old_path = shared / "la-guerre-des-mondes-s03e02.mkv"
+ old_path.write_bytes(b"not a real video, just needs to be indexed as one")
+
+ config = Config(
+ hub=HubConfig(url="http://localhost:9999", username="testuser"),
+ node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
+ groups=[GroupConfig(
+ id=group_id, name="test-group", shared_dir=str(shared),
+ visibility="private", quic_port=29016,
+ )],
+ keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
+ data_dir=tmp_path / "data",
+ )
+ daemon = NodeDaemon(config)
+ daemon._broadcast_coalesce_secs = 0.01
+ daemon._enricher = _SpyEnricher()
+ daemon._roster = _StubRoster()
+
+ indexer = DirectoryIndexer(
+ roots=one_root(shared), group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek(),
+ # reconcile() below calls this itself — the initial scan doesn't
+ # (see test_startup_scan_enrichment.py), so that first broadcast is
+ # still triggered manually, matching _bg_scan's real sequence.
+ on_change=daemon._on_index_change)
+ await indexer.initial_scan()
+ await daemon._on_index_change(indexer)
+ await asyncio.sleep(0.05)
+
+ entry = next(iter(indexer.index.entries))
+ file_id = entry.id
+ assert daemon._enricher.spawned == [file_id], (
+ "the file must be scheduled for enrichment once, under its original name")
+
+ new_path = shared / "war-of-the-worlds-s03e02.mkv"
+ old_path.rename(new_path)
+ changed = await indexer.reconcile()
+ assert changed, "the rename must actually be picked up by reconcile()"
+ # The re-broadcast is a fire-and-forget task chained behind the
+ # coalescing timer, itself scheduling another fire-and-forget task —
+ # poll rather than guess a single sleep long enough for both hops.
+ for _ in range(30):
+ if len(daemon._enricher.spawned) >= 2:
+ break
+ await asyncio.sleep(0.02)
+
+ renamed_entry = indexer.index.get_entry(file_id)
+ assert renamed_entry is not None
+ assert renamed_entry.name == "war-of-the-worlds-s03e02.mkv"
+ assert daemon._enricher.spawned == [file_id, file_id], (
+ "a rename must re-schedule enrichment for the same file id — "
+ "_enriched_attempted must not permanently block the new filename "
+ "from ever being title-parsed")
+
+
+async def test_an_unrelated_update_does_not_re_trigger_enrichment(tmp_path):
+ """
+ The other half of the same fix: an update whose name/path did *not*
+ change (the ordinary case — enrichment's own field-fill, or the
+ reconcile sweep confirming a file unmodified) must not re-schedule
+ enrichment. Without this, `_on_enriched` merging a file's own results
+ back into the index would count as its own trigger and loop forever.
+ """
+ group_id = "a" * 32
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ path = shared / "movie.mkv"
+ path.write_bytes(b"not a real video, just needs to be indexed as one")
+
+ config = Config(
+ hub=HubConfig(url="http://localhost:9999", username="testuser"),
+ node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
+ groups=[GroupConfig(
+ id=group_id, name="test-group", shared_dir=str(shared),
+ visibility="private", quic_port=29017,
+ )],
+ keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
+ data_dir=tmp_path / "data",
+ )
+ daemon = NodeDaemon(config)
+ daemon._broadcast_coalesce_secs = 0.01
+ daemon._enricher = _SpyEnricher()
+ daemon._roster = _StubRoster()
+
+ indexer = DirectoryIndexer(
+ roots=one_root(shared), group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek(),
+ on_change=daemon._on_index_change)
+ await indexer.initial_scan()
+ await daemon._on_index_change(indexer)
+ await asyncio.sleep(0.05)
+
+ entry = next(iter(indexer.index.entries))
+ assert daemon._enricher.spawned == [entry.id]
+
+ # A reconcile pass that finds nothing changed at all — not even a
+ # rename — must not re-schedule anything.
+ changed = await indexer.reconcile()
+ await asyncio.sleep(0.05)
+ assert not changed
+ assert daemon._enricher.spawned == [entry.id]
diff --git a/packages/meshbay-node/tests/test_season_and_search_requests.py b/packages/meshbay-node/tests/test_season_and_search_requests.py
new file mode 100644
index 0000000..8ba55fb
--- /dev/null
+++ b/packages/meshbay-node/tests/test_season_and_search_requests.py
@@ -0,0 +1,187 @@
+"""
+`_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
+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
+docstring for why — so these tests only exercise the read path, unlike
+test_tmdb_override_policy.py.
+"""
+
+import pytest
+
+from meshbay_common.protocol import MNP
+from meshbay_node.media_cache import MediaCache
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+pytestmark = pytest.mark.asyncio
+
+
+def _session(media_cache=None, tmdb_client=None) -> WebRTCPeerSession:
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {"media_cache": media_cache, "tmdb_client": tmdb_client}
+ session.sent = []
+ session._send = session.sent.append
+ return session
+
+
+@pytest.fixture
+async def media_cache(tmp_path):
+ c = MediaCache(db_path=tmp_path / "media_cache.db")
+ await c.open()
+ yield c
+ await c.close()
+
+
+class FakeTmdbClient:
+ def __init__(self, season_json=None):
+ self.season_json = season_json
+ self.tv_season_calls = []
+ self.movie_search_calls = []
+ self.tv_search_calls = []
+
+ @staticmethod
+ def poster_url(path):
+ return f"https://image.tmdb.org/t/p/w500{path}"
+
+ async def fetch_image(self, url):
+ return b"jpeg-bytes-for-" + url.encode()
+
+ async def tv_season(self, tmdb_id, season, language=None):
+ self.tv_season_calls.append((tmdb_id, season, language))
+ return self.season_json
+
+ async def search_movie_results(self, title):
+ self.movie_search_calls.append(title)
+ return [{"id": 111, "title": title, "release_date": "2019-05-01", "poster_path": "/m.jpg"}]
+
+ async def search_tv_results(self, title):
+ self.tv_search_calls.append(title)
+ return [{"id": 222, "name": title, "first_air_date": "2021-03-01", "poster_path": "/t.jpg"}]
+
+
+# ── season_meta_req ──────────────────────────────────────────────────────────
+
+async def test_season_meta_missing_tmdb_id_is_refused():
+ session = _session()
+ await session._do_season_meta_request({"season": 1})
+ assert session.sent == [{"type": "error", "detail": "Missing tmdb_id or season"}]
+
+
+async def test_season_meta_non_int_season_is_refused():
+ session = _session()
+ await session._do_season_meta_request({"tmdb_id": "42", "season": "1"})
+ assert session.sent == [{"type": "error", "detail": "Missing tmdb_id or season"}]
+
+
+async def test_season_meta_with_no_cache_or_client_reports_zero_confidence():
+ session = _session(media_cache=None, tmdb_client=None)
+ await session._do_season_meta_request({"tmdb_id": "42", "season": 1})
+ assert session.sent == [{
+ "type": MNP.SEASON_META_RESP, "v": session.sent[0]["v"],
+ "tmdb_id": "42", "season": 1, "confidence": 0,
+ }]
+
+
+async def test_season_meta_cache_hit_skips_the_tmdb_call(media_cache):
+ await media_cache.set_season_meta("42", 3, {
+ "name": "Season 3", "overview": "cached overview", "air_date": "2023-01-01",
+ "poster_path": "/cached.jpg",
+ })
+ client = FakeTmdbClient()
+ session = _session(media_cache=media_cache, tmdb_client=client)
+
+ await session._do_season_meta_request({"tmdb_id": "42", "season": 3})
+
+ assert client.tv_season_calls == [], "a cached season must not be re-fetched"
+ resp = session.sent[0]
+ assert resp["type"] == MNP.SEASON_META_RESP
+ assert resp["confidence"] == 1.0
+ assert resp["overview"] == "cached overview"
+
+
+async def test_season_meta_cache_miss_fetches_and_caches(media_cache):
+ client = FakeTmdbClient(season_json={
+ "name": "Season 1", "overview": "fresh overview", "air_date": "2020-01-01",
+ "poster_path": "/fresh.jpg",
+ })
+ session = _session(media_cache=media_cache, tmdb_client=client)
+
+ await session._do_season_meta_request({"tmdb_id": "7", "season": 1})
+
+ assert client.tv_season_calls == [("7", 1, None)]
+ resp = session.sent[0]
+ assert resp["overview"] == "fresh overview"
+ assert resp["poster_thumb_hash"] is not None
+ cached = await media_cache.get_season_meta("7", 1)
+ assert cached["overview"] == "fresh overview", "a fetched season must be cached for next time"
+
+
+async def test_season_meta_empty_overview_falls_back_to_english(media_cache):
+ async def tv_season(tmdb_id, season, language=None):
+ if language == "en-US":
+ return {"name": "S1", "overview": "English overview", "air_date": "2020-01-01",
+ "poster_path": "/p.jpg"}
+ return {"name": "S1", "overview": "", "air_date": "2020-01-01", "poster_path": "/p.jpg"}
+
+ client = FakeTmdbClient()
+ client.tv_season = tv_season
+ session = _session(media_cache=media_cache, tmdb_client=client)
+
+ await session._do_season_meta_request({"tmdb_id": "9", "season": 1})
+
+ assert session.sent[0]["overview"] == "English overview"
+
+
+# ── tmdb_search_req ──────────────────────────────────────────────────────────
+
+async def test_search_missing_query_is_refused():
+ session = _session()
+ await session._do_tmdb_search_request({"media_type": "movie"})
+ assert session.sent == [{"type": "error", "detail": "Missing query or media_type"}]
+
+
+async def test_search_bad_media_type_is_refused():
+ session = _session()
+ await session._do_tmdb_search_request({"query": "war", "media_type": "album"})
+ assert session.sent == [{"type": "error", "detail": "Missing query or media_type"}]
+
+
+async def test_search_with_no_cache_or_client_returns_empty_results():
+ session = _session(media_cache=None, tmdb_client=None)
+ await session._do_tmdb_search_request({"query": "war", "media_type": "tv"})
+ assert session.sent == [{
+ "type": MNP.TMDB_SEARCH_RESP, "v": session.sent[0]["v"],
+ "query": "war", "media_type": "tv", "results": [],
+ }]
+
+
+async def test_search_movie_calls_movie_search_and_echoes_media_type(media_cache):
+ client = FakeTmdbClient()
+ session = _session(media_cache=media_cache, tmdb_client=client)
+
+ await session._do_tmdb_search_request({"query": "War of the Worlds", "media_type": "movie"})
+
+ assert client.movie_search_calls == ["War of the Worlds"]
+ assert client.tv_search_calls == []
+ resp = session.sent[0]
+ assert resp["type"] == MNP.TMDB_SEARCH_RESP
+ assert resp["media_type"] == "movie", (
+ "media_type must be echoed back — otherwise a movie search and a tv "
+ "search for the same query are indistinguishable to the client's "
+ "keyed response matching (transport.js tmdb_search_resp handler)")
+ assert resp["results"] == [{
+ "tmdb_id": "111", "title": "War of the Worlds", "year": "2019",
+ "poster_thumb_hash": resp["results"][0]["poster_thumb_hash"],
+ }]
+
+
+async def test_search_tv_calls_tv_search(media_cache):
+ client = FakeTmdbClient()
+ session = _session(media_cache=media_cache, tmdb_client=client)
+
+ await session._do_tmdb_search_request({"query": "War of the Worlds", "media_type": "tv"})
+
+ assert client.tv_search_calls == ["War of the Worlds"]
+ assert client.movie_search_calls == []
+ assert session.sent[0]["media_type"] == "tv"
diff --git a/packages/meshbay-node/tests/test_startup_scan_enrichment.py b/packages/meshbay-node/tests/test_startup_scan_enrichment.py
new file mode 100644
index 0000000..cdadad9
--- /dev/null
+++ b/packages/meshbay-node/tests/test_startup_scan_enrichment.py
@@ -0,0 +1,92 @@
+"""
+Regression: indexer.initial_scan() (run once at startup, daemon.py's
+_bg_scan) never itself calls on_change — that predates the Videos app, and
+every existing caller only cared about the scan finishing, not about
+notifying anyone. Enrichment (duration/thumb_hash/display_title/...) hangs
+entirely off on_change (daemon._broadcast_index_change).
+
+Without an explicit call to _on_index_change right after the startup scan,
+a file already on disk at boot — the common case, an existing library —
+would never get enriched at all: only a file added later, while the node
+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
+configured video_root, so the enrichment-scheduling behaviour under test
+is exercised the same way a real operator's group would be.
+"""
+
+import asyncio
+
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from unittest.mock import MagicMock
+
+from meshbay_common.crypto import generate_gek
+from meshbay_node.config import Config, HubConfig, NodeConfig, GroupConfig, KeystoreConfig
+from meshbay_node.daemon import NodeDaemon
+from meshbay_node.indexer import DirectoryIndexer
+from meshbay_node.indexer.enrich import Enricher
+from meshbay_node.media_cache import MediaCache
+
+from conftest import one_root
+
+pytestmark = pytest.mark.asyncio
+
+
+def _free_port() -> int:
+ import socket
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+async def test_a_file_already_on_disk_at_startup_gets_enrichment_scheduled(tmp_path):
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ (shared / "movie.mkv").write_bytes(b"not a real video, just needs to be indexed as one")
+
+ config = Config(
+ hub=HubConfig(url="http://localhost:9999", username="testuser"),
+ node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
+ groups=[GroupConfig(
+ id="a" * 32, name="test-group", shared_dir=str(shared),
+ visibility="private", quic_port=29012,
+ )],
+ keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
+ data_dir=tmp_path / "data",
+ )
+ class _StubRoster:
+ async def video_root(self, group_id):
+ return "shared" # the root itself, i.e. "enrich the whole thing"
+
+ daemon = NodeDaemon(config)
+ daemon._broadcast_coalesce_secs = 0.01 # real value would make this test wait 0.5s
+ daemon._media_cache = MediaCache(db_path=tmp_path / "media_cache.db")
+ await daemon._media_cache.open()
+ daemon._enricher = Enricher(daemon._media_cache)
+ daemon._roster = _StubRoster()
+
+ sk_node = Ed25519PrivateKey.generate()
+ indexer = DirectoryIndexer(
+ roots=one_root(shared), group_id="a" * 32,
+ sk_node=sk_node, gek=generate_gek())
+
+ # Mirrors _bg_scan's actual sequence in daemon.py.
+ await indexer.initial_scan()
+ assert not daemon._enriched_attempted, (
+ "nothing should be scheduled before _on_index_change is ever called")
+
+ await daemon._on_index_change(indexer)
+ await asyncio.sleep(0.05) # let the coalescing timer fire _broadcast_index_change
+
+ entry = next(iter(indexer.index.entries))
+ assert entry.id in daemon._enriched_attempted, (
+ "a file already on disk at startup must get enrichment scheduled the "
+ "first time its group's index is broadcast, not only on a later "
+ "watchdog-detected change to it")
+
+ await daemon._media_cache.close()
diff --git a/packages/meshbay-node/tests/test_stream_audio_transcode.py b/packages/meshbay-node/tests/test_stream_audio_transcode.py
index 5b1fc46..dde0df4 100644
--- a/packages/meshbay-node/tests/test_stream_audio_transcode.py
+++ b/packages/meshbay-node/tests/test_stream_audio_transcode.py
@@ -149,13 +149,14 @@ async def test_probe_video_reports_aac_regardless_of_source_audio_codec(tmp_path
clip = tmp_path / "clip.mkv"
_make_clip(clip, acodec="eac3", channels=6)
- codec, duration, has_audio = await _probe_video(str(clip))
+ codec, duration, has_audio, width, height = await _probe_video(str(clip))
assert has_audio is True
assert duration > 0
assert codec is not None
assert "eac3" not in codec and "ec-3" not in codec
assert "mp4a.40.2" in codec
+ assert (width, height) == (320, 240)
async def test_probe_video_handles_no_audio_track(tmp_path):
@@ -167,8 +168,9 @@ async def test_probe_video_handles_no_audio_track(tmp_path):
check=True, capture_output=True,
)
- codec, duration, has_audio = await _probe_video(str(clip))
+ codec, duration, has_audio, width, height = await _probe_video(str(clip))
assert has_audio is False
assert codec is not None and "," not in codec, \
"no audio track must not produce a dangling ',' or a fake audio codec"
+ assert (width, height) == (320, 240)
diff --git a/packages/meshbay-node/tests/test_title_parse.py b/packages/meshbay-node/tests/test_title_parse.py
new file mode 100644
index 0000000..1a277f2
--- /dev/null
+++ b/packages/meshbay-node/tests/test_title_parse.py
@@ -0,0 +1,129 @@
+"""
+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
+is a manual acceptance step (§11), not something this repo's corpus holds.
+"""
+
+from meshbay_node.indexer.title_parse import (
+ ParsedName,
+ naive_title,
+ parse_episode_filename,
+ parse_movie_filename,
+ season_from_folder_name,
+ sequel_variants,
+)
+
+
+# ── §3.3 row: plain, well-formed movie filename ──────────────────────────────
+
+def test_plain_movie_filename_parses_confidently():
+ r = parse_movie_filename("The.Great.Adventure.2015.1080p.BluRay.x264.mkv")
+ assert r.display_title == "The Great Adventure"
+ assert r.year == 2015
+ assert r.confidence is True
+
+
+# ── §3.3 row 1: real title in alternative_title ──────────────────────────────
+
+def test_franchise_numeric_code_exposes_alternative_title():
+ r = parse_movie_filename("Franchise.007.-.The.Real.Subtitle.1999.720p.mkv")
+ assert r.year == 1999
+ # guessit lands the franchise fragment in title and the real subtitle in
+ # alternative_title — both must be surfaced so a caller (tmdb.py) can
+ # try either, per the design doc's "search both fields" fix.
+ assert r.alt_title is not None
+
+
+# ── §3.3 row 2: hyphenated title split before a parenthesized year ───────────
+
+def test_naive_title_normalizes_hyphens_dots_and_underscores():
+ nt = naive_title("Hero-Name.(2002).DVDRip.XviD-GROUP.avi")
+ assert "-" not in nt
+ assert "." not in nt
+ assert "_" not in nt
+ assert "2002" not in nt # the year itself is stripped, not just its parens
+ assert "Hero" in nt and "Name" in nt
+
+
+# ── §3.3 row 3: French edition vocabulary stuck to the title ─────────────────
+
+def test_french_edition_phrases_are_stripped():
+ r = parse_movie_filename("Some.Movie.Version.Longue.2010.mkv")
+ assert "version" not in (r.display_title or "").lower()
+ assert "longue" not in (r.display_title or "").lower()
+
+ r2 = parse_movie_filename("Autre.Film.Remasterise.1998.mkv")
+ assert "remasteris" not in (r2.display_title or "").lower()
+
+
+# ── §3.3 row 4: trailing sequel digit / Roman numeral ────────────────────────
+
+def test_sequel_variants_strips_digit_and_offers_roman_numeral():
+ variants = sequel_variants("Some Sequel 2")
+ assert "Some Sequel" in variants
+ assert "Some Sequel II" in variants
+
+
+def test_sequel_variants_empty_when_no_trailing_digit():
+ assert sequel_variants("Some Movie") == []
+
+
+# ── §3.3 row 6: no usable title at all ───────────────────────────────────────
+
+def test_low_confidence_when_no_title_or_year():
+ r = parse_movie_filename("abc123.mkv")
+ assert r.confidence is False
+ # the mandated fallback is always available regardless
+ assert r.naive_title == "abc123"
+
+
+# ── §3.2/§3.4: episode filename with no show name at all ─────────────────────
+
+def test_episode_only_filename_has_no_title_but_has_season_episode():
+ r = parse_episode_filename("S08E02.SUBFRENCH.720p.mkv")
+ assert r.display_title is None
+ assert r.season == 8
+ assert r.episode == 2
+ # confidence is False (no title yet) — the indexer must supply one from
+ # a representative sibling filename in the same folder, per §3.4.
+ assert r.confidence is False
+
+
+def test_episode_filename_with_show_name_parses_confidently():
+ r = parse_episode_filename("Some.Show.Name.S02E05.720p.WEB.mkv")
+ assert r.display_title == "Some Show Name"
+ assert r.season == 2
+ assert r.episode == 5
+ assert r.confidence is True
+
+
+# ── §3.4: season-like ancestor folders, including non-English vocabulary ────
+
+def test_season_folder_english():
+ assert season_from_folder_name("Season 2") == 2
+
+
+def test_season_folder_french_word():
+ assert season_from_folder_name("Saison 3") == 3
+
+
+def test_season_folder_roman_numeral():
+ assert season_from_folder_name("Saison IV") == 4
+
+
+def test_specials_folder_maps_to_season_zero():
+ assert season_from_folder_name("Specials") == 0
+ assert season_from_folder_name("Bonus") == 0
+ assert season_from_folder_name("Extras") == 0
+
+
+def test_non_season_folder_name_returns_none():
+ assert season_from_folder_name("Some Show Name") is None
+
+
+def test_parsed_name_is_a_plain_dataclass():
+ # sanity: constructible with just the one required field, per the
+ # "None => caller must supply from elsewhere" contract.
+ p = ParsedName(display_title=None)
+ assert p.confidence is False
+ assert p.naive_title == ""
diff --git a/packages/meshbay-node/tests/test_tmdb.py b/packages/meshbay-node/tests/test_tmdb.py
new file mode 100644
index 0000000..4500feb
--- /dev/null
+++ b/packages/meshbay-node/tests/test_tmdb.py
@@ -0,0 +1,166 @@
+"""Tests for tmdb.py against a mocked httpx transport — no live network in CI."""
+
+import httpx
+import pytest
+
+from meshbay_node.tmdb import TmdbClient
+
+
+class FakeRoster:
+ def __init__(self, enabled: bool = True, token: str | None = "fake-token",
+ language: str | None = None):
+ self._enabled = enabled
+ self._token = token
+ self._language = language
+
+ async def tmdb_config(self):
+ return self._enabled, self._token, self._language
+
+
+def _handler(response_map):
+ def handle(request: httpx.Request) -> httpx.Response:
+ path = request.url.path
+ for prefix, body in response_map.items():
+ if path.endswith(prefix):
+ return httpx.Response(200, json=body)
+ return httpx.Response(404, json={"results": []})
+ return handle
+
+
+@pytest.mark.asyncio
+async def test_search_movie_returns_top_result_and_confidence():
+ body = {"results": [{"id": 42, "title": "The Great Adventure", "release_date": "2015-01-01"}]}
+ client = TmdbClient(
+ roster=FakeRoster(),
+ transport=httpx.MockTransport(_handler({"search/movie": body})),
+ )
+ result, ratio = await client.search_movie("The Great Adventure", 2015)
+
+ assert result is not None
+ assert result["id"] == 42
+ assert ratio > 0.9
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_search_tv_returns_top_result():
+ body = {"results": [{"id": 7, "name": "Some Show"}]}
+ client = TmdbClient(
+ roster=FakeRoster(),
+ transport=httpx.MockTransport(_handler({"search/tv": body})),
+ )
+ result, ratio = await client.search_tv("Some Show")
+
+ assert result is not None and result["id"] == 7
+ assert ratio > 0.9
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_configured_language_is_sent_to_tmdb():
+ captured = {}
+
+ def handle(request: httpx.Request) -> httpx.Response:
+ captured["language"] = request.url.params.get("language")
+ return httpx.Response(200, json={"results": []})
+
+ client = TmdbClient(
+ roster=FakeRoster(language="fr-FR"),
+ transport=httpx.MockTransport(handle),
+ )
+ await client.search_movie("Anything")
+
+ assert captured["language"] == "fr-FR"
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_no_language_configured_omits_the_param():
+ captured = {}
+
+ def handle(request: httpx.Request) -> httpx.Response:
+ captured["has_language"] = "language" in request.url.params
+ return httpx.Response(200, json={"results": []})
+
+ client = TmdbClient(
+ roster=FakeRoster(language=None),
+ transport=httpx.MockTransport(handle),
+ )
+ await client.search_movie("Anything")
+
+ assert captured["has_language"] is False
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_no_results_returns_none_and_zero_confidence():
+ client = TmdbClient(
+ roster=FakeRoster(),
+ transport=httpx.MockTransport(_handler({"search/movie": {"results": []}})),
+ )
+ result, ratio = await client.search_movie("Nonexistent Obscure Title")
+
+ assert result is None
+ assert ratio == 0.0
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_disabled_via_roster_setting_makes_no_request():
+ calls = []
+
+ def handle(request: httpx.Request) -> httpx.Response:
+ calls.append(request)
+ return httpx.Response(200, json={"results": []})
+
+ client = TmdbClient(
+ roster=FakeRoster(enabled=False),
+ transport=httpx.MockTransport(handle),
+ )
+ result, ratio = await client.search_movie("Anything")
+
+ assert result is None
+ assert calls == [] # confirms the disabled check short-circuits before any request
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_no_token_resolvable_makes_no_request(monkeypatch):
+ monkeypatch.delenv("MESHBAY_TMDB_DEFAULT_TOKEN", raising=False)
+ calls = []
+
+ def handle(request: httpx.Request) -> httpx.Response:
+ calls.append(request)
+ return httpx.Response(200, json={"results": []})
+
+ client = TmdbClient(
+ roster=FakeRoster(enabled=True, token=None),
+ transport=httpx.MockTransport(handle),
+ )
+ result, ratio = await client.search_movie("Anything")
+
+ assert result is None
+ assert calls == []
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_http_error_returns_none_gracefully():
+ def handle(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(500, json={"status_message": "server error"})
+
+ client = TmdbClient(
+ roster=FakeRoster(),
+ transport=httpx.MockTransport(handle),
+ )
+ result, ratio = await client.search_movie("Anything")
+
+ assert result is None
+ assert ratio == 0.0
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_poster_url_builds_full_url():
+ assert TmdbClient.poster_url("/abc123.jpg") == "https://image.tmdb.org/t/p/w500/abc123.jpg"
+ assert TmdbClient.poster_url(None) is None
diff --git a/packages/meshbay-node/tests/test_tmdb_config_policy.py b/packages/meshbay-node/tests/test_tmdb_config_policy.py
new file mode 100644
index 0000000..29ef54c
--- /dev/null
+++ b/packages/meshbay-node/tests/test_tmdb_config_policy.py
@@ -0,0 +1,230 @@
+"""
+The operator decides whether the node calls TMDB at all, and whether it uses
+a custom API token — docs/mediacenter.md §5.5. 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.
+
+Specific to this one: the subject signed/audited must never contain the
+token itself (it would end up in the audit log in plaintext) — only whether
+one was supplied travels there. The token itself only ever travels in
+`payload`, which is node-side context never re-sent or re-verified from the
+wire (see _issue_admin_challenge's docstring).
+"""
+
+from pathlib import Path
+
+import pytest
+
+from meshbay_common.adminop import OP_TMDB_CONFIG
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roster import Roster
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from conftest import one_root
+
+pytestmark = pytest.mark.asyncio
+
+
+def _session(tmp_path: Path, user_id: str, *, operator: str | None = None) -> WebRTCPeerSession:
+ shared_root = tmp_path / "shared"
+ shared_root.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ ctx = {
+ "roots": one_root(shared_root),
+ "index": index,
+ "sk_node": index.sk_node,
+ "node_user_id": operator,
+ }
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = ctx
+ session._group_id = None
+ session._user_id = user_id
+ session._pk_user = ""
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session
+
+
+def _fake_challenge(issued: list):
+ return lambda op, subject, payload=None, group_id=None: issued.append(
+ (op, subject, payload, group_id))
+
+
+# ── Refused before a challenge is even issued ───────────────────────────────
+
+async def test_missing_enabled_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = _fake_challenge(issued)
+
+ session._do_tmdb_config({})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_non_bool_enabled_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = _fake_challenge(issued)
+
+ session._do_tmdb_config({"enabled": "yes"})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_non_string_token_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = _fake_challenge(issued)
+
+ session._do_tmdb_config({"enabled": True, "token": 12345})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path):
+ session = _session(tmp_path, "member-1", operator="the-operator")
+ session._has_admin_authority = lambda: False
+
+ session._do_tmdb_config({"enabled": False})
+
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+# ── Who may change it, and what gets signed ─────────────────────────────────
+
+async def test_changing_it_needs_a_signature(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = _fake_challenge(issued)
+
+ session._do_tmdb_config({"enabled": True})
+
+ assert len(issued) == 1
+ op, subject, payload, group_id = issued[0]
+ assert op == OP_TMDB_CONFIG
+ assert group_id == "", "node-wide, like group_attach/group_detach — not tied to self._group_id"
+
+
+async def test_the_token_itself_never_appears_in_the_signed_subject(tmp_path):
+ """The subject is what gets audited (self._audit(pending['subject'])) —
+ a secret must never end up there in plaintext."""
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = _fake_challenge(issued)
+
+ secret = "sk-super-secret-tmdb-token"
+ session._do_tmdb_config({"enabled": True, "token": secret})
+
+ _, subject, payload, _ = issued[0]
+ assert secret not in subject
+ assert payload["token"] == secret, "the real value still has to reach the exec step somehow"
+
+
+async def test_subject_reflects_enabled_and_whether_a_token_was_supplied(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = _fake_challenge(issued)
+
+ session._do_tmdb_config({"enabled": False, "token": "x"})
+
+ _, subject, _, _ = issued[0]
+ assert subject == "enabled=False,custom_token=yes,language=default"
+
+
+async def test_subject_says_no_custom_token_when_none_given(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = _fake_challenge(issued)
+
+ session._do_tmdb_config({"enabled": True})
+
+ _, subject, _, _ = issued[0]
+ assert subject == "enabled=True,custom_token=no,language=default"
+
+
+async def test_subject_reflects_a_configured_language(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = _fake_challenge(issued)
+
+ session._do_tmdb_config({"enabled": True, "language": "fr-FR"})
+
+ _, subject, payload, _ = issued[0]
+ assert subject == "enabled=True,custom_token=no,language=fr-FR"
+ assert payload["language"] == "fr-FR"
+
+
+async def test_non_string_language_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = _fake_challenge(issued)
+
+ session._do_tmdb_config({"enabled": True, "language": 42})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+# ── Where it is stored ──────────────────────────────────────────────────────
+
+async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path):
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ enabled, token, language = await roster.tmdb_config()
+ assert (enabled, token, language) == (True, None, None), (
+ "absent must mean on, with the shipped default token, TMDB's own default language")
+ await roster.set_tmdb_config(True, "my-custom-token", "fr-FR", set_by="op")
+ enabled, token, language = await roster.tmdb_config()
+ assert (enabled, token, language) == (True, "my-custom-token", "fr-FR")
+ finally:
+ await roster.close()
+
+ reopened = Roster(db_path=tmp_path / "roster.db")
+ await reopened.open()
+ try:
+ assert await reopened.tmdb_config() == (True, "my-custom-token", "fr-FR")
+ finally:
+ await reopened.close()
+
+
+async def test_clearing_the_token_reverts_to_the_default(tmp_path):
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ await roster.set_tmdb_config(True, "a-token", set_by="op")
+ assert (await roster.tmdb_config())[1] == "a-token"
+
+ await roster.set_tmdb_config(True, "", set_by="op")
+ enabled, token, language = await roster.tmdb_config()
+ assert token is None, "an explicit empty string clears the custom token"
+ finally:
+ await roster.close()
+
+
+async def test_omitting_the_token_leaves_it_unchanged(tmp_path):
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ await roster.set_tmdb_config(True, "a-token", set_by="op")
+ await roster.set_tmdb_config(False, None, set_by="op")
+ enabled, token, language = await roster.tmdb_config()
+ assert (enabled, token) == (False, "a-token")
+ finally:
+ await roster.close()
diff --git a/packages/meshbay-node/tests/test_tmdb_language_fallback.py b/packages/meshbay-node/tests/test_tmdb_language_fallback.py
new file mode 100644
index 0000000..1a85531
--- /dev/null
+++ b/packages/meshbay-node/tests/test_tmdb_language_fallback.py
@@ -0,0 +1,98 @@
+"""
+TMDB doesn't fall back server-side for a field with no translation in the
+requested language — it returns "" (or an empty list) for that one field,
+not the English text, confirmed live against a real French query. The TMDB
+website itself covers exactly this gap client-side by falling back to
+English per field; `_tmdb_build_meta` (webrtc_server.py) mirrors that,
+rather than discarding an otherwise-good localized response over one empty
+field, or silently showing a blank overview/poster.
+"""
+
+import pytest
+
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+pytestmark = pytest.mark.asyncio
+
+
+class FakeTmdbClient:
+ def __init__(self, localized: dict, english: dict, credits: dict | None = None):
+ self._localized = localized
+ self._english = english
+ self._credits = credits or {"cast": [], "crew": []}
+ self.calls: list[tuple[str, str | None]] = []
+
+ async def movie_details(self, tmdb_id, language=None):
+ self.calls.append(("movie_details", language))
+ return self._english if language == "en-US" else self._localized
+
+ async def tv_details(self, tmdb_id, language=None):
+ self.calls.append(("tv_details", language))
+ return self._english if language == "en-US" else self._localized
+
+ async def movie_credits(self, tmdb_id):
+ return self._credits
+
+ async def tv_credits(self, tmdb_id):
+ return self._credits
+
+
+async def test_empty_overview_falls_back_to_english_but_keeps_localized_poster():
+ localized = {
+ "title": "OVNI(s)", "original_title": "OVNI(s)",
+ "overview": "", # no French translation on TMDB for this field
+ "poster_path": "/fr-poster.jpg", "backdrop_path": "/fr-backdrop.jpg",
+ "genres": [{"name": "Comédie"}], "vote_average": 7.2,
+ "first_air_date": "2016-01-01",
+ }
+ english = {
+ "title": "UFOs", "original_title": "OVNI(s)",
+ "overview": "A real English overview.",
+ "poster_path": "/en-poster.jpg", "backdrop_path": "/en-backdrop.jpg",
+ "genres": [{"name": "Comedy"}], "vote_average": 7.2,
+ "first_air_date": "2016-01-01",
+ }
+ client = FakeTmdbClient(localized, english)
+
+ meta = await WebRTCPeerSession._tmdb_build_meta(client, "108353", "tv", {"id": 108353})
+
+ assert meta["overview"] == "A real English overview.", (
+ "the empty localized field must fall back to the English value")
+ assert meta["poster_path"] == "/fr-poster.jpg", (
+ "a non-empty localized field must NOT be overwritten by the English fallback"
+ )
+ assert meta["genres"] == ["Comédie"], "localized genres were present — kept as-is"
+ assert ("tv_details", "en-US") in client.calls, "the fallback fetch must actually happen"
+
+
+async def test_fully_populated_localized_response_never_triggers_a_fallback_call():
+ localized = {
+ "title": "OVNI(s)", "overview": "Un résumé complet en français.",
+ "poster_path": "/fr-poster.jpg", "genres": [{"name": "Comédie"}],
+ "vote_average": 7.2,
+ }
+ client = FakeTmdbClient(localized, english={"overview": "should never be used"})
+
+ meta = await WebRTCPeerSession._tmdb_build_meta(client, "108353", "tv", {"id": 108353})
+
+ assert meta["overview"] == "Un résumé complet en français."
+ assert client.calls == [("tv_details", None)], (
+ "a fully translated response must not cost a second TMDB request")
+
+
+async def test_completely_untranslated_response_falls_back_entirely():
+ localized = {"overview": "", "poster_path": None, "genres": []}
+ english = {
+ "title": "UFOs", "original_title": "OVNI(s)",
+ "overview": "A real English overview.", "poster_path": "/en-poster.jpg",
+ "genres": [{"name": "Comedy"}], "vote_average": 7.2,
+ "release_date": "2016-01-01",
+ }
+ client = FakeTmdbClient(localized, english)
+
+ meta = await WebRTCPeerSession._tmdb_build_meta(client, "418517", "movie", {"id": 418517})
+
+ assert meta["overview"] == "A real English overview."
+ assert meta["poster_path"] == "/en-poster.jpg"
+ assert meta["genres"] == ["Comedy"]
+ assert meta["title"] == "UFOs"
diff --git a/packages/meshbay-node/tests/test_tmdb_override_policy.py b/packages/meshbay-node/tests/test_tmdb_override_policy.py
new file mode 100644
index 0000000..b8fa6f9
--- /dev/null
+++ b/packages/meshbay-node/tests/test_tmdb_override_policy.py
@@ -0,0 +1,172 @@
+"""
+An operator correcting a wrong automatic TMDB match (found live: a real
+show's search consistently matched a season-3-specific promotional TMDB
+entry instead of the show itself). Signed like video_root/tmdb_config —
+it changes what every member sees, node-wide (media_cache is shared, not
+per-viewer) — and, once authorized, applies to every index entry sharing
+the representative file's display_title, the same grouping the poster
+grid itself uses (§3.4/§V6), not just the one file the operator happened
+to be looking at.
+"""
+
+import hashlib
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.adminop import OP_TMDB_OVERRIDE
+from meshbay_common.protocol import IndexEntry, MNP
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.media_cache import MediaCache
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+from conftest import one_root
+
+pytestmark = pytest.mark.asyncio
+
+
+def _session(tmp_path: Path, user_id: str, *, operator: str | None = None) -> WebRTCPeerSession:
+ shared_root = tmp_path / "shared"
+ shared_root.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ ctx = {
+ "roots": one_root(shared_root),
+ "index": index,
+ "sk_node": index.sk_node,
+ "node_user_id": operator,
+ }
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = ctx
+ session._group_id = None
+ session._user_id = user_id
+ session._pk_user = ""
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session
+
+
+def _entry(path: str, name: str, display_title: str) -> IndexEntry:
+ # A real id is a blake3 content hash; sha256 here is just a stand-in with
+ # the same property that matters for these tests — deterministic and
+ # effectively collision-free across the handful of entries a test builds.
+ # (`hash((path, name)) % 10` was tried here before and is NOT that: it's
+ # randomized per-process by PYTHONHASHSEED and collides constantly across
+ # only 10 possible values, silently dropping entries in GroupIndex's
+ # id-keyed dict.)
+ digest = hashlib.sha256(f"{path}/{name}".encode()).hexdigest()
+ return IndexEntry(
+ id=digest, name=name, path=path,
+ size=1, type="video", added_at=0, display_title=display_title,
+ season=1, episode=1,
+ )
+
+
+# ── Refused before a challenge is even issued ───────────────────────────────
+
+async def test_missing_path_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_tmdb_override({"tmdb_id": "123", "media_type": "tv"})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_missing_tmdb_id_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._ctx["index"].add_entry(_entry("shared", "ep.mkv", "Show"))
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_tmdb_override({"path": "shared", "media_type": "tv"})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_unknown_path_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_tmdb_override({"path": "nope", "tmdb_id": "123", "media_type": "tv"})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path):
+ session = _session(tmp_path, "member-1", operator="the-operator")
+ session._ctx["index"].add_entry(_entry("shared", "ep.mkv", "Show"))
+ session._has_admin_authority = lambda: False
+
+ session._do_tmdb_override({"path": "shared", "tmdb_id": "123", "media_type": "tv"})
+
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_a_valid_request_is_signed(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._ctx["index"].add_entry(_entry("shared", "ep.mkv", "War of the Worlds"))
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_tmdb_override({"path": "shared", "tmdb_id": "2255", "media_type": "tv"})
+
+ assert issued == [(OP_TMDB_OVERRIDE,
+ "path=shared,tmdb_id=2255,media_type=tv")]
+
+
+# ── Applying the override ───────────────────────────────────────────────────
+
+async def test_override_updates_every_entry_sharing_the_display_title(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ index = session._ctx["index"]
+ s1 = _entry("shared/S1", "s01e01.mkv", "War of the Worlds")
+ s2 = _entry("shared/S2", "s02e01.mkv", "War of the Worlds")
+ s3 = _entry("shared/S3", "s03e02.mkv", "War of the Worlds")
+ other_show = _entry("shared/Other", "ep.mkv", "A Different Show")
+ for e in (s1, s2, s3, other_show):
+ index.add_entry(e)
+
+ media_cache = MediaCache(db_path=tmp_path / "media_cache.db")
+ await media_cache.open()
+ try:
+ session._ctx["media_cache"] = media_cache
+ # Signature verification itself is exercised generically elsewhere
+ # (test_roster_pairing.py) — this test is about the policy once a
+ # signature is known good: which entries actually get updated, and
+ # who is told about it.
+ session._verify_admin_sig = lambda transcript, sig: _true()
+ peer = type("Peer", (), {"sent": []})()
+ peer._send = peer.sent.append
+ session._peer_registry = lambda: {"peer-1": peer}
+
+ await session._admin_exec_tmdb_override(
+ {"subject": "path=shared/S1,tmdb_id=999,media_type=tv"},
+ b"transcript", b"sig")
+
+ for e in (s1, s2, s3):
+ assert await media_cache.get_file_tmdb(e.id) == ("999", "tv"), (
+ "every entry sharing the representative file's display_title "
+ "must be corrected, not just the one the operator clicked on")
+ assert await media_cache.get_file_tmdb(other_show.id) is None, (
+ "a different show's own match must be left alone")
+ # The ack is broadcast to other connected peers, never echoed onto
+ # the requester's own `sent` — see the loop in
+ # _admin_exec_tmdb_override, which sends via each peer's own _send.
+ assert [m for m in peer.sent if m.get("type") == MNP.TMDB_OVERRIDE_ACK]
+ finally:
+ await media_cache.close()
+
+
+async def _true():
+ return True
diff --git a/packages/meshbay-node/tests/test_video_root_gates_enrichment.py b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py
new file mode 100644
index 0000000..df86a79
--- /dev/null
+++ b/packages/meshbay-node/tests/test_video_root_gates_enrichment.py
@@ -0,0 +1,153 @@
+"""
+Videos-app enrichment (ffprobe/thumbnailing/TMDB, mediacenter.md §5.2/§10)
+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
+they have chosen which folder is actually their media library would be
+real, ongoing cost for files never meant to be in the Videos app at all.
+
+Setting or changing the root (ops.set_video_root) fires a one-off sweep
+(_enrich_video_root_now) of whatever it already contains: the ordinary
+per-broadcast path only ever looks at files new since the last broadcast,
+so anything already sitting in a folder before it became the video_root
+would otherwise never be picked up.
+"""
+
+import asyncio
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.crypto import generate_gek
+from meshbay_node import ops
+from meshbay_node.config import Config, HubConfig, NodeConfig, GroupConfig, KeystoreConfig
+from meshbay_node.daemon import NodeDaemon
+from meshbay_node.indexer import DirectoryIndexer
+from meshbay_node.indexer.enrich import Enricher
+from meshbay_node.media_cache import MediaCache
+from meshbay_node.roster import Roster
+
+from conftest import one_root
+
+pytestmark = pytest.mark.asyncio
+
+
+def _free_port() -> int:
+ import socket
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+async def _make_daemon(tmp_path, shared, group_id):
+ config = Config(
+ hub=HubConfig(url="http://localhost:9999", username="testuser"),
+ node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
+ groups=[GroupConfig(
+ id=group_id, name="test-group", shared_dir=str(shared),
+ visibility="private", quic_port=29014,
+ )],
+ keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
+ data_dir=tmp_path / "data",
+ )
+ daemon = NodeDaemon(config)
+ daemon._broadcast_coalesce_secs = 0.01 # real value would make these tests wait 0.5s
+ daemon._media_cache = MediaCache(db_path=tmp_path / "media_cache.db")
+ await daemon._media_cache.open()
+ daemon._enricher = Enricher(daemon._media_cache)
+ daemon._roster = Roster(db_path=tmp_path / "roster.db")
+ await daemon._roster.open()
+ return daemon
+
+
+async def _teardown(daemon):
+ await daemon._media_cache.close()
+ await daemon._roster.close()
+
+
+async def test_no_video_root_means_no_enrichment_at_all(tmp_path):
+ group_id = "a" * 32
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ (shared / "movie.mkv").write_bytes(b"not a real video, just needs to be indexed as one")
+
+ daemon = await _make_daemon(tmp_path, shared, group_id)
+ try:
+ indexer = DirectoryIndexer(
+ roots=one_root(shared), group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek())
+ await indexer.initial_scan()
+ await daemon._on_index_change(indexer)
+ await asyncio.sleep(0.05)
+
+ assert not daemon._enriched_attempted, (
+ "a group with no video_root configured must not enrich anything, "
+ "not even fall back to the whole index")
+ finally:
+ await _teardown(daemon)
+
+
+async def test_only_entries_under_the_configured_root_are_enriched(tmp_path):
+ group_id = "a" * 32
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ (shared / "Movies").mkdir()
+ # Distinct content: the index dedupes by content hash, and two files with
+ # the same bytes would otherwise collapse into a single entry.
+ (shared / "Movies" / "in-root.mkv").write_bytes(b"in-root content")
+ (shared / "outside.mkv").write_bytes(b"outside content")
+
+ daemon = await _make_daemon(tmp_path, shared, group_id)
+ try:
+ await daemon._roster.set_video_root(group_id, "shared/Movies", set_by="op")
+ indexer = DirectoryIndexer(
+ roots=one_root(shared), group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek())
+ await indexer.initial_scan()
+ await daemon._on_index_change(indexer)
+ await asyncio.sleep(0.05)
+
+ by_name = {e.name: e for e in indexer.index.entries}
+ assert by_name["in-root.mkv"].id in daemon._enriched_attempted
+ assert by_name["outside.mkv"].id not in daemon._enriched_attempted, (
+ "a file outside the configured video_root must never be enriched")
+ finally:
+ await _teardown(daemon)
+
+
+async def test_setting_the_video_root_sweeps_what_it_already_contains(tmp_path):
+ group_id = "a" * 32
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ (shared / "Movies").mkdir()
+ (shared / "Movies" / "already-there.mkv").write_bytes(b"x")
+
+ daemon = await _make_daemon(tmp_path, shared, group_id)
+ try:
+ indexer = DirectoryIndexer(
+ roots=one_root(shared), group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek())
+ await indexer.initial_scan()
+ daemon._state["indexers"][group_id] = indexer
+
+ # Broadcast once with nothing configured — nothing should be scheduled.
+ await daemon._on_index_change(indexer)
+ await asyncio.sleep(0.05)
+ assert not daemon._enriched_attempted
+
+ # Now the operator points video_root at the folder that already held
+ # this file all along.
+ state = {
+ "roster": daemon._roster,
+ "groups_ctx": {group_id: {}},
+ "enrich_video_root_fn": daemon._enrich_video_root_now,
+ }
+ await ops.set_video_root(state, group_id, "shared/Movies")
+ await asyncio.sleep(0.05) # let the fire-and-forget sweep actually run
+
+ entry = next(iter(indexer.index.entries))
+ assert entry.id in daemon._enriched_attempted, (
+ "a file already sitting in the newly-chosen root must be picked "
+ "up by the sweep, not wait for some unrelated future change")
+ finally:
+ await _teardown(daemon)
diff --git a/packages/meshbay-node/tests/test_video_root_policy.py b/packages/meshbay-node/tests/test_video_root_policy.py
new file mode 100644
index 0000000..8cc1540
--- /dev/null
+++ b/packages/meshbay-node/tests/test_video_root_policy.py
@@ -0,0 +1,141 @@
+"""
+Which folder (possibly a subfolder of a shared root) is the Videos app's
+entry point for a group. Same shape as test_apps_enabled_policy.py: a
+signed operator instruction, per-group (unlike tmdb_config, which is
+node-wide), stored via roster.py's group_settings table.
+
+Specific to this one: a non-empty path must resolve to a real, readable
+directory inside one of the group's own roots before a challenge is ever
+issued — refusing a typo up front, the same way an empty apps set is
+refused up front rather than round-tripped to the operator's browser.
+"""
+
+from pathlib import Path
+
+import pytest
+
+from meshbay_common.adminop import OP_VIDEO_ROOT
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roster import Roster
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from conftest import one_root
+
+pytestmark = pytest.mark.asyncio
+
+
+def _session(tmp_path: Path, user_id: str, *, operator: str | None = None) -> WebRTCPeerSession:
+ shared_root = tmp_path / "shared"
+ shared_root.mkdir(exist_ok=True)
+ (shared_root / "Movies").mkdir()
+ (shared_root / "Shows").mkdir()
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ ctx = {
+ "roots": one_root(shared_root),
+ "index": index,
+ "sk_node": index.sk_node,
+ "node_user_id": operator,
+ }
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = ctx
+ session._group_id = None
+ session._user_id = user_id
+ session._pk_user = ""
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session
+
+
+# ── Refused before a challenge is even issued ───────────────────────────────
+
+async def test_missing_path_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_video_root({})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_a_nonexistent_folder_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_video_root({"path": "shared/Nonexistent"})
+
+ assert not issued, "a mistyped path must be refused before a signature round trip"
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_path_traversal_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_video_root({"path": "../../etc"})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path):
+ session = _session(tmp_path, "member-1", operator="the-operator")
+ session._has_admin_authority = lambda: False
+
+ session._do_video_root({"path": "shared/Movies"})
+
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+# ── Accepted cases ───────────────────────────────────────────────────────────
+
+async def test_an_empty_path_is_always_accepted(tmp_path):
+ """Empty means 'the whole group index' — always valid, nothing to resolve."""
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_video_root({"path": ""})
+
+ assert issued == [(OP_VIDEO_ROOT, "")]
+
+
+async def test_a_real_subfolder_is_accepted_and_signed(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_video_root({"path": "shared/Movies"})
+
+ assert issued == [(OP_VIDEO_ROOT, "shared/Movies")]
+
+
+# ── Where it is stored ──────────────────────────────────────────────────────
+
+async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path):
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ try:
+ assert await roster.video_root("g1") == "", "absent must mean the whole group index"
+ await roster.set_video_root("g1", "shared/Movies", set_by="op")
+ assert await roster.video_root("g1") == "shared/Movies"
+ finally:
+ await roster.close()
+
+ reopened = Roster(db_path=tmp_path / "roster.db")
+ await reopened.open()
+ try:
+ assert await reopened.video_root("g1") == "shared/Movies"
+ assert await reopened.video_root("g2") == "", "one group's setting must not answer for another"
+ finally:
+ await reopened.close()
diff --git a/packages/meshbay-node/tests/test_wizard_apps_endpoint.py b/packages/meshbay-node/tests/test_wizard_apps_endpoint.py
new file mode 100644
index 0000000..10ab489
--- /dev/null
+++ b/packages/meshbay-node/tests/test_wizard_apps_endpoint.py
@@ -0,0 +1,76 @@
+"""
+Create Group wizard: choosing which apps a brand-new group offers, before
+the (potentially long) initial scan — see app.js's CreateGroupWizard. This
+is a loopback-only, operator-authenticated endpoint (11.5.3), same shape as
+the existing member-upload one: a thin adapter over `ops.set_enabled_apps`,
+with only the validation `_do_apps_enabled` (the signed MNP front door)
+already does client-side in the wizard, but worth enforcing at this front
+door too since nothing else would.
+"""
+
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from fastapi.testclient import TestClient
+
+from conftest import one_root
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roster import Roster
+from meshbay_node.ui.app import create_ui_app
+
+pytestmark = pytest.mark.asyncio
+
+
+async def _client(tmp_path: Path):
+ shared = tmp_path / "shared"
+ shared.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ state = {
+ "status": "running",
+ "groups_ctx": {"g" * 32: {"index": index, "roots": one_root(shared)}},
+ "indexes": {"g" * 32: index},
+ "roster": roster,
+ }
+ app = create_ui_app(state)
+ return TestClient(app), roster
+
+
+async def test_narrowing_the_apps_persists_to_the_roster(tmp_path):
+ client, roster = await _client(tmp_path)
+ try:
+ resp = client.put(f"/api/groups/{'g' * 32}/apps", json={"apps": ["files", "video"]})
+ assert resp.status_code == 200, resp.text
+ assert sorted(resp.json()["apps"]) == ["files", "video"]
+ assert sorted(await roster.enabled_apps("g" * 32)) == ["files", "video"]
+ finally:
+ await roster.close()
+
+
+async def test_empty_apps_list_is_refused(tmp_path):
+ client, roster = await _client(tmp_path)
+ try:
+ resp = client.put(f"/api/groups/{'g' * 32}/apps", json={"apps": []})
+ assert resp.status_code == 400
+ finally:
+ await roster.close()
+
+
+async def test_missing_apps_field_is_refused(tmp_path):
+ client, roster = await _client(tmp_path)
+ try:
+ resp = client.put(f"/api/groups/{'g' * 32}/apps", json={})
+ assert resp.status_code == 400
+ finally:
+ await roster.close()
+
+
+async def test_unhosted_group_is_refused(tmp_path):
+ client, roster = await _client(tmp_path)
+ try:
+ resp = client.put("/api/groups/" + "z" * 32 + "/apps", json={"apps": ["files"]})
+ assert resp.status_code == 404
+ finally:
+ await roster.close()
diff --git a/packaging/systemd/meshbay-node-user.service b/packaging/systemd/meshbay-node-user.service
index 833f31a..f476841 100644
--- a/packaging/systemd/meshbay-node-user.service
+++ b/packaging/systemd/meshbay-node-user.service
@@ -25,6 +25,9 @@ Type=simple
# Secrets: MESHBAY_UNLOCK_KEY (keystore). MESHBAY_PASSWORD is no longer read —
# the node authenticates to the hub with an Ed25519 signature (NS7).
+# MESHBAY_TMDB_DEFAULT_TOKEN (Videos app, optional) also lives here — never
+# in source. Absent means TMDB lookups are inert until the operator sets one
+# here or configures a custom token in Settings (docs/mediacenter.md §5.5).
EnvironmentFile=-%h/.config/meshbay/node.env
ExecStart=/usr/bin/meshbay-node --config %h/.config/meshbay/node.toml
diff --git a/packaging/systemd/meshbay-node.service b/packaging/systemd/meshbay-node.service
index edff7f1..a469484 100644
--- a/packaging/systemd/meshbay-node.service
+++ b/packaging/systemd/meshbay-node.service
@@ -12,7 +12,10 @@ Group=%i
# Per-user service: systemctl enable --now meshbay-node@$USER
WorkingDirectory=%h
-# Secrets: MESHBAY_PASSWORD (hub login), MESHBAY_UNLOCK_KEY (keystore)
+# Secrets: MESHBAY_PASSWORD (hub login), MESHBAY_UNLOCK_KEY (keystore),
+# MESHBAY_TMDB_DEFAULT_TOKEN (Videos app — optional, no literal default
+# ships in source; absent means TMDB lookups are inert until the operator
+# sets one here or configures a custom token in Settings, docs/mediacenter.md §5.5)
EnvironmentFile=-%h/.config/meshbay/node.env
ExecStart=/usr/bin/meshbay-node --config %h/.config/meshbay/node.toml