summaryrefslogtreecommitdiffstats
path: root/docs/mediacenter.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/mediacenter.md')
-rw-r--r--docs/mediacenter.md1035
1 files changed, 0 insertions, 1035 deletions
diff --git a/docs/mediacenter.md b/docs/mediacenter.md
deleted file mode 100644
index a1dbe2f..0000000
--- a/docs/mediacenter.md
+++ /dev/null
@@ -1,1035 +0,0 @@
-# MeshBay — Videos application (design)
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the Videos application design; its design
-> content now lives in §9.7, with the node-side caching decision in §6.5.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **built** — `video-app.js`, the node-side matching, the metadata and
-> thumbnail caches all shipped, and §10.1–§10.7 record fixes made against a real
-> library since. This header said "not implemented" long after it was.
-> 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. `"Series/SomeShow/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 (an English title 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 picker
-(`Specials` / `Season 1` / `Season 2` / …) whenever a show has more than one
-season, defaulting to the lowest-numbered season present (`defaultSeason`;
-specials only when there is nothing else). It defaulted to the representative
-episode's season until §10.5, which is not the same thing at all. Selecting a season both filters the episode list to it 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. It was a row of tabs until §10.4; it is a
-menu now, for the reasons given there.
-
-**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: two release-group spellings of one show's season folders now render as a single 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 a real 3-season show, itself matched to a wrong season-specific promotional TMDB entry: season tabs correctly filtered episodes and swapped in each season's own air_date, and the search overlay returned real TMDB candidates for a manual correction |
-
-### 10.1 Movie-matching bugs — a batch found live (2026-08-29)
-
-Several wrong poster-grid matches on a real library: a two-volume film's second part
-matched the first; a numbered sequel matched a same-year making-of documentary (TMDB's
-real entry uses a Roman numeral); two entries of one franchise matched a single early
-entry whose *localized* TMDB title is the franchise name, and a third matched nothing.
-One mechanism: `_tmdb_search` returned the first candidate query whose title-similarity
-ratio merely cleared 0.6, before `alternative_title` / the Roman-numeral variant was
-tried. **Fixed** (branch `fix/videos-tmdb-matching`): scored retry ladder + year-exact
-rescue for a sub-0.6 top hit (§3.3's "trust TMDB's own ranking" still holds per query —
-no local re-rank of any one result list); `volume`/`part` folded into `display_title`;
-movie "Fix match" applied to the one file, not the whole `display_title` group;
-`ops.rematch_video` + a rename now drop stale cached matches (`media_cache.tmdb_override`
-shields manual corrections); the movie detail modal shows the source filename + resolved
-TMDB id, and an unmatched poster gets a badge.
-
-**V8–V13 — the follow-ups, now done** (branch `feat/videos-matching-v8-v13`):
-
-| # | Item | Done |
-|---|---|---|
-| ~~V8~~ | The TV/show branch of `_tmdb_search` used the old "first over 0.6 wins" shape | Both branches share one `_tmdb_ladder` — score every candidate, keep the best, fast-path a confident primary hit. `title_parse.year_in` lifts a year off a show folder name; `title_parse.clean_query` de-dots a folder-derived title without `naive_title`'s extension-strip |
-| ~~V9~~ | `_best_match` trusted `results[0]` per query unconditionally | Optional `year`: when the top result is not a confident textual hit (< 0.6) and a year was requested, a different result of that exact release year is preferred. A confident top hit is never overridden |
-| ~~V10~~ | `sequel_variants` narrow (trailing digit 2–9, arabic→roman only) | Widened: digit↔Roman both ways, spelled-out indices (one/two…, un/deux…, ordinals), a "Part N" / "Chapitre N" wrapper. Still empty for a trailing word that is not an index or a 4-digit year |
-| ~~V11~~ | Extra TMDB calls in the 0.6–0.85 band | When the primary hit is decent (≥ 0.6) and there is nothing more specific to try (no `alternative_title`, no sequel variant), the ladder returns without the extra requests |
-| V12 | Movies not merged in the poster grid | Built (`mergedMovies` by resolved `tmdb_id`) then **reverted** (`revert/video-movie-merge`): with a poor matcher it *fuses distinct films* — every numbered entry of a saga that TMDB resolves to the same base id collapses into one card (seen live: a 9-film saga, two unrelated same-title movies). A merge that trusts `tmdb_id` needs the match to be right first; that is the V8–V11 / V9 work, still not fully there. Movies now render one card per file again; the detail modal has a single Play button; `video.versions` key removed. |
-| ~~V13~~ | Per-card "re-match this one file" button | `OP_TMDB_REMATCH` / `MNP.TMDB_REMATCH` (MNP 0.13, additive) → `media_cache.drop_tmdb_match` (forgets the match *and* the override marker). Signed like `tmdb_override`. Button next to "Fix match" in the detail modal; `transport.rematchTmdbMatch`; `video.rematch_one` key ×10 |
-
-### 10.2 A movie mis-shelved as a series (2026-08-29)
-
-`Some.Film.2017.MULTI.108.grp.mkv` — a movie whose `1080p` tag was truncated to `108` —
-made guessit invent `S01E08`, so `enrich.py`'s flat-library branch filed a standalone film
-as a nonexistent series, and "Fix match" (TV-only for a "show") could only offer other
-series. **Fixed** (`fix/movie-misclassified-as-show`): that branch now requires
-`ep.season` *and* `ep.episode`, plus either an explicit marker
-(`title_parse.has_episode_marker` — `SxxExx` / `1x08` / `Episode N` / `Season N`) or the
-absence of a `(2019)`-style year; otherwise it falls through to the movie branch. A real
-flat-dumped episode always carries a marker, so nothing regresses.
-
-**Still open:** there is no operator control over the movie/show classification itself —
-"Fix match" only changes the TMDB id, not the kind. A genuinely ambiguous name
-(`Show.2019.308.mkv`) that guessit slots wrongly has no in-UI remedy short of renaming the
-file. A "this is a movie / a show" toggle would close that.
-
-### 10.3 A whole saga matched to its first film (2026-08-30)
-
-Every `<Saga> Episode <N> - <subtitle>` file in a numbered franchise resolved to the
-series' first entry. Cause: `sequel_variants` stripped `Episode <N>` and offered the bare
-`<Saga>` as a candidate query — which matches the first film's `original_title` at ratio
-1.0, beating PASS 1's correct (but lower-ratio) hit. A franchise's bare name is very often
-a real, different film. **Fixed** (`fix/saga-match`): when a `Part`/`Episode`/…
-keyword is what carried the index, `sequel_variants` no longer emits the bare base — only
-`<base> <digit>` and `<base> <roman>`. Without a keyword (`<Franchise> 3`) the bare base is
-still offered. Verified live against a numbered franchise's episodes plus the earlier
-numbered-sequel / two-part-film / franchise-subtitle regressions.
-
-### 10.4 The detail modal jumped from one season to the next (2026-09-02)
-
-Three faults, one complaint: opening a different season of the same show moved
-everything under the synopsis, which is where the season control and the
-episode list are, so the thing just clicked was no longer under the pointer.
-
-- **The synopsis is however many lines TMDB wrote.** A twelve-line season
- summary and a two-line one put the season control ten lines apart, and
- resized the modal with it. It is now **exactly three lines** with a "read
- more" link — clamped from above and, for a multi-season show only (the one
- case where a season can change underneath it), pinned from below to the same
- number. A constant, not a range: a two-line band still reads as a jump, and
- the point is that the picker is at the same pixel from one season to the
- next. Five lines was built first and is why the number is three — at five, a
- short synopsis sat over three blank lines. `-webkit-line-clamp` cannot do
- the clamp half of this: it only ever puts its ellipsis at the end of the
- last line and leaves no room for anything after it. The link is floated
- into the third line box instead, which is why
- `OverviewText` writes the button *before* the text (a float only pushes the
- line boxes that follow it) and the CSS reserves the two lines above with a
- zero-width float. That spacer is scoped to `.has-more`: `overflow: hidden`
- makes the paragraph a block formatting context, so it *contains* the float
- and would otherwise stand two lines tall for a one-line movie synopsis.
- Whether three lines is all of it depends on the modal's width, so it is
- measured in the browser, not counted, and re-measured on a resize.
-- **The cast line was however long the cast was.** Clamped to two lines,
- ellipsised at the end of the second. Nothing to expand to here, so this one
- is a plain `-webkit-line-clamp`.
-- **The episode list is however many episodes the season has**, and that is
- the half neither clamp can reach. The body scrolled as a whole, so a
- thirteen-episode season pushed the modal to its `max-height` where a
- six-episode one had not — and `.video-overlay` centres its child, so the
- taller modal *started higher up the screen*: title bar, close button and all.
- `.video-detail-steady` (a multi-season show only) gives the modal a `height`
- rather than a `max-height`, makes the body a flex column, and hands the
- leftover to `.video-season-list` as the one scrolling part. A constant-height
- box is centred in the same place every time, so both halves settle at once.
- A season that scrolls also draws a scrollbar where one that does not draws
- none, which is a scrollbar's width of content and re-wrapped the file path
- above it; `scrollbar-gutter: stable` reserves it either way.
-- **The season menu's panel is `position: fixed`.** As an absolutely
- positioned child it was clipped by the modal's own `overflow: hidden`
- whenever the seasons outran the room under the picker — measured at a 740 px
- viewport, a thirteen-season panel wanted 320 px and had 288, and the rest sat
- outside the modal where no scroll could reach it. `placeSeasonPanel()` takes
- the trigger's rect and the window height, picks whichever side has more room,
- and caps the panel to it. It is the only part of this menu the stylesheet
- does not decide, so it has its own test.
-- **The season tab bar scrolled sideways.** A show with a dozen seasons hid
- most of them behind a horizontal scrollbar — unusable on a phone, and ugly
- everywhere. `SeasonMenu` replaces `SeasonTabs`: one trigger reading
- `Season 5 · 1997` and a menu of every season with its episode count, one row
- high whatever the season count. The selected season's air year moved onto
- that trigger, where it names what it belongs to; the *show's* year moved
- into the facts line next to the director. Per-season air dates are still
- fetched lazily for the selected season only (§5.7), so the menu lists
- episode counts — which are local — rather than a year per row.
-
-A series also had no director: `media_meta_resp.director` was filled from the
-credits crew's `job == "Director"`, a movie shape. TMDB's aggregate
-`tv_credits` crew is routinely empty and never carries that job, so every show
-answered `null` and the modal dropped the line. It now comes from `created_by`
-on the show details, which is what TMDB's own page shows. Cached show metadata
-keeps its `null` until `TMDB_META_TTL_SECS` expires or an operator re-matches.
-
-Measured, not read out of the stylesheet (`test_video_detail_measured.py`,
-via the `layout_probe.py` harness): the season picker's offset inside its own
-modal body is the same pixel for a one-line and a twelve-line synopsis *and*
-for a six- and a twenty-four-episode season, at every width from 320 px up;
-the modal's own top and height are the same for all three; the episode list
-never runs past the bottom of its modal; the clamped synopsis is three
-line-heights either way and the cast at most two; the read-more link lands on
-the third line; and a movie gets neither the floor nor the fixed height. Each
-measured block sits inside a real `.video-overlay` — the centring is half the
-defect — in a whole-pixel-height container, since stacked in flow they start at
-fractional offsets and two identical layouts an eighth of a pixel apart round
-to tops one pixel apart, which reads as a defect in the thing being measured
-and is not one. `test_season_panel_placement.py` runs `placeSeasonPanel()` in
-node over a rect and a window height: down, flipped up, capped to the room
-available, floored when cramped. Two things there are declarations rather than
-rectangles, and say so in their own docstrings: `scrollbar-gutter`, because
-headless Chrome gives the probe zero-width overlay scrollbars.
-`test_tmdb_show_director.py` covers the credit.
-
-### 10.5 A show that opened on season 6 (2026-09-02)
-
-Every season was in the picker and none was missing; the *default* was wrong.
-`VideoDetailModal` took it from `repEntry.season`, and `repEntry` is the show's
-"representative entry", which the poster grid picks as
-`episodes.find((e) => e.thumb_hash) || episodes[0]` — the first episode that
-has a thumbnail, so the card has a fallback frame when TMDB has no poster.
-That choice is from the original Videos commit; the season tabs came later and
-read the same entry as "the episode the reader is looking at", which it never
-was on that path. Episodes are sorted by (season, episode), so a show whose
-first five seasons had no thumbnail yet — a partial enrichment pass, or ffmpeg
-failing on those particular files — hands back a season-6 episode.
-
-Two meanings of "representative" that were never the same thing, and only one
-of them is about what the reader is looking at. `defaultSeason(show)` now reads
-the season list and nothing else: the lowest-numbered season present, specials
-only when there is nothing else, and the lowest *number* rather than the first
-entry so it does not quietly depend on `buildSeasons` keeping its sort.
-`test_video_default_season.py` — no input it takes can carry a thumbnail.
-
-### 10.6 The same film twice in the cross-group Search view (2026-09-02)
-
-An operator hosting two groups gave both the *same* video directory — which is
-the point of having two groups: different people are invited to different
-libraries, and one library may be in several of them. **Search files** then
-showed every film as two poster cards and every episode twice in the season
-list, one copy badged per group. Flat list too.
-
-Not a Videos bug. Inside a group it cannot happen: `GroupIndex` is keyed by
-blake3, so the same bytes at two paths are already one entry. `search-page.js`
-concatenates *N* independently keyed indexes into one list, and that is where
-the duplication is born.
-
-`source-merge.js` folds entries on the content hash and resolves **one source
-per unit** — a film, a whole show — rather than per file: a season split across
-two nodes would open two connections and two metadata lookups for one show. A
-group hosted by the reader's own node wins (read from `handshake_ack`'s
-`is_node_admin`, which the node computes from its own record of who it belongs
-to, never a hub claim); failing that the pick is `hash(unitKey + userId)`,
-stable for one reader across renders and reloads — a source that changed
-mid-stream would tear down the connection under a film that is playing — and
-spread across readers.
-
-The units come from **`groupVideoEntries` itself**, called on the un-merged
-list purely to learn them, never a second copy of its keys in the Search page.
-A copy would keep agreeing until one of them changed, and the symptom would be
-a show whose episodes stream from two different nodes.
-
-Two consequences worth knowing:
-
-- **An operator's "Fix match" and "Rematch" go to the chosen source's node.**
- On the operator's own libraries that is their node, which is what they mean.
- On a merged entry they do not host, the override lands on whichever group was
- picked — and the other source keeps its own match.
-- **`PosterGrid.mergedShows` still merges two differently-parsed titles once
- both resolve to the same TMDB id.** Those were two units when the source was
- picked, so a merged card can hold two sources. Left alone deliberately:
- re-picking under a card the reader is looking at is worse than a mixed one.
-
-Full design, the adversary this names, and what must not change:
-`docs/refactoring-search.md`. `test_search_source_merge.py` holds the rules,
-`test_search_media_merge.py` holds this symptom end to end.
-
-### 10.7 Every card badged "unmatched" when TMDB is off (2026-09-08)
-
-§10.1 gave an unmatched poster a badge: a dashed warning outline on the card
-plus a `?` next to the title, with `video.no_match` ("no confident TMDB match
-— showing the filename") as its tooltip, and the same sentence again at the
-top of the detail modal. That is a useful signal — it is how an operator finds
-the titles worth a "Fix match".
-
-It says the wrong thing when the group's operator has turned TMDB **off**
-(§5.5's per-group switch). No lookup was made, so nothing failed to match;
-`media_meta_req` answers `confidence: 0` for the whole library by design
-(`webrtc_server._do_media_meta_request`, the same silent degradation as "no
-client configured"). The poster grid therefore drew *every* card as a failure
-of the very thing the operator chose, and no poster was ever going to arrive
-to clear it.
-
-Poster mode now presents the file's own thumbnail plainly in that case —
-what Mode B (§4.2) does — and keeps the badge for what it was built for, a
-lookup that ran and came back with nothing:
-
-- `PosterCard` takes `tmdbEnabled` and derives `flagUnmatched = tmdbEnabled &&
- !confident`, which gates both `video-card-unmatched` and the `?` span. The
- title still falls back to the parsed filename exactly as before.
-- `VideoDetailModal` takes it too and gates its `video-detail-nomatch`
- paragraph. A **show** still opens that modal with TMDB off — it is where the
- season list lives, unlike a movie, which §V12's `onOpen` already sends
- straight to the player — so suppressing it on the card alone would have left
- the same claim one click deeper.
-- Both default the prop to `true`, so a call site that forgets it keeps the
- badge rather than silently losing it.
-- The modal's operator-only **"Fix match" / "Rematch"** buttons are gated on
- `isNodeAdmin && tmdbEnabled` for the same reason one step further on: both
- act on a TMDB match that, with the switch off, does not exist. "Fix match"
- opened a search the node answers with an empty result list (§5.7's silent
- degradation) and "Rematch" dropped a cached match that was never made — two
- dead buttons offered to the one person who already knows why. Nothing else
- reaches `TmdbSearchOverlay` or `doRematch`; those buttons are their only
- entry points, so gating them here is the whole change.
-
-Deliberately unchanged: `search-page.js` passes `tmdbConfig={{ enabled: true }}`
-because a cross-group view has no single group's switch to read, so a merged
-card there still badges — and, being `readOnly`/`isNodeAdmin={false}`, it never
-showed the operator buttons in the first place.
-
-
-## 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.