diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/refactoring-search.md | 376 |
1 files changed, 376 insertions, 0 deletions
diff --git a/docs/refactoring-search.md b/docs/refactoring-search.md new file mode 100644 index 0000000..08774d1 --- /dev/null +++ b/docs/refactoring-search.md @@ -0,0 +1,376 @@ +# Refactor: one file, one entry — merging duplicate sources in the Search view + +> Status: **planned, not built** (2026-09-02). Branch `feat/search-source-merge`. +> Scope: the cross-group Search page (`static/search-page.js`) and the three +> media applications it reuses (Videos, Music, Photos). The Files **explorer** +> inside Search is explicitly out of scope and must not change. +> +> **The bug, in one sentence:** a file shared by two groups is two entries in the +> Search view, so a film shows twice in the poster grid, an episode twice in a +> show's list, and a track twice in an album. +> +> The convention from draft-v6 is carried forward: **a claim in this document +> must name the adversary it holds against.** + +--- + +## 1. What was observed + +A single node hosts two groups, `demo35` and `media`. Both were given the *same* +directory as their video root — that is the whole point of having two groups: +different people are invited to different libraries, and one library may be in +several of them. + +Everything works per group. In **Search files**, which walks every group the +account belongs to and merges their indexes into one view, every file of that +shared directory is listed twice: + +| View | Symptom | +|---|---| +| Videos — Posters | Two identical cards for the same film, one badged `demo35`, one badged `media` | +| Videos — Flat list | Same, and a show folder that expands to each episode twice | +| Videos — detail modal | The season/episode list under the synopsis lists every episode twice | +| Music (not reported, same by construction) | Every track twice inside one album | +| Photos (not reported, same by construction) | Every photo twice inside one album | + +**Files (the explorer) is not affected and must stay that way.** There, each +group is a top-level folder and the two copies live in two different folders — +which is correct and is how a member navigates *per group*. Confirmed in +`search-page.js`'s `fileEntries`, which prefixes every path with the group name +precisely so that navigation works. + +**Within a single group this cannot happen.** `GroupIndex` is keyed by blake3 +(`group_index.py`, `_entries: dict # id → IndexEntry`), so the same bytes at two +paths inside one group are already one entry — the lesson recorded in `CLAUDE.md` +("a content-addressed index cannot represent the same bytes at two paths"). The +duplication is created by the Search page, which concatenates *N* independently +keyed indexes into one list, and by nothing else. + +--- + +## 2. Decision + +1. **Identity is the content hash.** `IndexEntry.id` is blake3 of the file + (`protocol.py:182`). Two entries with the same `id` are the same file, whatever + group announced them, whatever their path. +2. In the three media views, entries sharing an `id` are **merged into one + entry** carrying a list of sources. +3. **One source is chosen per logical unit**, not per file — a movie, a whole + show, a whole album, a whole photo album. Streaming, thumbnails, TMDB / + MusicBrainz metadata and the download link all use that one source. +4. The choice is: **a group hosted by the local node wins**; otherwise a + deterministic pseudo-random pick, stable for one user, spread across users. +5. If the chosen source turns out to be unreachable, the unit **fails over** to + another source that has the file. +6. The badge that today names the group becomes: the group name when there is + exactly one source, `N sources` when there is more than one. **Which** source + was picked is never shown. +7. **The Files explorer is untouched.** Not "mostly untouched" — the merge code + is never called on that path. + +--- + +## 3. Answers to the questions this raised + +Recorded here because each of them changed the plan. + +### 3.1 Merge across nodes, or only within one node? + +**Decided: across all groups**, whatever node hosts them. Merging only groups +that share a `node_id` would fix the reported case with no trust question at all +(same process, same file on disk), but it would deliver nothing else: two +different operators hosting the same film would stay two entries, and the +failover in §2.5 would have nothing to fail over to. + +**The adversary this names.** Chunks are encrypted and authenticated with the +group's GEK (`crypto.js` `deriveChunkKey(gek, fileHashHex, chunkIndex)`), and the +client does **not** re-hash the plaintext against `file_id`. So the GCM tag proves +"encrypted by someone holding this group's GEK for this file id", not "these bytes +hash to this id". After the merge, opening a file the Search view shows may fetch +bytes from a group the reader did not name. + +Bounded by three things, which is why it is accepted rather than blocking: + +- only groups the reader is **already a member of** are ever candidates — the + Search page indexes nothing else; +- an operator of such a group can already serve that reader arbitrary content + *inside their own group*, so no new capability is granted, only a new occasion + to use it; +- the local node wins whenever it is a candidate, which is the reported case and + the common one. + +Not accepted silently: the merged entry shows `N sources`, so a reader can see +that more than one group is involved. Re-hashing the plaintext client-side would +close it properly and is **not** proposed here — blake3 is not in WebCrypto, and +a streamed film is exactly the case where it cannot be done before playback. +Recorded in §9 as still open. + +### 3.2 What does "random" mean, concretely? + +**Decided: deterministic per user.** `Math.random()` re-evaluated during a render +would flip the source mid-stream and re-fetch every thumbnail on each re-render; +re-evaluated once per session it changes on every reload, and any index refetch +has to be careful to preserve it. + +The pick is `sourceIndex = hash(unitKey + userId) % sources.length` over the +sources sorted by group id. Stable for one reader across renders and reloads; +different readers land on different sources, which is what "random" was for. + +### 3.3 What if the chosen source is unreachable? + +**Decided: fail over.** A group whose index could not be fetched at all +contributes no entries and is already excluded (`fetchAllIndexes`'s `unreachable`). +What is new is a group that indexed fine and whose WebRTC connection later fails: +the pick must skip it and the unit must re-resolve. Without this, merging could +make a file *less* available than it is today, which would be a regression +dressed as a feature. + +### 3.4 Which views? + +Videos, Music and Photos. **Not** Files: the explorer stays navigable per group, +as it is today. + +--- + +## 4. Where the duplication actually comes from + +`search-page.js` builds four independent entry lists. Each walks +`indexedGroups` (a `Map` of groupId → `{entries, roots, groupName, groupOwner}`), +filters by that group's own root, and pushes a *copy* of the entry annotated with +its group's connection: + +```js +result.push({ + ...e, + path: SEARCH_VIDEO_ROOT + '/' + e.path, + groupId, groupName, groupOwner, + _tRef: conn ? conn.tRef : null, // which transport fetches this file + _gRef: conn ? conn.gRef : null, // which GEK decrypts it + _connGen: conn ? conn.gen : 0, // refetch key when that transport reconnects +}); +``` + +Everything downstream reads the source off the entry and nothing else: + +| Consumer | Reads | +|---|---| +| `MediaThumb`, `PosterCard`, `FlatMovieRow` | `_tRef` / `_gRef` / `_connGen` | +| `useMediaMeta` (TMDB), `useMusicMeta` | `_tRef`, `entry.id` | +| `VideoPlayer` (streaming) | the modal's transport, set from `connectGroup(entry.groupId)` | +| `music-player.js` | `getConnection(entry.groupId)` (`music-player.js:288`) | +| `downloadEntry` | the modal's transport, same origin | + +**This is the good news, and it decides the shape of the fix.** "The source" is +already one triple of fields on one entry. Producing *one* merged entry with one +source is therefore the whole change on the consumer side — the players, the +downloader and the metadata hooks need no modification at all. + +--- + +## 5. The shape of the fix + +### 5.1 A new module, `static/source-merge.js` + +Pure functions, no Preact, no transport — testable by reading them out of the +file and running them under node, the idiom `test_video_default_season.py` +already uses. + +``` +mergeUnitEntries(units, opts) → { entries, unitSourceByKey } +pickSource(sources, unitKey, salt, isDown) → source +sourceLabel(entry) → { text, groupId | null } +``` + +- `units` is a list of `{ key, entries }`. **The unit lists are produced by the + applications' own grouping functions**, never by a second copy of them (§5.2). +- `opts` carries `salt` (the user id), `isLocal(groupId)` and `isDown(groupId)`. +- Each output entry is one merged `IndexEntry` plus `_sources` (every group that + has it, sorted by group id) and the resolved `groupId` / `_tRef` / `_gRef` / + `_connGen` of its effective source. + +**Field provenance rule: every displayed field comes from the chosen source's +entry, and no field is back-filled from another source.** A `thumb_hash` or a +`display_title` that only one node computed is only fetchable over *that* node's +connection, so borrowing it would produce a poster request the chosen transport +cannot answer. Stated here because "merge two records field by field" is the +obvious thing to write and it is wrong. + +### 5.2 Units come from the real grouping functions + +The unit key must agree with how each application groups, or a show would get one +source and its episodes another. Rather than re-deriving the keys in +`search-page.js` — a copy that keeps passing after the original changes, the trap +`test_video_default_season.py`'s docstring names — the page calls the exported +grouping functions on the **un-merged** list purely to learn the units, merges +within each unit, and hands the merged flat list to the application, which groups +it again exactly as it does today. + +| View | Grouping function | Unit key | +|---|---|---| +| Videos | `groupVideoEntries` (exported) | show: `show:<title>`; movie: `movie:<id>` | +| Music | `groupMusicEntries` (exported) | album: `album:<artist>/<album>`; loose track: `track:<id>` | +| Photos | `groupPhotoAlbums` (**must be exported**, `photos-app.js:29`) | `album:<dir>` | + +Grouping therefore runs twice per recompute. It is a linear pass over an index +already held in memory and already re-run on every keystroke of the filter; the +cost is not worth a duplicated implementation. + +**Consequence to accept for Photos.** Photo albums are keyed by directory. Two +groups whose roots have different basenames put the same photo in two +differently-named albums, and the merge — scoped to a unit — will leave it in +both. That is correct: they *are* two albums. Only same-named albums collapse, +which is the reported shape. + +**Consequence to accept for Videos.** `PosterGrid.mergedShows` merges two +differently-parsed show titles once both resolve to the same TMDB id +(`video-app.js:845`). That happens after metadata arrives, inside the component, +and the two constituents may hold different chosen sources. Left alone: they were +two units when the source was picked, the episode lists are already disjoint, and +re-picking a source under a card the reader is looking at is worse than a mixed +one. + +### 5.3 Choosing the source + +``` +candidates = sources of every file in the unit, minus the ones marked down +local = candidates whose group is hosted by the local node +pool = local.length ? local : candidates +chosen = pool[ hash(unitKey + salt) % pool.length ] // pool sorted by group id +``` + +Then per file in the unit: use `chosen` if that file has it, otherwise re-run the +same rule over that file's own sources. An episode present in only one of the two +groups still plays. + +**"Hosted by the local node" is read from the handshake, not from the hub.** +`handshake_ack` already carries `is_node_admin`, computed by the node from its own +record of who it belongs to and never from a hub claim +(`webrtc_server.py:3916`). `fetchGroupIndex` has the ack in hand and today keeps +only the three root fields from it; it will keep `is_node_admin` too. A hub that +lied about it would only change which of the reader's own groups is preferred, and +the reader is a member of all of them. + +This is a proxy, not the literal question: it says "the operator of the node +serving this group is me", which for a person browsing their own libraries is the +same set. A browser has no other way to know — only the desktop client reaches +the daemon's loopback API. If the node id is wanted later, `fetchGroupIndex` +already knows which one it connected to and can record it at no cost. + +### 5.4 Failover + +`ConnectionPool` gains nothing; `search-page.js` gains a `downGroups` set: + +- `connectGroup(groupId)` records a failure and bumps `connectionGen`, which is + already the signal every entry list recomputes on; +- a later successful connect clears the mark; +- `pickSource` skips marked groups, and falls back to the full candidate list if + every one of them is marked (better a broken tile than a vanished film). + +Two properties this must have and must be tested for: a unit whose chosen source +goes down **re-resolves to another source without a page reload**, and a unit with +one source behaves exactly as it does today. + +### 5.5 The badge + +One helper, `sourceLabel(entry)`, four call sites: + +| File | Line today | Today | After | +|---|---|---|---| +| `video-app.js` | 405 | `repEntry.groupName` | group name, or `N sources` | +| `video-app.js` | 986 | group-name link badge | same, non-clickable when `N > 1` | +| `music-app.js` | 267 | `repTrack.groupName` | same rule | +| `photos-app.js` | 96 | `cover.groupName` | same rule | + +`files-app.js:446` is **not** in this list: the Files explorer is not merged and +its group column keeps naming exactly one group. + +New i18n key `search.n_sources` (`{ one: '{n} source', other: '{n} sources' }`) +in `en.js` and the nine other catalogues — `test_locales.py` holds them to `en`'s +key set and will fail otherwise. + +--- + +## 6. What must not change + +A checklist for the review, not prose. Each of these is a regression that would +be easy to ship and hard to notice. + +1. **The Files explorer.** Same folder tree, same per-group top level, same + badges, same navigation. `fileEntries` keeps building one entry per + (group, file). +2. **The single-group Group page.** `VideoApp`, `MusicApp`, `PhotosApp` are + rendered there with entries that carry no `_sources` and no `_tRef`. Every + changed component must behave identically when those fields are absent — + `entry._tRef || transportRef` is the existing idiom and stays. +3. **Streaming and download.** `VideoPlayer` and `downloadEntry` read the modal's + transport, set from `connectGroup(entry.groupId)`. With one `groupId` per + merged entry this is unchanged by construction — but a merged entry with a + *null* `groupId` would silently break both, so the merge must never emit one. +4. **The music queue.** `onPreview`'s audio branch builds siblings by + `e.path === origPath && e.groupId === groupId` over `allEntries`, which is the + *un-merged* list. It must be rebuilt over the merged music entries, or a queue + will contain each track twice — the exact bug, one layer down. +5. **The connection pool's eviction path.** `_onEvict` drops `groupConns` and + bumps `connectionGen`. Eviction is not failure and must not mark a group down. +6. **TMDB / MusicBrainz overrides.** An operator's "Fix match" and "Rematch" go + to the chosen source's node (`transportRef.current.rematchTmdbMatch`). With the + local node preferred, that is the operator's own node — correct. Worth a line + in `mediacenter.md` §10 all the same: on a file the operator does not host, the + override lands on whichever node was picked. +7. **`cacheGroupIndex`.** The IndexedDB cache is per group and stores raw index + entries. Merged entries must never be written to it. + +--- + +## 7. Execution order + +Each phase is independently testable and leaves the tree working. + +| # | Phase | Files | +|---|---|---| +| 1 | Export `groupPhotoAlbums`; record `is_node_admin` in `fetchGroupIndex`'s result | `photos-app.js`, `search-page.js` | +| 2 | `source-merge.js` — `pickSource`, `mergeUnitEntries`, `sourceLabel`. No caller yet | new file | +| 3 | Tests for phase 2, read out of the source | `test_search_source_merge.py` | +| 4 | Wire the Videos list through the merge | `search-page.js` | +| 5 | Same for Music, including the `onPreview` queue (§6.4) | `search-page.js` | +| 6 | Same for Photos | `search-page.js` | +| 7 | `downGroups` and failover | `search-page.js` | +| 8 | `sourceLabel` at the four display sites; `search.n_sources` in ten catalogues | `video-app.js`, `music-app.js`, `photos-app.js`, `locales/*.js` | +| 9 | Docs: `mediacenter.md` §10, `musicbay.md`, `photos.md`, `apps.md` if the props contract moves | `docs/` | + +Phase 4 alone is enough to confirm the reported bug is gone; phases 5–8 are the +same mechanism applied outward. + +--- + +## 8. Tests + +The SPA has no runtime test harness beyond "read the source and run it under +node", so that is what these are. Source-reading tests are weak evidence and are +the only evidence available here — which is why every one below **re-derives** a +value from the real source rather than restating a constant. + +| Test | Holds | +|---|---| +| `test_search_source_merge.py` | `pickSource` / `mergeUnitEntries` lifted out of `source-merge.js` and executed: a file in two groups yields one entry with two sources; a local source always wins; the pick is stable across calls and varies with the salt; a unit's files share the unit's source; a file the unit's source lacks falls back to its own; a down group is skipped; all-down falls back to the full list | +| extend `test_locales.py` | already fails on a key present in `en.js` and missing elsewhere — no change needed, listed so the ten-catalogue edit is not forgotten | +| `test_search_files_unmerged.py` | reads `search-page.js` and refuses a build where `fileEntries` is fed through the merge — the one guarantee §6.1 makes, and the one a later refactor is most likely to break by tidying the four lists into one | +| extend `test_transport_contracts.py` | the existing "declared vs. called setters" check covers the new state in `search-page.js` for free | + +Beyond the suite, this needs a person: two groups sharing one directory, one +film and one multi-season show, checked in Posters, Flat list, the detail modal, +Music and Photos, plus one playback and one download from a merged entry. The +standing rule from `CLAUDE.md` applies — a stylesheet does not tell you where +anything lands, and neither does a source-reading test tell you what a poster +grid renders. + +--- + +## 9. Still open + +| Item | Status | +|---|---| +| Client-side content verification | **Open.** The merge lets bytes arrive from a group the reader did not name (§3.1). Closing it means re-hashing plaintext against `file_id`, which needs blake3 in the browser and is impossible before playback for a stream. Not attempted here | +| Showing *which* source was picked | Deliberately not shown, per the request. `_sources` is on the entry, so a debug affordance is cheap if it is ever wanted | +| Merging across nodes for *availability* | The failover in §5.4 is per unit and reactive. Preferring a node that is already connected, or one that answered faster, is a further step and is not planned | +| Preferring the local node by `node_id` rather than `is_node_admin` | §5.3. The exact signal exists and costs nothing to record; the proxy is used because it needs no new field on the wire | |