summaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/apps.md49
-rw-r--r--docs/desktop-client-v1.md10
-rw-r--r--docs/mediacenter.md731
3 files changed, 770 insertions, 20 deletions
diff --git a/docs/apps.md b/docs/apps.md
index 94116c6..a032f56 100644
--- a/docs/apps.md
+++ b/docs/apps.md
@@ -5,13 +5,18 @@
> `meshbay-draft-v6.md` §2.7 for why this exists and what it changes; this
> document is the how-to.
-A group has "applications" — Chat and Files today, Videos/Music/Photos planned
-(Netflix-style browsing, Spotify-style playback, an album viewer). None of the
-planned ones need an MNP protocol change: video/audio/image files are already
-classified by the node's indexer (`meshbay_node/indexer/indexer.py`, `type:
-video|audio|image`) and flow through the same `index_sync`/`file_req`/
-`stream_req` messages Files and `VideoPlayer` already use. Adding one is a new
-file plus one registry entry — nothing about the group shell changes.
+A group has "applications" — Chat, Files, and Videos today (a poster-grid
+browser; see `docs/mediacenter.md`), Music/Photos planned (a music player, an
+album viewer). Video/audio/image files are already classified by the node's
+indexer (`meshbay_node/indexer/indexer.py`, `type: video|audio|image`) and
+flow through the same `index_sync`/`file_req`/`stream_req` messages Files and
+`VideoPlayer` already use — Music/Photos need no MNP change beyond that.
+Videos itself did need one: TMDB metadata (`media_meta_req`/`resp`), per-season
+overview (`season_meta_req`/`resp`), and operator match correction
+(`tmdb_search_req`/`resp`, `tmdb_override`/`_ack`) are all additive message
+pairs on top of the same index/chunk plumbing, not a replacement for it.
+Adding a new app is still a new file plus one registry entry — nothing about
+the group shell changes.
---
@@ -172,18 +177,24 @@ the only node-side touches, and both are allow-lists, not new wire messages.
## 5. What does not exist yet
-- **Thumbnails/posters.** No generation mechanism, client or node side. A
- Netflix-style grid or a photo album needs *something* here; the pragmatic
- v1 (decided but not built) is lazy, client-side: decrypt the image, or a
- video's first segment, only for tiles actually visible, with an in-memory
- cache — no new MNP message, no node-side store. This is real per-tile cost
- (a full chunk-pipelined decrypt per thumbnail), acceptable for a browsing
- grid, not for hundreds of tiles rendered at once — a virtualized grid is
- part of building Videos/Photos, not optional.
-- **Videos, Music, Photos themselves.** Deliberately out of scope for the
- refactor that built this — see `meshbay-draft-v6.md` §2.7. The
- infrastructure above is proven end-to-end with the two apps that already
- existed (Chat, Files); a new one is additive.
+- **Thumbnails/posters — built for Videos, 2026-08-23, see `docs/mediacenter.md`.**
+ The plan below (lazy, client-side, no node-side store) turned out to be
+ wrong once a real design pass ran the numbers: `docs/mediacenter.md` §2
+ revises `desktop-client-v1.md`'s O12 and has the node generate thumbnails
+ (an `ffmpeg` frame grab, its own bounded worker pool) and cache them
+ durably in its own `data_dir`, delivered over the existing `file_req`/
+ chunk path addressed by their own blake3 hash. TMDB posters/metadata are
+ fetched and cached by the node the same way — no client ever talks to
+ TMDB directly. A virtualized grid (`IntersectionObserver`-based lazy
+ mount) is built in `video-app.js`, per the note below. A future Photos
+ app can reuse the same node-side machinery (thumbnail cache, chunk-path
+ delivery) without re-deciding any of this.
+- **Videos, Music, Photos themselves.** Videos is now built (`video-app.js`,
+ `docs/mediacenter.md`). Music and Photos remain deliberately out of scope
+ — see `meshbay-draft-v6.md` §2.7. The infrastructure in this document was
+ proven end-to-end first with Chat/Files, then with Videos; Music/Photos
+ are additive from here, and can reuse Videos' thumbnail/chunk-delivery
+ machinery rather than re-deciding it.
- **The offline/loopback settings path.** `member_upload` can be toggled two
ways: over a live MNP connection, or (Electron only) via the node's local
HTTP API when MNP isn't connected (`platform.node.call('PUT', .../member-
diff --git a/docs/desktop-client-v1.md b/docs/desktop-client-v1.md
index 08dd548..c11837c 100644
--- a/docs/desktop-client-v1.md
+++ b/docs/desktop-client-v1.md
@@ -733,6 +733,14 @@ Detecting it differs by platform, and neither method is exotic:
### 6.10 The planned libraries are views, not a catalogue
+> **Revised 2026-08-23 for the Videos group app** — see `docs/mediacenter.md` §2.
+> The "no thumbnail store, ever" conclusion below (O12) was an extension by
+> analogy to draft-v5 §5.2, not a direct consequence of it. The Videos app
+> now caches thumbnails and TMDB metadata durably in the node's own
+> `data_dir` — still never inside a shared root, and still no per-file
+> identity beyond the file's own content hash. Everything else in this
+> section stands: views over the index, not a catalogue.
+
**Scope settled 2026-08-17, and it is deliberately small.** The planned "video library"
and "audio library" are **alternative views over the existing file index** — a variant of
the explorer. Presentation rests on the filename and the directory structure, plus a
@@ -1124,7 +1132,7 @@ Two rules came out of building it and both are easy to undo by accident:
| O10 | Canonical file identity across filesystems | Case folding and NFC normalization decide when two names are "the same file". It affects the index, the no-overwrite rule and cross-platform replication, so it must be defined once and shared (§6.8) |
| O11 | Root alias when the basename cannot be used | Duplicate basenames across volumes will be common, and a basename can fail the portability rules. Refusing is correct; "rename the folder on your disk" is a poor answer, so an explicit alias is the escape hatch (§6.7) |
| O13 | Hub identity pinning | The client points at a hub by URL and nothing pins that hub's identity. `GET /v1/hub/pubkey` exists; pinning it on first use — as `pk_node` already is (11.5.8) — would stop an account's hub being silently swapped. Bounded today, because a substituted hub cannot read content and no longer ships the code; worth doing all the same |
-| ~~O12~~ | Derived thumbnails vs draft-v5 §5.2 | **Resolved 2026-08-17** — frame grab produced on demand by the node, cached on the requesting device. The node keeps no thumbnail store, so §5.2 stands unchanged (§6.10) |
+| ~~O12~~ | Derived thumbnails vs draft-v5 §5.2 | **Resolved 2026-08-17, revised 2026-08-23** — frame grab produced on demand by the node, cached on the requesting device. The node keeps no thumbnail store, so §5.2 stands unchanged (§6.10). **Revision:** for the Videos group app, the node now also caches thumbnails and TMDB metadata durably in its own `data_dir` (not a shared root) — see `docs/mediacenter.md` §2 for the reasoning (TMDB quota is per-node not per-device; thin clients benefit from a node that does more) |
---
diff --git a/docs/mediacenter.md b/docs/mediacenter.md
new file mode 100644
index 0000000..66232f8
--- /dev/null
+++ b/docs/mediacenter.md
@@ -0,0 +1,731 @@
+# MeshBay — Videos application (design)
+
+> Status: **proposal**, prepared 2026-08-23, not implemented. Read `docs/apps.md`
+> first — Videos is a new group application built on the plug-in mechanism
+> described there, and this document assumes that mechanism rather than
+> re-explaining it. Read `docs/desktop-client-v1.md` §6.10 before touching
+> thumbnails or metadata — this document **revises decision O12** from that
+> file, with the reasoning recorded in §2 below.
+>
+> Follows the project convention: every claim names the adversary it holds
+> against (§8).
+
+---
+
+## 0. What was asked, in one paragraph
+
+A streaming-service-style poster browser for a shared directory (or a subtree
+of one), with two operator-selectable modes: (a) **poster-grid**, metadata
+and artwork from TMDB, falling back to a thumbnail when TMDB match fails; (b)
+**flat**, alphabetically sorted, thumbnail-based, folders as
+categories/seasons, no TMDB. Both need a filename parser, evaluated in §3
+against a real local media library (~1950 files across three collections —
+standalone movies, TV shows with nested season folders, and a small
+single-franchise set) against a >95% bar. A default TMDB read token is
+provided; an operator may supply their own.
+
+---
+
+## 1. What this design does not reopen
+
+Everything already settled stands, and this plan is written to fit inside it,
+not around it:
+
+- **Views over the index, never a catalogue** (`desktop-client-v1.md` §6.10,
+ draft-v6 §2.7). A file stays tied to its filesystem representation; nothing
+ here adds a second identity for a file, and nothing reaches the hub.
+- **The apps plug-in mechanism** (`apps.md`): a new `video-app.js`, one
+ registry entry, one node-side `ALLOWED_APPS` entry, i18n keys, the asset
+ list, the two file-set tests. Enablement is a per-group,
+ operator-signed setting, same shape as `member_upload`.
+- **Group-related server state lives on the node** (E9). Nothing in this
+ design puts a row on the hub. TMDB and thumbnail caches are node-local,
+ disposable, and rebuildable from the shared files plus TMDB — the same
+ status the file index's own hash/size already have, not the kind of
+ authority state E9 protects (membership, roster, uploads).
+- **Filesystem portability** (§6.8): nothing here writes into a shared root.
+ See §2.
+
+## 2. Revision to decision O12 — where the cache lives, and why
+
+`desktop-client-v1.md` §6.10 (2026-08-17) decided thumbnails are computed
+**on demand by the node** and cached **on the requesting device**, never
+durably on the node. The stated reason was an analogy to draft-v5 §5.2's
+chat-thumbnail rule ("the node stores no second copy of an image it already
+holds"). Re-reading §5.2's actual text: it is about the **upload directory**,
+and the chat case it names is one the **browser computes itself for free**
+(resizing an image it already decrypted) — the node has no work to do at all.
+A video thumbnail is not that: it needs `ffmpeg` to decode a frame, work only
+the node can do, and §6.10 already conceded this ("a frame grab is strictly
+less than the decoding the node already does for streaming"). So the
+per-device-cache-only conclusion was an **extension by analogy** to a general
+project preference for keeping the node free of derived state — a real,
+deliberate stance (E9, "no metadata store, ever"), but a policy call, not a
+consequence of §5.2 itself.
+
+**Revised here, on two concrete grounds raised by the operator:**
+
+1. **TMDB quota is per API token, not per device.** A token shipped inside
+ every install and called directly from every browser/Electron client
+ scales with the number of *devices* across every MeshBay deployment in
+ existence, hitting TMDB's rate limits far sooner than if each **node**
+ makes the calls on behalf of all its own members — the number of nodes is
+ the relevant denominator, and each node's own cache means one lookup per
+ unique title serves every member, indefinitely.
+2. **Thin/future clients benefit from a node that does more, not less.**
+ `desktop-client-v1.md` §7.6 already flags Android as hostile to *hosting*
+ a node but fine as a *client*; a phone with limited storage and battery
+ should receive a small ready-made JPEG and a JSON blob, not decode video
+ or hold its own multi-gigabyte thumbnail cache.
+
+**What is decided:**
+
+- **TMDB metadata and posters/backdrops are fetched, cached and served by the
+ node.** Clients never call TMDB directly, from any platform. This also
+ removes a question this design would otherwise raise: the native client's
+ tested invariant is *"issues no request outside `/v1/` and the signaling
+ socket"* (`desktop-client-v1.md` §8.1) — if the client never talks to TMDB,
+ that invariant is untouched, no CSP change, no new egress from the
+ renderer.
+- **Video thumbnails are generated by the node (unchanged) and now cached by
+ the node too**, not only handed over and forgotten.
+- **The cache lives in the node's own `data_dir`** — a SQLite store or blob
+ files beside `chat.db`/`audit.db`/`bundle_store.db` — **never inside a
+ shared root**. This is not a compromise; it is strictly better than the
+ operator's original `.thumbs`-in-the-media-folder idea, and for reasons
+ independent of the sovereignty argument (§9): the node's own `data_dir` is
+ always writable by the process regardless of platform, whereas a shared
+ root is routinely a read-only backup mount, a NAS share the node has no
+ write access to, or an external exFAT/NTFS drive where a stray `.thumbs`
+ folder is not hidden by default on Windows and would appear as an ordinary
+ visible folder full of preview images of a private group's content to
+ anyone who plugs that drive into a different machine. Keeping the cache
+ entirely off the shared volume avoids that whole class of problem for
+ free, with no loss of the benefit the operator asked for (one computation,
+ reused by every member, every platform).
+- **A visible, node-wide toggle exists to turn TMDB calls off entirely**
+ (`tmdb_enabled`, default on) for an operator who wants zero third-party
+ network traffic. This is new "outbound to a third party" behaviour the
+ node did not have before (§8), and it is disabled independently of the
+ Videos app itself being enabled.
+- **Cache lifecycle is tied to the index.** A file's thumbnail cache entry
+ and its file→TMDB-id mapping are pruned when the indexer removes that
+ file's `IndexEntry` (the same event that already prunes the index itself).
+ TMDB metadata JSON is cached per TMDB id with its own refresh window (30
+ days is a reasonable default — TMDB ratings/overviews do change) rather
+ than tied to any one file, since several files (episodes of a show) share
+ one show-level fetch.
+
+**Security read of this reversal (per adversary, full table in §8):** against
+the node's own operator, nothing changes — they already hold every file in
+plaintext and could compute all of this themselves; against another member or
+an outside attacker, the same GEK-proofed MNP channel that already serves
+files and streams now also serves metadata and thumbnails, no new
+authorization surface. Keeping the TMDB token server-side is a **strict
+improvement** over shipping it to every renderer: one place holds the
+credential, not every device.
+
+`desktop-client-v1.md` should get a one-line pointer added at O12 when this is
+implemented, noting the revision and pointing here — not done in this
+document, which only adds the new design.
+
+---
+
+## 3. Filename parsing — empirical study
+
+### 3.1 Method
+
+The operator's own library was used as the test corpus, listed on
+2026-08-23:
+
+| Set | Files | Notes |
+|---|---|---|
+| movies collection | 261 | flat directory, mixed naming conventions, ~15 years of scene/tracker releases |
+| TV shows collection | 1677 | 21 shows, nested up to `Show/SxxSeasonFolder/episode.mkv`, several non-standard layouts |
+| single-franchise collection | 9 | small, single-franchise sanity check |
+
+`guessit` 4.4.0 (pure Python, MIT, pip-installable, actively maintained —
+the de facto reference implementation for this exact problem, used by
+Sonarr/Radarr-adjacent tooling) was installed in a scratch venv and run
+against every bare filename. Its output was then validated for real against
+the live TMDB API using the token the operator provided, with a small
+Python harness: search by parsed title (+ year for movies), score the result
+against the query with `difflib.SequenceMatcher`, and — this matters, see
+§3.3 — retry with a more naively cleaned title before giving up.
+
+The harness and raw output are not committed; the corpus itself is a local
+media library external to the repo. Re-running this validation against
+the production parser, on the same corpus, is listed as an acceptance step
+in §6.
+
+### 3.2 Results
+
+| Metric | Movies (261) | TV shows, show-level (21 shows / 1677 episodes) |
+|---|---|---|
+| `guessit` extracts a non-empty title from the bare filename | **100%** | 98.7% raw; the 1.3% gap is entirely episode files with no show name in the filename itself (e.g. `S08E02.SUBFRENCH.720p.mkv`) — recovered to 100% once the parent folder supplies the title (§3.4) |
+| `guessit` extracts season+episode | — | 98.6% raw (same gap, same fix) |
+| **Confident TMDB match, real network call** | **92.3%** (241/261), first pass, before the fixes in §3.3 | **100%** (21/21 shows, 1677/1677 episodes weighted) |
+
+Series clears the 95% bar outright. Films does not on the raw first pass —
+every miss has an identified, generic (not per-title) cause, listed next.
+
+### 3.3 What the film misses were, and the fix for each
+
+| Cause | Example pattern | Fix |
+|---|---|---|
+| Real title lands in `guessit`'s `alternative_title` field, not `title` | A "Franchise Name + numeric code" filename (e.g. `Franchise.007.-.1999.-.The.Real.Subtitle...`) parses to `title="Franchise 007"`, `alternative_title="The Real Subtitle"` | **Search both fields**; prefer whichever gets a confident TMDB result |
+| Hyphenated proper noun split at the hyphen | A hyphenated title immediately followed by a parenthesized year (e.g. `Hero-Name.(2002)...`) parses to `release_group="Hero"`, `title="Name"` (a known `guessit` tokenizer quirk before a parenthesized year) | Fallback tier: retry with **all** of `.`, `_`, `-` normalized to spaces (the naive fallback used in this test only normalized `.`/`_`, which is why this specific case still missed even with a fallback in place — noted as a bug in the test harness itself, not the parser design) |
+| French edition vocabulary not in `guessit`'s (English-centric) edition list | Titles carrying `Version.Longue`, `remasterise`, `Original.Version` as a release tag | Extend the edition/tag stripping list with `version longue`, `version intégrale`, `remasterisé`/`remasterise`, `non censuré`, `original version` |
+| Trailing sequel digit kept, real TMDB title has none or uses Roman numerals | A sequel filename ending in a bare `1`/`2` where the real TMDB title has no trailing digit, or uses a Roman numeral instead | Additional retry: same title **without** the trailing digit, and with it converted to a Roman numeral |
+| The test's own scoring picked the wrong result | A show referenced by its acronym in the folder name: TMDB's own search genuinely returns the right show first for that acronym — confirmed by a direct re-query — but the harness re-ranked TMDB's top-5 by local string similarity and preferred a coincidentally closer-looking unrelated show | **Trust TMDB's own top-ranked result** rather than re-ranking locally; use local similarity only as a confidence signal for a "not the right one? search again" affordance, never to override the choice |
+| No TMDB entry exists under any reasonable query | An obscure abbreviated title, unresolved even manually | Inherent, expected. Falls to the mandated fallback: thumbnail + minimally cleaned filename (§4.3) |
+
+None of these are per-title hacks; each is a generic rule. Applying them was
+not re-run against the full corpus for this planning pass (that means writing
+the production-quality parser, which is implementation, not planning) but
+every miss in the 261-file set is accounted for by one of the six rows above,
+which gives good confidence the >95% bar holds once built — to be confirmed
+empirically in §6's acceptance step, on the same corpus, before shipping.
+
+### 3.4 Directory context — the one thing bare-filename parsing cannot give you
+
+Two real findings from the series corpus, both because a bare filename does
+not carry the folder it lives in:
+
+- **Abbreviated folder names need corroboration from a file inside them.**
+ A show folder named after its acronym rather than its full name (with
+ episode files inside following the usual `Show.Name.SxxExx...` pattern) —
+ a design that only trusts a strict "does this look like the real title"
+ gate on the folder name alone is fragile the moment the query is
+ ambiguous, even when TMDB's own search actually does resolve the acronym
+ correctly (verified directly). **Rule:** derive the show title primarily
+ from `guessit` run on a representative **episode filename** inside the
+ folder (which reliably contains the full show name, per the table in
+ §3.2), and use the folder name only as a grouping key and a
+ manual-override anchor — never as the sole source of the TMDB query.
+- **Non-standard season folders are real, and TMDB already models them.**
+ One show in the corpus numbers its episodes with a bare leading integer
+ and no `SxxExx` token anywhere, uses a French word (not "season") plus a
+ Roman numeral as its season-folder name, and keeps a sibling folder for
+ specials. Querying TMDB directly confirmed this is not an obstacle to
+ work around — **TMDB's own season list for that show uses the identical
+ non-English season-naming scheme**, with a `season_number: 0` entry for
+ specials that the sibling folder maps onto exactly. **Rule, kept generic:**
+ when no `SxxExx` token is found, look for a season-like ancestor folder
+ matching `season|saison|<local-language season word>\s*(\d+|[ivx]+)`
+ (Roman numerals included, vocabulary configurable per locale), and treat a
+ folder matching `bonus|extras|specials?` as season 0. Match that folder's
+ name against the show's own TMDB season list rather than assuming a
+ numeric convention everywhere.
+
+### 3.5 What the parser does **not** need to guess
+
+The node already probes real technical facts from the file itself via
+`ffprobe` for streaming (`_probe_video()`,
+`meshbay_node/transport/webrtc_server.py`) — resolution, codec, duration.
+**None of that should come from the filename.** A file mislabeled `1080p`
+that is actually 720p is a real, observed failure mode of filename-derived
+metadata; ffprobe is authoritative and already runs on the node. The parser's
+job is narrowed to exactly what TMDB search and the flat view need: title,
+year, season, episode — a materially smaller surface than `guessit`'s full
+scope (it also parses codecs, languages, release groups — useful for
+debugging, not needed by either view).
+
+### 3.6 Where the parser runs
+
+**On the node, in Python, using `guessit` as a real dependency** (pure
+Python, MIT license, no native extensions — trivially addable to
+`packages/meshbay-node/pyproject.toml`), wrapped by a small
+`meshbay_node.indexer.title_parse` module implementing the rules in §3.3–3.4.
+This follows directly from §2: since the node already owns the TMDB call, it
+is the natural place to own the parse step too — no filename-parsing code is
+needed in the browser/Electron client at all. The parsed `(title, year,
+season, episode)` and a best-effort `display_title` (the flat-mode fallback:
+extension stripped, `_`/`.` replaced by spaces, used when parsing confidence
+is low) travel to clients as ordinary index fields (§5), computed once by the
+indexer and cached the same way size/hash already are.
+
+---
+
+## 4. The two view modes
+
+### 4.1 Mode A — poster grid (TMDB), default
+
+Browse by poster, click through to a detail view (poster,
+year, resolution + duration from the real probe, overview, cast, director,
+a **Play** button that hands off to the existing `video-player.js`/
+`stream_req` path unchanged). Falls back to a thumbnail-only card, with just
+the cleaned filename, when the node's TMDB lookup returns nothing or a
+low-confidence match (§3.3's last row) — exactly the operator's own fallback
+rule.
+
+Grouping for the poster grid: one card per **movie**, and one card per
+**show** (not per episode) that expands to a season/episode picker on click,
+using TMDB's own season list (§3.4) — the standard model for this kind of
+browser, and requiring no new grouping logic beyond what the folder structure
+and the node's per-show TMDB fetch already give.
+
+### 4.2 Mode B — flat, thumbnail-based, no TMDB
+
+Alphabetical, folder-driven, exactly as specified: a folder of movies is a
+flat list of thumbnail cards; a folder that turns out to be a show is a list
+of season subfolders (or files directly, if there's no season layer), each
+expandable, never TMDB-enriched. Per-card info comes entirely from the
+(now-populated, §5) index entry: `display_title`, `duration`, resolution
+(from the technical probe fields), file `size` — no network call, no TMDB
+dependency, and it keeps working with TMDB disabled node-wide.
+
+### 4.3 The toggle between modes
+
+**Per-device, `localStorage`**, same mechanism already used for the video
+player's resume position and the i18n language choice — a display
+preference with no security or authority implication, so it does not need
+the `roster.db` + signed-op treatment `member_upload`/`apps_enabled` get.
+Default: Mode A, per the operator's own spec.
+
+### 4.4 Scoping to a folder — added 2026-08-24, this was in §0 and got missed
+
+The operator's original ask was explicit: pick an already-shared directory,
+possibly a subdirectory of one already exposed — not necessarily the whole
+group. This did not make it into the plan when the rest of this document was
+written; caught only once the feature was in real use against an existing
+group.
+
+**Unlike the mode toggle above, this is not a per-device display
+preference** — it decides what *every* member's Videos tab shows, the same
+class of decision `apps_enabled`/`member_upload` already are. So it gets the
+same treatment: a per-group setting in `roster.db` (`video_root`, empty
+string meaning "the whole group index" — today's behaviour, unchanged),
+changed by a new signed op (`OP_VIDEO_ROOT`, `video_root`/`video_root_ack`
+on the wire) that any already-connected member is told about without
+reconnecting, exactly like `apps_enabled_ack`.
+
+**Where the operator sets it: Files, not Settings.** The folder being
+scoped is something the operator is already looking at while browsing —
+Files gets a toolbar action that toggles between "Set as Videos root" (any
+other folder) and "Clear Videos root" (the folder that already is one, or
+the top level when one is set deeper in the tree). No separate path-picker
+UI was built; browsing *to* the folder in the browser used for browsing
+everything else in the group is the picker.
+
+**Validation happens before a signature is ever asked for**, the same
+principle as apps_enabled's "empty set refused up front": a candidate path
+is resolved against the group's actual `RootSet` (the same traversal-safe
+resolution `entry_abs_path`/directory creation already use) and must name a
+real, currently-readable directory, or the request is refused immediately
+— a stale or mistyped path never reaches the operator's browser as a
+signing prompt.
+
+The client-side filter is a plain path-prefix test against `IndexEntry.path`
+(already root-relative, e.g. `"Séries/OVNI/S1/ep01.mkv"`) — no new index
+field, no server-side filtering; the node still sends the whole index, and
+narrowing to the configured folder is exactly the same kind of view-over-
+the-index work Files' own current-folder filter already does.
+
+---
+
+## 5. Protocol and index changes
+
+### 5.1 `IndexEntry` already reserves two of the three fields this needs
+
+`meshbay_common/protocol.py:133` — checked directly, and both fields are
+declared but **never populated by the indexer nor consumed by any client
+today**:
+
+```python
+duration: int | None = None # seconds, for media
+thumb_hash: str | None = None # blake3 of thumbnail
+```
+
+This is smaller than a fresh addition: `duration` needs the indexer to run
+(the existing) `ffprobe`-based probe at index time and store the result;
+`thumb_hash` needs a thumbnail to have been generated and cached, addressed
+by its own blake3 the same way a file is addressed by its `id` — which
+opens a clean reuse (§5.3). **New**, not yet present: `width`/`height`
+(resolution) and the parsed `display_title`/`season`/`episode` fields from
+§3. All are additive fields on the same dataclass, so this is a MNP
+**MINOR** bump (0.4 → 0.5, same class of change as the multi-root 0.2→0.3
+bump), not a breaking one — an older client simply doesn't render the new
+fields.
+
+### 5.2 Index-time cost, bounded the same way thumbnails already are
+
+Probing every video file with `ffprobe` at index time is real cost on a
+large library. `desktop-client-v1.md` §6.10 already established the pattern
+for exactly this shape of problem (thumbnails): **a small, bounded worker
+pool, its own short timeout, never sharing the streaming pool**
+(`max_concurrent_streams`), draining stdout and releasing the slot
+regardless — the same lesson `CLAUDE.md` records repeatedly for anything
+that shells out to `ffmpeg`/`ffprobe`. The probe (and the `guessit` parse,
+which is cheap by comparison) runs asynchronously after a file is first
+seen; the file appears in the index immediately with size/hash only, and an
+`index_sync` delta fills in the technical/parsed fields once ready — no
+scan is blocked waiting for it.
+
+### 5.3 Thumbnail delivery — reuse the chunk path
+
+Since `thumb_hash` is a blake3 exactly like a file's own `id`, the cleanest
+delivery is to **serve a thumbnail through the existing chunked
+`file_req`/`ChunkRequest` path**, with the node's chunk handler resolving a
+requested id against either a real file or the thumbnail cache. This reuses
+transport, GEK-scoped encryption, and backpressure handling that already
+exist and are already tested, rather than inventing a parallel mechanism. If
+that reuse proves awkward against the concrete dispatch code once someone
+is in it, a dedicated `thumb_req`/`thumb_resp` pair is a perfectly fine
+fallback — noted as an implementation choice, not a decision this document
+needs to force.
+
+### 5.4 New request/response: TMDB metadata
+
+A JSON blob, not chunked file content, so a plain request/response pair
+fits better than reusing the chunk path:
+
+```
+media_meta_req { path } # client → node, root+relpath already known from the index
+media_meta_resp { tmdb_id, title, original_title, overview, poster_thumb_hash,
+ backdrop_thumb_hash, release_date | first_air_date,
+ genres[], vote_average, runtime,
+ cast[] (name, character), director,
+ season?, episode? (for a TV request), confidence }
+```
+
+`poster_thumb_hash`/`backdrop_thumb_hash` point back at §5.3's mechanism —
+the node fetches the image from `image.tmdb.org` once, caches it under its
+own blake3 in `data_dir`, and clients pull it exactly like a video
+thumbnail. No client ever contacts `image.tmdb.org` or `api.themoviedb.org`
+directly (§2).
+
+**Bug found live, 2026-08-24**: `transport.js`'s generic reply routing
+resolves an untyped response against whichever request is *oldest pending*
+— correct for every request the client only ever has one of in flight, but
+`media_meta_req` is fired once per visible poster-grid tile, several at a
+time (apps.md §5's virtualization), exactly the shape `file_req` already
+special-cases with a `chunk:{file_id}:{chunk_index}` key. Without the same
+treatment, two concurrent lookups could resolve to each other's answer
+whenever their responses arrived out of order — reproduced live as two
+shows' posters/season lists swapping across reloads. Fixed by keying
+`media_meta_req`/`media_meta_resp` by `path`, the same way `file_req` is
+keyed by id+chunk.
+
+**Bug found live, 2026-08-24**: a poster-grid tile used to render its raw
+per-file frame immediately (already known from the index, no network round
+trip needed), then swap it for the TMDB poster once `media_meta_resp`
+arrived — and, for a §V6 merge candidate, briefly render as its own
+standalone card before collapsing into its sibling once both resolved to
+the same `tmdb_id`. Individually harmless, but visibly distracting in
+sequence (reported as "hurts the eyes"). `video-app.js`'s `PosterCard` now
+withholds a tile's content entirely — title, subtitle, and image alike —
+until both the metadata lookup and the chosen image (poster or fallback
+frame) have actually settled, showing a spinner in the meantime; a slow
+lookup (a very large, freshly-scanned library) just means the spinner
+stays a little longer. This does not (and structurally cannot, without
+giving up per-tile virtualization, apps.md §5) suppress every trace of a
+§V6 merge: a rare split-release show can still show two fully-resolved
+cards for an instant before they collapse into one, since the merge is
+only known once both siblings' independent lookups return.
+
+**Bug found live, 2026-08-24**: an operator reported the poster grid
+hanging on spinners forever on a *second* visit to the same group's Videos
+tab within one page session (revisit the tab, or a full reload — either
+way, reproducibly). Two real, separate causes:
+
+1. `_fetch_and_cache_poster` (`webrtc_server.py`) downloaded a TMDB poster/
+ backdrop from `image.tmdb.org` on *every* `media_meta_req`, even for a
+ file whose TMDB match was already cached — nothing had ever checked "is
+ this poster_path already fetched" before making the network call, since
+ the content-addressed `thumb_hash` isn't known until the bytes already
+ are. Fixed by keying the `thumbs` cache on a synthetic `tmdb:{poster_path}`
+ id, checked before the request (`media_cache.get_thumb_hash_by_file_id`).
+2. §V6's merge used to mint a brand-new key (every constituent title,
+ joined) the instant two raw groups turned out to be the same show —
+ destroying an already-visible, already-resolved card and rebuilding one
+ from scratch. Fixed by keying the merged entry as `groups[0].title`
+ instead: the overwhelmingly common (unmerged) case is unaffected, and a
+ merge now updates an existing card's props rather than replacing it.
+
+Neither of those was the actual hang, though both were real, worth fixing,
+and made it worse. The hang itself was a race in `PosterCard`'s own
+"nothing shown until ready" gate (§ above): `imageReady` was a `useState`
+reset to `false` by its *own* `useEffect` keyed on `posterHash`, and set to
+`true` by `MediaThumb`'s `onReady` callback. The instant `metaReady` flips
+true, `posterHash` jumps from the raw fallback frame to the resolved
+poster **in the same commit** that mounts `MediaThumb` for it. If that
+poster's bytes are already in `MediaThumb`'s session-lifetime blob cache —
+exactly true on a same-tab revisit, since that cache is never cleared —
+its `onReady` fires *synchronously* from that mount effect. Effects run
+children-first, so `PosterCard`'s own "reset on posterHash change" effect
+fired *after* it in that same commit, unconditionally overwriting the flag
+back to `false` — with nothing left to ever set it `true` again, since
+`posterHash` doesn't change a second time. The card was stuck as a
+spinner forever despite the image already being fully loaded and cached.
+Fixed by deriving `imageReady` from a direct comparison —
+`readyHash === posterHash`, where `onReady` sets `readyHash` — rather than
+a second, separately-timed writer of the same boolean: whichever of
+"meta resolves" and "image confirms ready" happens first, in whichever
+order effects run, the next render sees the same, correct answer. A cold
+fetch (no reload, or a genuinely new poster) never hit this at all,
+because `onReady` there fires asynchronously, in a later commit, well
+after the reset had already settled — which is exactly why it only ever
+showed up on a revisit, and only after the two fixes above had already
+made a revisit's fetch fast enough to *be* synchronous.
+
+### 5.5 Node-wide config, not per-group
+
+TMDB is one operator's budget and one credential, not a per-group concern,
+so it does not belong in `group_settings` keyed by a real `group_id`. The
+existing precedent for node-wide operator state stored in that same table is
+already established (`desktop-client-v1.md` §6.3: *"the precedent exists:
+`roster.get_member("", user_id)` already authorizes the operator
+node-wide"*) — so `tmdb_enabled` and an optional `tmdb_api_token` land in
+`group_settings` under the sentinel `group_id=""`, changed by a signed
+operator op (`OP_TMDB_CONFIG`, same shape as `OP_MEMBER_UPLOAD`/
+`OP_APPS_ENABLED`), and broadcast in `node_status` (the node-wide channel,
+as opposed to the per-group `handshake_ack`) so every connected client,
+across every group, sees whether TMDB is on without reconnecting. Absent
+means the shipped default token, present with a token means "use this one
+instead" — the same "absent means the old behaviour" discipline
+`member_upload`/`apps_enabled` already follow.
+
+**Added 2026-08-24**: a third field, `tmdb_language` (e.g. `"fr-FR"`),
+travels the same way — one node-wide setting, not a per-viewer request.
+Live against a real French-language show, the node's TMDB matches came back
+in English by default (`"UFOs"` for a show whose real title and audience
+are French) purely because no language was ever requested; TMDB honors a
+`language` parameter on every search/details call, so `tmdb.py`'s `_get()`
+now attaches whichever language is configured (or none, TMDB's own English
+default, if unset) to every request transparently — no change needed at
+any call site. The Settings UI pre-fills this field from the operator's own
+current browser language (a sensible default, not an attempt to read their
+hub-account profile, which the node has no way to learn), and the operator
+can still change it. This does not by itself fix an outright wrong match
+(a real, separate accuracy question, §3) — only which language a *correct*
+match's title/overview/poster come back in.
+
+**Correction, same day**: it turned out to fix more than that. Live, the
+French query didn't just translate the operator's example show — it
+resolved season 1's match correctly for the first time (previously a wrong,
+unrelated film in the same fallback slot, §3's kind of miss). A likely
+explanation, not a guaranteed one: TMDB's own search ranking can differ by
+language, and the French title happened to disambiguate what the English
+query could not. Not a substitute for §3's accuracy work — a French-only
+title with no ambiguity would not be helped this way — but a real,
+observed improvement worth having independent of translation.
+
+**Per-field English fallback, added same day**: TMDB does not fall back to
+English server-side for a field with no translation in the requested
+language — it returns `""` (or `[]`) for that one field, confirmed live
+(a French query left `overview` empty for a title TMDB has no French copy
+of, despite a good poster and cast). The TMDB *website* papers over exactly
+this gap client-side; `_tmdb_build_meta` (webrtc_server.py) now does the
+same — a second, English-forced details fetch only when a checked field
+(`overview`, `poster_path`, `genres`) comes back empty, merged field by
+field so a good localized field is never overwritten by the fallback.
+
+### 5.6 `ALLOWED_APPS`
+
+`webrtc_server.py:1629` — add `"video"` to the frozenset. `DEFAULT_APPS`
+(`roster.py`) is deliberately **left unchanged** (`("chat", "files")`): a
+brand-new group does not get Videos for free, since it is the first app that
+makes outbound third-party network calls (once TMDB is on) — the operator
+opts a group into it explicitly via the existing Settings checklist, same
+gesture as any other app.
+
+### 5.7 Season-specific overview, and correcting a wrong automatic match
+
+Found live, 2026-08-24, on a real 3-season show: TMDB's own search
+consistently resolved every season's folder to the same *season-3-specific*
+promotional entry — a wrong `tmdb_id`, not a MeshBay grouping bug (§3.3's
+`_best_match` deliberately trusts TMDB's own top result, per that section's
+own postmortem). Two independent problems, two independent fixes:
+
+**A show's own `overview` (§5.4) is one static field that does not
+necessarily describe every season alike.** New request/response pair, read
+lazily per selected tab (same virtualization discipline as `media_meta_req`,
+never fetched for a season the operator hasn't clicked):
+
+```
+season_meta_req { tmdb_id, season } # tmdb_id is whatever media_meta_resp
+ # already resolved — never re-searched here
+season_meta_resp { tmdb_id, season, confidence, name, overview, air_date,
+ poster_thumb_hash }
+```
+
+`video-app.js`'s `VideoDetailModal` shows a season tab bar
+(`Season 1` / `Season 2` / … / `Specials`) whenever a show has more than one
+season, defaulting to whichever season the representative episode belongs
+to. Selecting a tab both filters the episode list to that season and swaps
+in that season's own `overview`/`air_date` — falling back to the show-level
+`overview` when a season's own comes back empty (TMDB has no season-level
+text for every show), the same per-field fallback shape §5.4's English
+fallback already established, just one level further down when there is
+nothing at all to show otherwise.
+
+**An operator needs a way to correct a wrong match** when TMDB's own
+top-ranked result is simply wrong — no amount of local re-ranking fixes
+this (§3.3's last row is exactly the mistake that would repeat). Two more
+message pairs, the second an admin op:
+
+```
+tmdb_search_req { query, media_type } # media_type: "movie" | "tv"
+tmdb_search_resp { query, media_type, results: [{ tmdb_id, title, year,
+ poster_thumb_hash }] }
+
+tmdb_override { path, tmdb_id, media_type } # admin op, subject =
+ # "path={path},tmdb_id={tmdb_id},media_type={media_type}"
+tmdb_override_ack { path, tmdb_id, media_type } # broadcast to every connected peer
+```
+
+`tmdb_search_req` is deliberately **not** admin-gated — it is read-only (the
+same TMDB lookup the automatic matcher already performs on everyone's
+behalf) and returns nothing that isn't already visible in the search
+results a browser could get by hand. `media_type` is echoed back in the
+response, not only the query: a client that fires a movie search and a tv
+search for the same title in close succession needs it to tell the two
+responses apart for keyed matching (`transport.js`), the same reordering
+hazard §5.4's first postmortem already covers for `media_meta_req`.
+
+`tmdb_override`, once signed, is applied to **every index entry sharing the
+resolved file's `display_title`** (`webrtc_server.py`'s
+`_admin_exec_tmdb_override`) — the same grouping the poster grid itself uses
+(§3.4/V6) — not just the one file the operator happened to right-click,
+so the correction actually sticks for every episode of the show, and
+broadcasts a `tmdb_override_ack` to every connected peer so an already-open
+grid/modal picks up the change without a reconnect (`video-app.js`'s
+`useMediaMeta` subscribes to a module-level generation counter, bumped on a
+successful override, that forces every mounted tile/modal to refetch).
+
+New adminop: `OP_TMDB_OVERRIDE = "tmdb_override"` (`adminop.py`), following
+`OP_VIDEO_ROOT`/`OP_TMDB_CONFIG`'s exact shape — signed for the same reason:
+`media_cache` is shared node-wide, not per-viewer, so an unsigned override
+would let any member vandalize another show's metadata for everyone.
+
+**Bug found live, 2026-08-24**: the search overlay (a second, later
+`.video-overlay` sibling, opened on top of the detail modal) rendered
+completely invisibly the first time it shipped — present in the DOM
+(confirmed via the accessibility tree), inert on screen. Cause: its own
+`z-index: 1` lost to the base `.video-overlay` class's `z-index: 200` that
+the detail modal underneath it already used; both are `position: fixed`,
+so an explicit z-index always wins over DOM order regardless of which
+element mounted later. Fixed by giving `.video-search-overlay` an explicit
+`z-index: 210`.
+
+---
+
+## 6. Node-side implementation, concretely
+
+| Piece | Where | What |
+|---|---|---|
+| Title/season/episode parse | new `meshbay_node/indexer/title_parse.py` | Wraps `guessit`, applies §3.3–3.4's rules, returns `(display_title, year, season, episode, confidence)` |
+| Technical probe at index time | `meshbay_node/indexer/indexer.py`, reusing `_probe_video`'s ffprobe call from `webrtc_server.py` (extend `-show_entries` with `width,height`) | Populates `IndexEntry.duration`, new `width`/`height` |
+| Bounded probe/thumbnail pool | `meshbay_node/transport/webrtc_server.py`, sibling to the streaming pool | Small worker count, short timeout, drains stdout, never touches `max_concurrent_streams` |
+| TMDB client | new `meshbay_node/tmdb.py` | `httpx` (already a dependency) against `api.themoviedb.org`; resolves token via §5.5, falls back to the shipped default |
+| Caches | `data_dir/media_cache.db` (SQLite, alongside `chat.db`/`audit.db`/`bundle_store.db`) | `(content_hash) → tmdb_id` mapping; `(tmdb_id) → metadata json`, TTL 30 days; `(thumb_hash) → jpeg bytes`, pruned when the owning file leaves the index |
+| Operator config | `roster.py` `group_settings`, `group_id=""` | `tmdb_enabled`, `tmdb_api_token`; `ops.py` gains `set_tmdb_config(state, enabled, token=None)`, one `_op(...)` line, same adapters (loopback/CLI/MNP) as everything else in `ops.py` |
+| `pyproject.toml` | `packages/meshbay-node/pyproject.toml` | add `guessit>=4.4` |
+
+## 7. Client-side, per `apps.md`'s checklist
+
+1. `video-app.js` — receives the standard props (§2 of `apps.md`); internally
+ two presentational sub-views (poster grid / flat), switched by the
+ `localStorage` toggle (§4.3). Requests `media_meta_req` lazily, only for
+ currently-visible tiles in the poster grid (same virtualization discipline
+ `apps.md` §5 already calls out as mandatory for a grid of many tiles), and
+ thumbnails the same way.
+2. Register `{ key: "video", icon, labelKey: "group.tab_video", Component: VideoApp }` in `apps.js`.
+3. `ALLOWED_APPS` (§5.6).
+4. `group.tab_video` in all ten `static/locales/*.js`.
+5. `webapp.py`'s `_ASSETS` tuple — add `video-app.js`.
+6. `test_hook_ordering.py` (`STATIC_FILES`) and `test_transport_contracts.py`
+ (`SPLIT_FILES`) — add the new file to both.
+7. `npm run sync-ui` in `meshbay-client`.
+
+No hub change, no protocol change beyond §5's additive fields and two new
+message pairs — consistent with every other app added under this mechanism.
+
+---
+
+## 8. Security — per adversary
+
+| Claim | Passive hub | Active hub | Malicious node operator | Another member |
+|---|---|---|---|---|
+| TMDB token confidentiality | ✅ unreachable | ✅ unreachable | held by the operator, who could always query TMDB manually anyway — no new exposure | ✅ never transmitted to members |
+| Metadata/thumbnail delivery | ✅ unchanged transport | ✅ unchanged transport | sees it already (holds the plaintext file) | same GEK-proofed MNP channel as files/streaming — no new authorization surface |
+| New outbound traffic (node → TMDB) | — | — | the operator already controls their own node's egress | — |
+| Stale cache after file deletion | — | — | pruned on the index's own deletion event (§2); until built, a deleted file's thumbnail/metadata could persist on the node's disk after removal from the share — a real, addressable gap, not a design flaw, and called out here so it is not forgotten | — |
+
+**The claim this design supports:** moving TMDB/thumbnail work to the node
+adds no new authorization boundary — everything travels over the channel
+that already proves group membership and GEK possession — and *removes* a
+credential (the TMDB token) from every client that would otherwise hold it.
+
+**The claim it must not make:** that this is free. It is new node behaviour
+(egress to a third party, a new disk-resident cache with a real deletion
+obligation) that did not exist before, and both are called out explicitly
+rather than left implicit.
+
+## 9. Filesystem/Windows — why §2's choice avoids the trap
+
+`desktop-client-v1.md` §6.8/§7.5 already established exFAT/NTFS/Windows as
+the **common** case, not an edge case. Had the cache lived inside a shared
+root (the operator's original `.thumbs` idea), it would have inherited every
+one of those constraints for no benefit:
+
+- A read-only backup mount or a NAS share the node has no write access to
+ would refuse the write outright — not a Windows quirk, a real failure on
+ any platform.
+- Windows does not hide a leading-dot folder the way Unix does; `.thumbs`
+ would need an explicit "hidden" attribute set (and kept set across
+ recreation), or it shows up as an ordinary visible folder in Explorer —
+ previews of a private group's content, visible to anyone who plugs that
+ drive into a different machine.
+- It would need to be filtered out of every directory listing path
+ consistently (indexer, root-name collision checks, watcher
+ reconciliation) — one more place a future change could forget it.
+
+Keeping the cache in the node's own `data_dir` (§2) — the same location
+`chat.db`/`audit.db`/`bundle_store.db` already use on every supported
+platform — sidesteps all three for free, with no loss of the benefit the
+centralization was for (one computation, reused by every member).
+
+---
+
+## 10. Open items
+
+| # | Item | Why it is not decided here |
+|---|---|---|
+| V1 | Exact wording/UX of the "TMDB disabled" state in Mode A (Mode B unaffected) | Product/UI choice, not architectural |
+| V2 | Whether `poster_thumb_hash`/`backdrop_thumb_hash` reuse the chunk path (§5.3) or get a dedicated pair | Depends on how the concrete `file_req` dispatch code reads once someone is implementing against it |
+| V3 | TMDB metadata cache TTL (30 days proposed) | Needs no more than a config constant; not worth deciding without seeing real staleness complaints |
+| V4 | Multi-audio-track / subtitle-track surfacing in the detail view | Out of scope for this pass — `video-player.js`'s existing track handling is unchanged; Videos only adds discovery and metadata |
+| V5 | `Music`/`Photos` apps | Explicitly out of scope, per `apps.md` — this document only builds `video-app.js` and the shared node-side machinery (TMDB client, thumbnail cache, title parser) that a future audio/photo app could also reuse |
+| ~~V6~~ | ~~Two folders of the same show, named by different release groups, can produce two separate poster-grid cards~~ | **Closed, 2026-08-24.** Rather than fuzzy title matching (real design decision, still deferred), `PosterGrid` now merges raw show-groups client-side once each group's TMDB lookup resolves to the same confident `tmdb_id` (`onMetaResolved` reports each `PosterCard`'s resolved meta upward; a `useMemo` groups by id, combining episodes/seasons into one card). This only merges what TMDB already agrees is one show — a genuinely unmatched show still gets its own card, which is correct. Confirmed live: `OVNIs.S01...`/`Ovnis-S01...` now render as a single "OVNI(s)" card with both seasons |
+| ~~V7~~ | ~~A show's automatic TMDB match can be wrong at the show level (not just mis-ranked locally), and a wrong match's `overview` can read as scoped to one season~~ | **Closed, 2026-08-24 — §5.7.** Per-season tabs (own `overview`/`air_date`, falling back to the show-level text when empty) plus an operator-only "Fix match…" search-and-correct affordance, applied to every file sharing the resolved `display_title`. Confirmed live on the operator's real "War of the Worlds" 3-season show, itself matched to a wrong season-3-specific 1988 promotional TMDB entry: season tabs correctly filtered episodes and swapped in each season's own air_date, and the search overlay returned real TMDB candidates for a manual correction |
+
+## 11. Acceptance before shipping
+
+1. Re-run the §3 validation (real TMDB calls, same corpus, same script
+ methodology) against the actual production parser and `tmdb.py`, not the
+ scratch harness used for this planning pass. Keep the script in `QE/`
+ (not versioned, per repo convention) as a regression check for future
+ parser changes.
+2. Confirm the >95% bar on `Films/` with the §3.3 fixes applied for real,
+ not just argued.
+3. Verify cache pruning on file deletion actually fires (§2, §8) before
+ calling the durable-cache reversal complete — an untested claim here
+ would repeat the exact "documented but not built" pattern this project's
+ own history (CLAUDE.md) warns about repeatedly. Covered by an automated
+ test (`test_media_cache.py`), not just argued.
+
+**Done, 2026-08-24**: the full pipeline ran live against the operator's real
+node and library (a real show, real TMDB token) — probe/parse/thumbnail
+enrichment, TMDB search/match/credits, poster fetch-and-cache, chunk-path
+delivery, and the poster-grid/flat-list/detail-modal UI, end to end, no
+errors. One real bug was found and fixed this way that no unit test would
+have caught: `DirectoryIndexer.initial_scan()` (run once at startup) never
+itself triggers the `on_change` callback enrichment hangs off — daemon.py's
+`_bg_scan` now explicitly calls `_on_index_change` after the startup scan
+completes, or every file already on disk at boot (the common case) would
+never get enriched, only files added later while the node runs. Covered by
+`test_startup_scan_enrichment.py`. The exhaustive §3 corpus re-run (item 1
+above, item 2's >95% bar) is still open — this was a live smoke test of the
+mechanism, not the accuracy study.