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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
|
/**
* Playlists: the shapes, and reconciling N copies of them.
*
* `docs/playlists.md` §5 and §6. A playlist is stored as two things — one
* small **manifest** naming every playlist, and one **body** per playlist
* holding its tracks — and every device holds its own copy of both while
* nodes come and go. This is where the copies are put back together.
*
* The stated fear is correct for a single blob under last-writer-wins: node A
* is off while an edit is made, node B is off while the next one is, and one
* edit disappears with nothing to show for it. Four rules remove it, and they
* are the whole of this file:
*
* - **The unit is one playlist, not the collection.** Two playlists edited on
* two devices never collide.
* - **Revision counters order writes, never the wall clock.** Clocks across
* devices are not trustworthy, and `updated_at` is carried for display and
* never read here.
* - **A deletion is a tombstone, never an absence.** An absence means "this
* copy is older than the one that created it". Drop that and a node rehomed
* after three weeks resurrects every deleted playlist — the single most
* likely defect in the design, and it looks like a sync working correctly
* right up until it doesn't.
* - **The client is the authority.** Merge takes the maximum across local
* *plus* every node answering, so a node serving a stale copy can only lose
* the tie. It can never lower the merged state.
*
* No imports, and there must be none: the whole module is executed standalone
* by `test_playlist_merge.py`, the way `source-merge.js` is. The merge is the
* one part of this feature that can be properly tested, and it is untested
* from the moment it cannot be executed.
*
* JSON, not msgpack, for what goes inside a blob. The node never parses it —
* it stores bytes — so the encoding is this client's private choice rather
* than a wire format, and MNP's codec is private to transport.js, which is a
* classic script and cannot be imported here. Keeping the payload in reach of
* `JSON.parse` is what lets this module be run by `node` with nothing around it.
*/
// The reserved playlist. Favourites is a playlist, not a second mechanism —
// deciding that here is what stops a parallel store being built beside this
// one in three months. It is never created by the user, is materialised on
// first use, and cannot be deleted (which is what keeps the tombstone rule
// above free of an exception).
const FAVORITES_ID = 'favorites';
const MANIFEST_KIND = 'playlists';
const bodyKind = (id) => `playlist:${id}`;
// What a stored track reference carries, and nothing else. The entries the UI
// hands over are live objects: `_tRef` is a transport and `_gRef` a CryptoKey,
// attached by the Search page's merge. JSON-encoding a transport throws at
// best; storing a stale one and reading it back a week later is worse, because
// it looks like a connection and is not.
//
// Short keys because the same eleven repeat on every entry — see docs §4.1 for
// what one costs.
const TRACK_FIELDS = ['id', 'g', 'hv', 'n', 's', 'p', 't', 'a', 'b', 'd', 'tn'];
/**
* An index entry (plus its `groupId`) as a stored track reference.
*
* `s` and `n` are not optional and their absence is not cosmetic: the player
* computes its chunk count from `entry.size` and reads `entry.name` both for
* the MIME type and to decide whether the file needs the node-side conversion
* first. Without them a playlist entry cannot be downloaded at all.
*/
function toStored(entry, groupId) {
return {
id: entry.id,
g: entry.groupId || groupId || '',
hv: entry.hash_version || 1,
n: entry.name || '',
s: entry.size || 0,
p: entry.path || '',
t: entry.display_title || entry.name || '',
a: entry.artist || '',
b: entry.album || '',
d: entry.duration || 0,
tn: entry.track_no || 0,
};
}
/** A stored reference back in the shape the player and the views consume. */
function fromStored(e) {
return {
id: e.id,
groupId: e.g,
hash_version: e.hv || 1,
name: e.n,
size: e.s,
path: e.p,
display_title: e.t || e.n,
artist: e.a || null,
album: e.b || null,
duration: e.d || 0,
track_no: e.tn || null,
type: 'audio',
};
}
function emptyManifest() {
return { v: 1, playlists: {} };
}
function emptyBody(id) {
return { v: 1, id, rev: 0, device: '', tracks: [] };
}
/**
* Which of two revisions of the same thing wins.
*
* The higher `rev`; a tie goes to the lexicographically smaller `device`, so
* every device reaches the same answer without talking to any other. A tie is
* two devices writing the same revision from the same starting point, and one
* of the two edits is lost — stated plainly in docs §6.5 rather than papered
* over, because the alternative is an OR-Set and a much larger design.
*/
function _wins(x, y) {
const rx = x.rev || 0;
const ry = y.rev || 0;
if (rx !== ry) return rx > ry ? x : y;
return String(x.device || '') <= String(y.device || '') ? x : y;
}
/**
* Two manifests into one, per playlist.
*
* Two counters, deliberately. `rev` governs what the manifest itself says — the
* name, and whether this playlist is deleted. `body_rev` is a **watermark**: the
* highest body revision anybody has seen, merged by taking the maximum and never
* decreasing, with `count` following whichever side carries it.
*
* One counter would be simpler and is wrong. Rename a playlist on one device
* while adding a track to it on another and both write revision n+1; a single
* counter makes those collide and one of the two is lost, for two edits that
* do not overlap at all. Two counters let them merge independently, which is
* the common case rather than an exotic one.
*/
function mergeManifests(a, b) {
const pa = (a && a.playlists) || {};
const pb = (b && b.playlists) || {};
const out = { v: 1, playlists: {} };
for (const id of new Set([...Object.keys(pa), ...Object.keys(pb)])) {
const ea = pa[id];
const eb = pb[id];
if (!ea || !eb) { out.playlists[id] = ea || eb; continue; }
const named = _wins(ea, eb);
// The body watermark moves on its own, and only ever forward.
const bodied = (eb.body_rev || 0) > (ea.body_rev || 0) ? eb : ea;
out.playlists[id] = {
...named,
body_rev: bodied.body_rev || 0,
count: bodied.count || 0,
};
}
return out;
}
/**
* Two copies of one playlist's tracks into one.
*
* A body whose manifest entry is a tombstone is not resurrected here: the
* manifest is the authority on existence, and `liveBodies` below is what drops
* the orphans. Keeping that decision out of this function is deliberate —
* a body merge that also had to know about deletion would need the manifest
* passed to it, and the two would drift.
*/
function mergeBodies(a, b) {
if (!a) return b || null;
if (!b) return a;
return _wins(a, b);
}
/** Every merged copy folded together — local first, then whatever each node had. */
function mergeAllManifests(copies) {
return (copies || []).filter(Boolean).reduce(mergeManifests, emptyManifest());
}
/**
* Is this playlist gone?
*
* A tombstone is `deleted: true`, kept. Collected only once every known node
* reports a `rev` at or above the deleting one — immediate for a single-node
* account, eventual for a multi-node one. A tombstone is about forty bytes, so
* there is no hurry, and hurrying is what resurrects playlists.
*/
function isDeleted(entry) {
return !!(entry && entry.deleted);
}
/** The playlists a reader should see, newest name order, favourites first. */
function livePlaylists(manifest) {
const ps = (manifest && manifest.playlists) || {};
const live = Object.entries(ps)
.filter(([, e]) => !isDeleted(e))
.map(([id, e]) => ({ id, ...e }));
live.sort((x, y) => {
// Favourites is always first, as the menus promise, whatever it is called
// in this reader's language.
if (x.id === FAVORITES_ID) return -1;
if (y.id === FAVORITES_ID) return 1;
return String(x.name || '').localeCompare(String(y.name || ''));
});
return live;
}
/**
* Repair entries against a live index, once, on the client.
*
* Content addressing survives a move; a path survives a re-encode. Keeping
* both means either can repair the other — an entry whose `id` is no longer in
* the index but whose path and filename are gets its id rewritten, and an
* entry whose id is there under a different path gets its path rewritten.
*
* `index` is `{ byId: Map(id -> {p, n}), byPath: Map("p/n" -> id) }` for one
* group. Entries from other groups are left alone: this index says nothing
* about them, and "not in the index I happen to be holding" is not evidence
* that a track is gone.
*/
function repairTracks(tracks, groupId, index) {
let repaired = 0;
const out = (tracks || []).map((e) => {
if (e.g !== groupId) return e;
const here = index.byId.get(e.id);
if (here) {
if (here.p === e.p && here.n === e.n) return e;
repaired++;
return { ...e, p: here.p, n: here.n };
}
const byPath = index.byPath.get(`${e.p}/${e.n}`);
if (byPath && byPath !== e.id) {
repaired++;
return { ...e, id: byPath };
}
return e;
});
return { tracks: out, repaired };
}
/** The `{ byId, byPath }` shape repairTracks wants, from a group's entries. */
function indexTracks(entries) {
const byId = new Map();
const byPath = new Map();
for (const e of entries || []) {
if (e.type !== 'audio') continue;
byId.set(e.id, { p: e.path || '', n: e.name || '' });
byPath.set(`${e.path || ''}/${e.name || ''}`, e.id);
}
return { byId, byPath };
}
export {
FAVORITES_ID, MANIFEST_KIND, bodyKind, TRACK_FIELDS,
toStored, fromStored, emptyManifest, emptyBody,
mergeManifests, mergeBodies, mergeAllManifests,
isDeleted, livePlaylists, repairTracks, indexTracks,
};
|