# 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|\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. **Bug found live, 2026-08-24**: an operator moved a whole show's season into an already-watched shared folder and saw no scanning indicator and no progress bar at all — files just appeared one at a time, exactly as if nothing were tracking the add. Root cause: `IndexProgress` (`scanning`/ `scanned_bytes`/`total_bytes`) was only ever touched by the two *bulk* scan paths — the initial walk and the periodic reconcile backstop, both of which know their file list upfront. The real-time watchdog path (`_schedule_update`/`_debounce`/`_update_entry`, one file at a time, each independently debounced) never touched it at all — an omission, not a race: dropping in ten files this way always gave zero feedback, indexing or not. Fixed by accounting a "burst" the same real-time path creates: a file's size is added to `total_bytes` the moment its debounce timer is first scheduled (not on every re-trigger of the same path — a cancelled-and-rescheduled timer must not double-count), and to `scanned_bytes` once its hash actually finishes; `scanning` flips back off only once no debounce timers are pending *and* no hash is still running, not just when the last timer fires — the in-flight hash of a large file is the entire reason to show progress in the first place. ### 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 The TMDB credential and query language are one operator's budget and one shared cache, not a per-group concern, so they do 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 an optional `tmdb_api_token` and `tmdb_language` 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 the current token/language state 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. (Whether TMDB is used *at all* used to live here too — moved per-group, below.) **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. **Whether TMDB is used at all, moved per-group, 2026-08-24**: an operator running one real media-library group alongside several test/demo groups on the same node found every group sharing one on/off switch meant there was no way to spend TMDB quota and make outbound requests for the group that actually needed it without doing the same for groups that didn't. Split into its own signed op, `OP_TMDB_ENABLED`/`tmdb_enabled`/`tmdb_enabled_ack`, scoped to `self._group_id` exactly like `OP_VIDEO_ROOT` — stored in `group_settings` under the real `group_id` this time, not the `""` sentinel, and surfaced in the per-group `handshake_ack` (`tmdb_enabled`, already there — it simply reads a per-group value now) rather than `node_status`. `tmdb_config`/`OP_TMDB_CONFIG` keep their name and shape for the token/language, which stay node-wide for the reasoning above; only the on/off switch moved. `TmdbClient` itself lost its own notion of "enabled" entirely — every call site in `webrtc_server.py` (`_do_media_meta_request`, `_do_season_meta_request`, `_do_tmdb_search_request`) now checks `self._group_ctx().get("tmdb_enabled", True)` before ever calling it, and a group with it off degrades exactly like "no client configured" already did — zero confidence, empty search results, never an error, since a member's Videos tab already has to handle "no TMDB match" as the ordinary case (§4.1). ### 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 | ### 10.1 Movie-matching bugs — a batch found live (2026-08-29) Several wrong poster-grid matches on a real library: a two-volume film's second part matched the first; a numbered sequel matched a same-year making-of documentary (TMDB's real entry uses a Roman numeral); two entries of one franchise matched a single early entry whose *localized* TMDB title is the franchise name, and a third matched nothing. One mechanism: `_tmdb_search` returned the first candidate query whose title-similarity ratio merely cleared 0.6, before `alternative_title` / the Roman-numeral variant was tried. **Fixed** (branch `fix/videos-tmdb-matching`): scored retry ladder + year-exact rescue for a sub-0.6 top hit (§3.3's "trust TMDB's own ranking" still holds per query — no local re-rank of any one result list); `volume`/`part` folded into `display_title`; movie "Fix match" applied to the one file, not the whole `display_title` group; `ops.rematch_video` + a rename now drop stale cached matches (`media_cache.tmdb_override` shields manual corrections); the movie detail modal shows the source filename + resolved TMDB id, and an unmatched poster gets a badge. **Still open, noted so they are not lost (all found while fixing the above):** | # | Item | Severity | |---|---|---| | V8 | The **TV/show branch** of `_tmdb_search` still returns the first candidate over 0.6 — the same shape just fixed for movies. §5.7's own wrong show-level match was this class; its parade was Fix match + season tabs, not the search. Give it the scored ladder too | medium | | V9 | `_best_match` still trusts `results[0]` per query unconditionally. The movie fix works around it by trying more queries; if no competing query beats a wrong-but-high-ratio `results[0]`, it still wins. The originally-planned year-aware `_best_match` (mediacenter "C") would harden this — deferred: apply the year signal only when the #1 hit is already low-confidence | medium | | V10 | `sequel_variants` is narrow: one trailing digit 2–9, arabic→roman only. No "Part One", "Chapitre 2", "10", roman→arabic | low | | V11 | Extra TMDB calls in the 0.6–0.85 band: a film that used to match in one request now makes 2–4 before settling (then cached). Bounded, but real | low | | V12 | Movies are not merged in the poster grid — a film present in 1080p + 720p shows as two cards (the `tmdb_id` merge in §V6 is show-only) | cosmetic | | V13 | A per-card "re-match this one file" button in the SPA — `ops.rematch_video` is group-wide and CLI/loopback only; a per-card control needs a new signed op or MNP message. Fix match already covers targeted correction | low | ## 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.