# 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: