/** * One file, one entry — merging the same content announced by several groups. * * Only the cross-group Search view needs this. Inside a single group the case * cannot arise: `GroupIndex` is keyed by blake3, so the same bytes at two * paths are already one entry. The Search page is what creates duplicates, by * concatenating N independently keyed indexes into one list — a directory * shared by two groups then shows every film twice, every episode twice, every * track twice. See docs/refactoring-search.md. * * Two rules decide everything here: * * - **Identity is the content hash.** `IndexEntry.id` is blake3 of the file, * so two entries sharing an id are the same file whatever group announced * it and whatever path it sits at. * - **A source is chosen per unit, not per file.** A unit is a film, a whole * show, a whole album — whatever the reader thinks of as one thing. Picking * per file would scatter a season's episodes across two nodes, opening two * connections and two metadata lookups for one show. * * No imports, and there must be none: the whole module is executed standalone * by `test_search_source_merge.py`, which is the only evidence the merge has. */ // FNV-1a, 32 bits. Spreads the source choice deterministically across readers // — nothing here is security-bearing, and a cryptographic hash would buy // nothing a multiplication does not. function stableHash(s) { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; } function _byGroupId(a, b) { return String(a.groupId).localeCompare(String(b.groupId)); } function _source(e) { return { groupId: e.groupId, groupName: e.groupName, groupOwner: e.groupOwner }; } /** * Which of `sources` serves this unit. * * A group hosted by the reader's own node wins outright — for someone browsing * their own libraries that is the whole of the reported case, and it makes the * answer predictable where it matters most. Failing that the pick is a hash of * the unit key and a per-reader salt: 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), different from one reader to the next, which * is what spreading the load across sources was asking for. * * `isDown` marks a source whose connection has failed. Every source being down * still returns one: a tile that fails to load is better than a film that * vanished from the grid. */ function pickSource(sources, unitKey, opts) { if (!sources || !sources.length) return null; const { salt = '', isLocal, isDown } = opts || {}; const sorted = [...sources].sort(_byGroupId); const up = isDown ? sorted.filter((s) => !isDown(s.groupId)) : sorted; const live = up.length ? up : sorted; const local = isLocal ? live.filter((s) => isLocal(s.groupId)) : []; const pool = local.length ? local : live; return pool[stableHash(`${unitKey}\u0000${salt}`) % pool.length]; } /** * Merge the entries of each unit by content hash and resolve one source each. * * `units` is `[{ key, entries }]`, and the caller builds it by running the * application's *own* grouping function over the un-merged list — never a * second copy of that logic, which would keep agreeing right up until one of * them changed. Two lists carrying the same key are one unit, which is what * lets a caller emit a naive `{key: 'movie:', entries: [e]}` per copy of a * film and still get one card. * * Every field of a merged entry comes from the chosen source's own entry, and * nothing is back-filled from another source. A `thumb_hash` or a * `display_title` 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 — merging two records field by field is the obvious * thing to write here and it is wrong. * * The result carries `_sources`: every group that has the file, sorted by * group id. That is what the badge counts, and it is the only trace left that * more than one group was involved. */ function mergeUnitEntries(units, opts) { const byKey = new Map(); for (const u of units || []) { if (!byKey.has(u.key)) byKey.set(u.key, []); byKey.get(u.key).push(...(u.entries || [])); } const merged = []; for (const [key, entries] of byKey) { // id -> one entry per group announcing it, first seen wins within a group const byId = new Map(); for (const e of entries) { if (!byId.has(e.id)) byId.set(e.id, []); const copies = byId.get(e.id); if (!copies.some((c) => c.groupId === e.groupId)) copies.push(e); } const unitSources = []; const seen = new Set(); for (const copies of byId.values()) { for (const e of copies) { if (seen.has(e.groupId)) continue; seen.add(e.groupId); unitSources.push(_source(e)); } } const unitSource = pickSource(unitSources, key, opts); for (const copies of byId.values()) { const sources = copies.map(_source).sort(_byGroupId); let chosen = unitSource && copies.find((e) => e.groupId === unitSource.groupId); if (!chosen) { // A file the unit's source does not hold — an episode only one of the // groups has. Resolved over its own sources by the same rule and the // same unit key, so every such file in the unit lands on the same // fallback instead of scattering one per file. const alt = pickSource(sources, key, opts); chosen = (alt && copies.find((e) => e.groupId === alt.groupId)) || copies[0]; } merged.push({ ...chosen, _sources: sources }); } } return merged; } /** * What the group badge should say — for one entry, or for a whole unit. * * Takes a list, because a card stands for a unit while the entry it is drawn * from is one file. A show's poster entry is picked for its *thumbnail* * (`episodes.find((e) => e.thumb_hash)`), so reading the badge off it would * report that one episode's sources: a show in two groups whose cover episode * sits in only one of them would claim a single source. The union over the * unit is the number the reader is actually being told. * * Returns `{ count, name, groupId }` rather than a rendered string: this module * is executed standalone under node by its test, so it holds no reference to * `i18n.js`. A caller renders `name` (a link to `groupId`) when `count` is 1, * and a plural of `count` above that — which group was picked is deliberately * never shown. * * `count: 0` is an entry with no group at all, which is the single-group Group * page: there is one source by construction and no badge is drawn there. */ function sourceLabel(entries) { const list = Array.isArray(entries) ? entries : [entries]; const groups = new Map(); for (const e of list) { if (!e) continue; // An un-merged entry carries no `_sources`; it is its own single source. const sources = (e._sources && e._sources.length) ? e._sources : (e.groupId ? [_source(e)] : []); for (const s of sources) if (!groups.has(s.groupId)) groups.set(s.groupId, s); } if (groups.size > 1) return { count: groups.size, name: '', groupId: null }; const only = groups.values().next().value; return { count: groups.size, name: (only && only.groupName) || '', groupId: (only && only.groupId) || null, }; } export { pickSource, mergeUnitEntries, sourceLabel, stableHash };