aboutsummaryrefslogtreecommitdiffstats
path: root/docs/mediacenter.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/mediacenter.md')
-rw-r--r--docs/mediacenter.md166
1 files changed, 165 insertions, 1 deletions
diff --git a/docs/mediacenter.md b/docs/mediacenter.md
index e3054f7..a165d64 100644
--- a/docs/mediacenter.md
+++ b/docs/mediacenter.md
@@ -283,6 +283,45 @@ 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
@@ -356,6 +395,80 @@ 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,
@@ -373,6 +486,41 @@ 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`
@@ -471,6 +619,7 @@ centralization was for (one computation, reused by every member).
| 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 |
## 11. Acceptance before shipping
@@ -484,4 +633,19 @@ centralization was for (one computation, reused by every member).
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.
+ 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.