summaryrefslogtreecommitdiffstats
path: root/docs/mediacenter.md
blob: 0df115b4d8c85a4393bad0d4652bb38e37b74888 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
# 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.

**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 |

## 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.