summaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/apps.md40
-rw-r--r--docs/mediacenter.md43
-rw-r--r--docs/musicbay.md34
-rw-r--r--docs/photos.md23
-rw-r--r--docs/refactoring-search.md10
5 files changed, 146 insertions, 4 deletions
diff --git a/docs/apps.md b/docs/apps.md
index 1cd6339..ef8cc1a 100644
--- a/docs/apps.md
+++ b/docs/apps.md
@@ -80,6 +80,39 @@ needs — a new app does not get a bespoke prop list. Notable ones:
| `transportRef`, `gekRef` | refs to the live MNP transport and the imported group key | never state — a ref, so reconnects don't force a re-render of every app |
| `mayUpload` | `memberUpload || isNodeAdmin`, computed once | Files' toolbar and Chat's composer both gate on it; a second derivation would eventually disagree with the first |
+### 2b. The same app, rendered by the Search page
+
+Videos, Music and Photos are mounted twice: by `group-page.js` for one group,
+and by `search-page.js` across every group the reader belongs to. The second
+caller passes the same prop shape, and the difference lives entirely on the
+**entries**, in underscore-prefixed fields the group page never sets:
+
+| Field | What it is |
+|---|---|
+| `groupId`, `groupName`, `groupOwner` | which group serves this entry |
+| `_tRef`, `_gRef` | that group's transport and key — read as `entry._tRef \|\| transportRef`, which is why a single-group mount needs no special case |
+| `_connGen` | bumped when that group reconnects; use it as a refetch key so a tile recovers instead of staying a spinner |
+| `_sources` | every group that has this file, after the de-duplication below |
+
+**A file shared by two groups is one entry, not two** (`source-merge.js`,
+`docs/refactoring-search.md`). Entries are folded on their content hash and one
+source is resolved per *unit* — a film, a show, an album — using each app's own
+grouping function to decide what a unit is. Two consequences for a new app:
+
+- if it renders a group name, use **`SourceTag`** from `group-name.js` rather
+ than `entry.groupName`: a merged entry has several groups and must say
+ `N sources` instead of naming one. Pass it the **whole unit** (a show's
+ episodes, an album's tracks), not the entry the card was drawn from — that
+ entry is usually chosen for its thumbnail, and would under-report;
+- if it needs a merge unit key of its own, add a `<name>Units()` helper to
+ `search-page.js` that calls the app's **exported** grouping function. Never
+ re-derive the keys there: a copy keeps agreeing until one of them changes,
+ and the symptom is a show whose episodes stream from two different nodes.
+
+The Files explorer is deliberately **not** merged — there each group is a
+top-level folder, and merging would remove a file from one of them.
+`test_search_files_unmerged.py` refuses a build that changes this.
+
An app that needs **local** state (Files' `selecting`/`sortKey`/`currentPath`,
for instance) owns it itself with `useState`, same as before the split. One
thing worth keeping if you add a tab with a notion of "current location within
@@ -160,8 +193,11 @@ registry, so a newly-registered app gets a checkbox for free.
5. **`webapp.py`'s `_ASSETS`** tuple: add the new file. This is the
cache-busting hash's input list — a file imported by the page but missing
here can change without the served URL changing, which is the exact bug
- class `test_asset_versioning.py` exists for. Forgetting this step is
- silent: nothing errors, a browser just keeps an old copy.
+ class `test_asset_versioning.py` exists for. Forgetting this step used to be
+ silent: nothing errors, a browser just keeps an old copy. It is now caught —
+ `test_every_static_script_participates_in_the_fingerprint` holds `_ASSETS`
+ to every `.js` in `static/` (`sw.js` excepted, unversioned on purpose).
+ Written after `source-merge.js` shipped missing from the list.
6. **Test coverage that scans the file set**: `test_hook_ordering.py`
(`STATIC_FILES`) and `test_transport_contracts.py`
(`test_no_setter_survives_the_state_it_belonged_to`, `SPLIT_FILES`) walk a
diff --git a/docs/mediacenter.md b/docs/mediacenter.md
index dbdf85c..d714366 100644
--- a/docs/mediacenter.md
+++ b/docs/mediacenter.md
@@ -904,6 +904,49 @@ 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.
+
## 11. Acceptance before shipping
1. Re-run the §3 validation (real TMDB calls, same corpus, same script
diff --git a/docs/musicbay.md b/docs/musicbay.md
index 2ca8b23..d9794d2 100644
--- a/docs/musicbay.md
+++ b/docs/musicbay.md
@@ -434,6 +434,40 @@ actually implement (§3.2), not a property the protocol gives for free.
| M5 | Gapless playback, crossfade, lyrics, waveform seek preview | Nice-to-haves, no architectural prerequisite from this plan either way |
| M6 | Photos app | Out of scope of this document, per `apps.md`'s own list — unaffected by anything here |
+## 9b. One album, not two, in the cross-group Search view (2026-09-02)
+
+Reported against Videos and true here by construction: an operator hosting two
+groups that share one directory saw every track listed twice inside one album.
+Inside a group it cannot happen (`GroupIndex` is keyed by blake3); the Search
+page concatenates *N* indexes, and that is where the duplication is born.
+
+The fix is `source-merge.js`, applied identically to all three media views —
+fold on the content hash, resolve **one source per unit**. For Music the unit
+is an album, and a track loose enough to carry no artist at all is a unit of
+its own (or it would be dropped from the merge entirely, since
+`groupMusicEntries` never puts it in an album bucket).
+
+**`foldKey` is exported for this**, and the reason is worth keeping. §5.1's
+grouping folds case and `&`/`and` for the *key* while keeping the first-seen
+spelling for display — and which group is seen first is whichever index
+happened to arrive first. Keying a merge unit on the display strings would
+therefore let the chosen source change between page loads. The folded key is
+the one grouping actually uses, and is stable.
+
+The units come from `groupMusicEntries` itself, called on the un-merged list
+purely to learn them — never a second copy of its keys in the Search page.
+
+**The player needed no change.** `music-player.js` resolves a connection from
+`entry.groupId` (§2.3), and a merged entry carries exactly one. The queue built
+in `search-page.js`'s `onPreview` needed none either: it filters by `groupId`
+and is reachable only from the Files explorer, which is deliberately *not*
+merged — there each group is a folder and merging would remove a track from
+one of them.
+
+Design and the adversary it names: `docs/refactoring-search.md`.
+`test_search_media_merge.py` covers the album cases, including a differently-
+cased tag not splitting the unit.
+
## 10. Acceptance before shipping
1. Tag-coverage measurement against a real local library (not committed —
diff --git a/docs/photos.md b/docs/photos.md
index ed4a826..c00a7e0 100644
--- a/docs/photos.md
+++ b/docs/photos.md
@@ -456,6 +456,29 @@ than Videos, not more.
| P4 | Wrap-around vs. stop-at-ends for next/previous at album boundaries | UI choice, mirror whatever `video-player.js`'s own controls already do for consistency |
| P5 | Album cover selection (always "first photo" vs. an operator/member choice) | Product call; "first photo, stable" is a reasonable, zero-config default and is what this document assumes |
+## 10b. One photo, not two, in the cross-group Search view (2026-09-02)
+
+Reported against Videos and true here by construction: two groups sharing one
+directory listed every photo twice inside one album. `source-merge.js` folds
+entries on the content hash and resolves one source per album; the units come
+from `groupPhotoAlbums` itself (exported for this), called on the un-merged
+list purely to learn them.
+
+**One consequence is deliberate and is not a bug.** Albums are keyed by
+directory (§2.5), so two groups whose roots have *different basenames* put the
+same photo in two differently-named albums, and the merge — scoped to a unit —
+leaves it in both. That is correct: they are two albums. Only same-named albums
+collapse, which is the reported shape. Videos and Music do not have this case,
+their units being title- and tag-based rather than path-based.
+
+The album card's source badge counts the union over **the album**, not the
+cover photo: the cover is `photos.find((p) => p.thumb_hash) || photos[0]`, so
+an album in two groups whose cover sits in only one would have claimed a single
+source.
+
+Design: `docs/refactoring-search.md`. `test_search_media_merge.py` covers both
+the collapsing and the non-collapsing case.
+
## 11. Acceptance before shipping
1. Orientation correction verified against a real EXIF-rotated phone photo
diff --git a/docs/refactoring-search.md b/docs/refactoring-search.md
index 2210397..d39bd36 100644
--- a/docs/refactoring-search.md
+++ b/docs/refactoring-search.md
@@ -1,6 +1,7 @@
# Refactor: one file, one entry — merging duplicate sources in the Search view
-> Status: **phases 1–8 built** (2026-09-02), 9 (the doc pass) outstanding.
+> Status: **complete** (2026-09-02) — all nine phases landed. This stays as
+> the decision record.
> Branch `feat/search-source-merge`. Videos, Music and Photos are merged,
> failover is in, and a card with several sources says `N sources` instead of a
> group name. Confirmed live for Videos on 2026-09-02: one card per film,
@@ -335,6 +336,11 @@ be easy to ship and hard to notice.
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.
+8. **`webapp.py`'s `_ASSETS`.** A new static file missing from it changes
+ without moving the asset URL, so a browser that cached the page keeps the
+ old copy — and nothing errors. `source-merge.js` shipped missing from it;
+ caught afterwards, and `test_every_static_script_participates_in_the_fingerprint`
+ now holds the list to every `.js` in `static/` so the next one cannot.
---
@@ -352,7 +358,7 @@ Each phase is independently testable and leaves the tree working.
| 6 ✅ | Same for Photos | `search-page.js` |
| 7 ✅ | `downGroups` and failover | `search-page.js` |
| 8 ✅ | `SourceTag` at the four display sites; `search.n_sources` in ten catalogues | `group-name.js`, `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/` |
+| 9 ✅ | Docs: `mediacenter.md` §10.6, `musicbay.md` §9b, `photos.md` §10b, `apps.md` §2b + checklist step 5 | `docs/` |
Phase 4 alone was enough to confirm the reported bug is gone; phases 5–8 are the
same mechanism applied outward.