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
|
# 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.
---
## 5. Protocol and index changes
### 5.1 `IndexEntry` already reserves two of the three fields this needs
`meshbay_common/protocol.py:133` — checked directly, and both fields are
declared but **never populated by the indexer nor consumed by any client
today**:
```python
duration: int | None = None # seconds, for media
thumb_hash: str | None = None # blake3 of thumbnail
```
This is smaller than a fresh addition: `duration` needs the indexer to run
(the existing) `ffprobe`-based probe at index time and store the result;
`thumb_hash` needs a thumbnail to have been generated and cached, addressed
by its own blake3 the same way a file is addressed by its `id` — which
opens a clean reuse (§5.3). **New**, not yet present: `width`/`height`
(resolution) and the parsed `display_title`/`season`/`episode` fields from
§3. All are additive fields on the same dataclass, so this is a MNP
**MINOR** bump (0.4 → 0.5, same class of change as the multi-root 0.2→0.3
bump), not a breaking one — an older client simply doesn't render the new
fields.
### 5.2 Index-time cost, bounded the same way thumbnails already are
Probing every video file with `ffprobe` at index time is real cost on a
large library. `desktop-client-v1.md` §6.10 already established the pattern
for exactly this shape of problem (thumbnails): **a small, bounded worker
pool, its own short timeout, never sharing the streaming pool**
(`max_concurrent_streams`), draining stdout and releasing the slot
regardless — the same lesson `CLAUDE.md` records repeatedly for anything
that shells out to `ffmpeg`/`ffprobe`. The probe (and the `guessit` parse,
which is cheap by comparison) runs asynchronously after a file is first
seen; the file appears in the index immediately with size/hash only, and an
`index_sync` delta fills in the technical/parsed fields once ready — no
scan is blocked waiting for it.
### 5.3 Thumbnail delivery — reuse the chunk path
Since `thumb_hash` is a blake3 exactly like a file's own `id`, the cleanest
delivery is to **serve a thumbnail through the existing chunked
`file_req`/`ChunkRequest` path**, with the node's chunk handler resolving a
requested id against either a real file or the thumbnail cache. This reuses
transport, GEK-scoped encryption, and backpressure handling that already
exist and are already tested, rather than inventing a parallel mechanism. If
that reuse proves awkward against the concrete dispatch code once someone
is in it, a dedicated `thumb_req`/`thumb_resp` pair is a perfectly fine
fallback — noted as an implementation choice, not a decision this document
needs to force.
### 5.4 New request/response: TMDB metadata
A JSON blob, not chunked file content, so a plain request/response pair
fits better than reusing the chunk path:
```
media_meta_req { path } # client → node, root+relpath already known from the index
media_meta_resp { tmdb_id, title, original_title, overview, poster_thumb_hash,
backdrop_thumb_hash, release_date | first_air_date,
genres[], vote_average, runtime,
cast[] (name, character), director,
season?, episode? (for a TV request), confidence }
```
`poster_thumb_hash`/`backdrop_thumb_hash` point back at §5.3's mechanism —
the node fetches the image from `image.tmdb.org` once, caches it under its
own blake3 in `data_dir`, and clients pull it exactly like a video
thumbnail. No client ever contacts `image.tmdb.org` or `api.themoviedb.org`
directly (§2).
### 5.5 Node-wide config, not per-group
TMDB is one operator's budget and one credential, not a per-group concern,
so it does not belong in `group_settings` keyed by a real `group_id`. The
existing precedent for node-wide operator state stored in that same table is
already established (`desktop-client-v1.md` §6.3: *"the precedent exists:
`roster.get_member("", user_id)` already authorizes the operator
node-wide"*) — so `tmdb_enabled` and an optional `tmdb_api_token` land in
`group_settings` under the sentinel `group_id=""`, changed by a signed
operator op (`OP_TMDB_CONFIG`, same shape as `OP_MEMBER_UPLOAD`/
`OP_APPS_ENABLED`), and broadcast in `node_status` (the node-wide channel,
as opposed to the per-group `handshake_ack`) so every connected client,
across every group, sees whether TMDB is on without reconnecting. Absent
means the shipped default token, present with a token means "use this one
instead" — the same "absent means the old behaviour" discipline
`member_upload`/`apps_enabled` already follow.
### 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.
---
## 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 |
## 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.
|