diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/apps.md | 40 | ||||
| -rw-r--r-- | docs/mediacenter.md | 43 | ||||
| -rw-r--r-- | docs/musicbay.md | 34 | ||||
| -rw-r--r-- | docs/photos.md | 23 | ||||
| -rw-r--r-- | docs/refactoring-search.md | 10 |
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. |