1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
|
/**
* 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/MESHBAY_DESIGN.md §9.11.
*
* 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:<id>', 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 };
|