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
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
|
import {
MANIFEST_KIND, bodyKind, FAVORITES_ID,
emptyManifest, emptyBody, mergeManifests, mergeBodies,
toStored, fromStored, livePlaylists, repairTracks, indexTracks,
} from './playlist-merge.js';
import { derivePlaylistKey, seal, open } from './playlist-crypto.js';
import {
session, _loadBundleKey, openDB, IDB_PLAYLISTS,
} from './hub-client.js';
/**
* Playlists: the store, and putting it on nodes.
*
* `docs/playlists.md` §7 and §14. The rules live in `playlist-merge.js` and the
* sealing in `playlist-crypto.js`, both of which are pure and both of which are
* executed by their own tests. What is here is the part that cannot be: reading
* and writing IndexedDB, and talking to nodes.
*
* **The client is the authority.** The merged state is what is in this
* browser's IndexedDB, and every merge takes the maximum across the local copy
* *plus* whatever each node answers. A node serving a stale copy, or a fresh
* one it never received, can only lose the tie — it can never lower the merged
* state, because the local copy is one of the inputs. That is what makes an
* offline node a non-event rather than a hazard.
*
* **Nothing here dials.** Sync rides connections that were being opened anyway:
* opening a group, the Search page's sweep, the music pool connecting to play a
* track. A sweep of every group costs ten seconds per unreachable one, and
* doing that at sign-in for a feature nobody has asked to use yet is exactly
* what §7 refuses.
*/
/**
* The largest sealed body this browser can actually send.
*
* Not a policy — a wire limit. A DataChannel `send()` throws when the frame is
* larger than the value the far end advertised, and the node's aiortc fixes
* that at 65 536 (`docs/playlists.md` §15.3). The node's own 1 MB body cap is
* therefore unreachable from here, and the same constraint is why uploads chunk
* at 48 KB.
*
* 62 KB leaves room for the frame around the bytes — the four-byte length
* prefix and the msgpack map that carries the kind, the revision and a request
* id.
*
* How many tracks that is depends entirely on how much the metadata repeats,
* because repetition is all deflate has to work with. Measured both ways: a
* library where artists and albums recur costs ~50 bytes a track and tops out
* near **1200**; one with no repetition at all costs ~91 and tops out near
* **660**. The sentence shown to a reader says 500, which holds in both worlds
* — `test_playlist_store.py` fails if it stops holding in the harsher one.
*
* Checked *after* sealing rather than estimated before it: compression makes
* the length of a playlist a poor guide to the length of its blob, and a guess
* that is wrong in the permissive direction is a thrown exception in the middle
* of a sync.
*/
const MAX_BODY_BYTES = 62 * 1024;
// ── local identity ───────────────────────────────────────────────────────────
const DEVICE_KEY = 'meshbay_playlist_device';
/**
* A stable id for *this browser*, for breaking merge ties and nothing else.
*
* Not an identity and not a secret: it never leaves the sealed blob, and two
* devices needing different values is the whole requirement. Random rather than
* derived from the account, because two browsers of one account must not
* collide — which is the one case the tie-break exists for.
*/
function deviceId() {
try {
let id = localStorage.getItem(DEVICE_KEY);
if (!id) {
id = Array.from(crypto.getRandomValues(new Uint8Array(8)),
(b) => b.toString(16).padStart(2, '0')).join('');
localStorage.setItem(DEVICE_KEY, id);
}
return id;
} catch {
// A private window with storage refused. A per-session id still breaks
// ties correctly; it just stops being the *same* device next time, which
// costs nothing but a different arbitrary winner on a tie.
return 'ephemeral';
}
}
// ── the key ──────────────────────────────────────────────────────────────────
let _key = null;
let _keyFor = null;
/**
* The playlist key, derived once per sign-in from the HKDF handle that rides
* alongside the bundle key (`keyderive.js`'s deriveBundleKeys).
*
* `v2hkdf` is absent when this browser's session predates it — a stored bundle
* key from before the change, loaded out of IndexedDB. There is nothing to do
* about that here and nothing to fall back to: the passphrase is not in memory
* to re-derive from. Playlists stay local until the next sign-in, which is a
* degradation rather than a failure and is reported as one.
*/
async function playlistKey(userId) {
if (_key && _keyFor === userId) return _key;
let bundleKey = session.bundleKey;
if (!bundleKey) {
bundleKey = await _loadBundleKey();
if (bundleKey) session.bundleKey = bundleKey;
}
if (!bundleKey || !bundleKey.v2hkdf) return null;
_key = await derivePlaylistKey(bundleKey.v2hkdf);
_keyFor = userId;
return _key;
}
/** Sign-out: the next account on this browser must not inherit this one's key. */
function forgetPlaylistKey() {
_key = null;
_keyFor = null;
_state = null;
}
/**
* Drop this account's local copy — what a browser that has never seen it looks
* like. Exported for the tests, which have no other way to be a fresh device.
*/
async function forgetLocal(userId) {
const st = await ensureLoaded(userId);
for (const id of Object.keys(st.manifest.playlists || {})) {
await _idbPut(userId, bodyKind(id), null);
}
await _idbPut(userId, MANIFEST_KIND, null);
_state = null;
}
// ── local storage ────────────────────────────────────────────────────────────
//
// Plaintext in IndexedDB, exactly as the cached group indexes already are
// (`hub-client.js`'s `group_indexes`). What is sealed is the copy that leaves
// this browser; a second encryption layer over the local cache would protect
// against nothing the index cache is not already exposed to, and would need a
// key held in the same place.
//
// Keyed by `userId|kind`, not by `kind`. Two accounts on one browser is
// ordinary, and a sign-out that fails to run is ordinary too — so the
// separation is in the key rather than in a cleanup path that has to happen.
// The database, its version and its stores are `hub-client.js`'s: two modules
// opening one database at versions of their own is a `VersionError` thrown at
// whichever runs second, and the store this uses was added there at version 2
// (docs/playlists.md §14.2).
async function _idbGet(userId, kind) {
try {
const db = await openDB();
const tx = db.transaction(IDB_PLAYLISTS, 'readonly');
const req = tx.objectStore(IDB_PLAYLISTS).get(`${userId}|${kind}`);
const value = await new Promise((res, rej) => {
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
});
db.close();
return value || null;
} catch { return null; }
}
async function _idbPut(userId, kind, value) {
try {
const db = await openDB();
const tx = db.transaction(IDB_PLAYLISTS, 'readwrite');
tx.objectStore(IDB_PLAYLISTS).put(value, `${userId}|${kind}`);
await new Promise((res, rej) => { tx.oncomplete = res; tx.onerror = rej; });
db.close();
} catch { /* a private window, or storage refused — the session still works */ }
}
// ── pushing, which is this module's job and not the caller's ─────────────────
//
// The first version left it to the call sites: `music-app.js` synced after
// adding tracks, and nothing else did. Creating a playlist, deleting one,
// removing a track and saving the queue all wrote to IndexedDB and stopped
// there — so a second device saw nothing, which is the one thing this feature
// exists for. Found by reading the node's own audit log: two events, ever.
//
// Scattering `syncNow()` across six call sites would be the same mistake with
// five more places to forget it. The store knows when it changed; it pushes.
let _transport = null; // () => Promise<transport|null>, registered by the shell
let _pushTimer = null;
let _retryTimer = null;
let _pushFor = null;
// Coalescing window, and the single retry after a push that did not land.
// `let` rather than `const` so the tests can shorten them: a retry nobody can
// wait for is a retry nobody has checked, and this feature has already shipped
// one path that no test ever called.
let PUSH_DEBOUNCE_MS = 1500;
let PUSH_RETRY_MS = 20000;
function setPushTimings({ debounceMs, retryMs }) {
if (debounceMs != null) PUSH_DEBOUNCE_MS = debounceMs;
if (retryMs != null) PUSH_RETRY_MS = retryMs;
}
// What the last push actually did. A background push that fails reports to
// nobody by construction — which is how playlists stopped leaving a browser for
// two hours while the interface showed them perfectly. `lastSync()` is what
// "Sync now" and a support question can both read.
let _lastSync = { ok: null, reason: 'never run', at: null };
function lastSync() {
return { ..._lastSync };
}
/**
* How the store reaches a node. The shell registers this because the shell owns
* connections; `null` unregisters it at sign-out.
*/
function setPlaylistTransport(fn) {
_transport = fn;
}
/**
* Push after a write, coalesced.
*
* Adding an album is one write and should be one push, and so should a burst of
* stars. A second and a half is long enough to swallow a burst and short enough
* that closing the laptop afterwards is not how the edit is lost.
*
* Failure is silent by design: the local copy is the authority (§6.4), and the
* next sync — the next group opened, or "Sync now" — carries it. What must not
* happen is the write failing because the push did.
*/
function _schedulePush(userId, isRetry) {
if (!_transport) return;
_pushFor = userId;
clearTimeout(_pushTimer);
_pushTimer = setTimeout(async () => {
_pushTimer = null;
let result;
try {
const tr = await _transport();
result = tr ? await syncWith(tr, _pushFor)
: { ok: false, reason: 'no reachable node' };
} catch (err) {
// The write itself already succeeded locally; this is only the push.
result = { ok: false, reason: err.message || String(err) };
}
_note(result);
// One retry, and exactly one. A node that is off tends to stay off, and a
// push that keeps looping spends a phone's battery on a node that is not
// coming back — while the local copy is already correct and the next
// mutation, or the next Music tab opened, carries it anyway. `no_key` is
// never retried: it cannot fix itself without the reader doing something.
if (!result.ok && !isRetry && result.reason !== 'no_key') {
clearTimeout(_retryTimer);
_retryTimer = setTimeout(() => _schedulePush(_pushFor, true), PUSH_RETRY_MS);
}
}, isRetry ? 0 : PUSH_DEBOUNCE_MS);
}
/**
* A device that has nothing of its own fetches, once.
*
* §7 said "on sign-in, nothing", on the grounds that the local copy is
* authoritative and complete. That is true of a device that has been used
* before and **false of a new one** — which is the case this whole feature
* exists for. Signing in on a phone and finding the playlists absent is exactly
* what the design promised would not happen, and exactly what it did.
*
* It runs **whatever this browser already holds**, which is the correction to
* the correction: bounding it to an empty device left the ordinary case out.
* A phone that already has nine playlists and is missing the tenth would not
* have gone looking either, and would have waited for a group's Music tab to
* be opened — which is not where anybody looks for a playlist.
*
* Once per sign-in, and it is one `list` plus one `fetch`: the manifest is a
* few kilobytes, and bodies are still pulled only when they are behind.
*/
async function pullOnce(userId) {
if (!_transport) return { ok: false, reason: 'no_transport' };
try {
const tr = await _transport();
if (!tr) return { ok: false, reason: 'offline' };
return await syncWith(tr, userId);
} catch (err) {
return { ok: false, reason: err.message || 'failed' };
}
}
/**
* Record what a push did, and say so once in the console when it did not work.
*
* Silence was the actual defect here, not any single bug: every path that could
* fail swallowed its reason, so the interface, the console and the node's log
* all agreed that nothing was wrong.
*/
function _note(result) {
_lastSync = {
ok: !!(result && result.ok),
reason: (result && result.reason) || null,
unreadable: !!(result && result.unreadable),
pushed: (result && result.pushed) || 0,
at: new Date().toISOString(),
};
if (!_lastSync.ok) {
console.warn('[MeshBay] playlists not synced:', _lastSync.reason);
}
return result;
}
/**
* Send a pending push now, because the page is about to go away.
*
* The shell calls this on `pagehide` and when the tab goes hidden — closing a
* laptop within the coalescing window is not a rare thing to do, and it is the
* one moment when waiting another second and a half means waiting until the
* next time Music is opened.
*
* Best-effort by nature: a browser tearing a page down owes an `await`
* nothing. It costs a message when it works and nothing when it does not.
*/
async function flushPush() {
if (!_pushTimer && !_retryTimer) return;
clearTimeout(_pushTimer);
clearTimeout(_retryTimer);
_pushTimer = null;
_retryTimer = null;
try {
const tr = await _transport();
if (tr) _note(await syncWith(tr, _pushFor));
} catch (err) {
_note({ ok: false, reason: err.message || String(err) });
}
}
// ── in-memory state ──────────────────────────────────────────────────────────
let _state = null; // { userId, manifest, bodies: Map(id -> body) }
async function ensureLoaded(userId) {
if (_state && _state.userId === userId) return _state;
const manifest = (await _idbGet(userId, MANIFEST_KIND)) || emptyManifest();
_state = { userId, manifest, bodies: new Map() };
return _state;
}
async function _body(userId, id) {
const st = await ensureLoaded(userId);
if (st.bodies.has(id)) return st.bodies.get(id);
const body = (await _idbGet(userId, bodyKind(id))) || emptyBody(id);
st.bodies.set(id, body);
return body;
}
async function _saveManifest(st) {
// Its own counter, separate from the per-playlist revisions: it is what the
// node's `rev` column carries, and what a listing compares without opening
// anything.
st.manifest.rev = (st.manifest.rev || 0) + 1;
await _idbPut(st.userId, MANIFEST_KIND, st.manifest);
}
async function _saveBody(st, body) {
st.bodies.set(body.id, body);
await _idbPut(st.userId, bodyKind(body.id), body);
}
/** The manifest entry for `id`, materialised if this is Favourites. */
function _entry(st, id, name) {
let e = st.manifest.playlists[id];
if (!e) {
e = {
name: name || '', rev: 1, body_rev: 0, device: deviceId(),
updated_at: Math.floor(Date.now() / 1000), deleted: false, count: 0,
};
st.manifest.playlists[id] = e;
}
return e;
}
// ── reading ──────────────────────────────────────────────────────────────────
/**
* Every playlist a reader should see, Favourites first.
*
* Drawn from the manifest alone — names, counts, a few kilobytes — so the
* "add to playlist" menu opens instantly with every node offline and no body
* fetched. That is what the manifest is for.
*/
async function listPlaylists(userId) {
const st = await ensureLoaded(userId);
return livePlaylists(st.manifest);
}
/**
* One playlist's tracks, in the shape the player consumes.
*
* `cachedIndexes` is optional and is `[{ groupId, entries }]`: given it, an
* entry whose hash is no longer in its group's index but whose path is gets
* its id rewritten, and the other way round. Repaired in memory and written
* back, once, so the next read costs nothing.
*/
async function getPlaylistTracks(userId, id, cachedIndexes) {
const body = await _body(userId, id);
let tracks = body.tracks || [];
if (cachedIndexes && cachedIndexes.length) {
let changed = 0;
for (const { groupId, entries } of cachedIndexes) {
const res = repairTracks(tracks, groupId, indexTracks(entries));
tracks = res.tracks;
changed += res.repaired;
}
if (changed) {
const st = await ensureLoaded(userId);
await _saveBody(st, { ...body, tracks });
}
}
return tracks.map(fromStored);
}
// ── writing ──────────────────────────────────────────────────────────────────
function _newId() {
return (crypto.randomUUID && crypto.randomUUID())
|| Array.from(crypto.getRandomValues(new Uint8Array(16)),
(b) => b.toString(16).padStart(2, '0')).join('');
}
/**
* Create a playlist under a name that is not already taken.
*
* Case- and accent-folded, because "Soirée" and "soiree" being two playlists
* is not something anyone wants and is very easy to do by accident.
*/
async function createPlaylist(userId, name) {
const st = await ensureLoaded(userId);
const wanted = String(name || '').trim();
if (!wanted) throw new Error('empty name');
const fold = (s) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
for (const p of livePlaylists(st.manifest)) {
if (fold(p.name) === fold(wanted)) throw new Error('duplicate name');
}
const id = _newId();
_entry(st, id, wanted);
await _saveManifest(st);
await _saveBody(st, emptyBody(id));
_schedulePush(userId);
return id;
}
async function renamePlaylist(userId, id, name) {
const st = await ensureLoaded(userId);
const e = _entry(st, id);
e.name = String(name || '').trim();
e.rev += 1;
e.device = deviceId();
e.updated_at = Math.floor(Date.now() / 1000);
await _saveManifest(st);
_schedulePush(userId);
}
/**
* Tombstone a playlist. Favourites is refused outright.
*
* Refusing rather than special-casing is what keeps the tombstone rule free of
* an exception: a tombstone on the reserved id would have to be ignored by
* every reader, and a tombstone rule with an exception is how the resurrection
* defect ships.
*/
async function deletePlaylist(userId, id) {
if (id === FAVORITES_ID) throw new Error('favorites cannot be deleted');
const st = await ensureLoaded(userId);
const e = _entry(st, id);
e.deleted = true;
e.rev += 1;
e.device = deviceId();
e.updated_at = Math.floor(Date.now() / 1000);
await _saveManifest(st);
st.bodies.delete(id);
await _idbPut(st.userId, bodyKind(id), null);
_schedulePush(userId);
}
/**
* Append entries to a playlist, creating it if this is Favourites' first use.
*
* Duplicates are allowed in an ordinary playlist — real ones have them — and
* refused in Favourites, where the gesture is a toggle rather than an append.
* Returns how many were actually added, so the caller can say so.
*/
async function addTracks(userId, id, entries, groupId, name) {
const st = await ensureLoaded(userId);
const e = _entry(st, id, id === FAVORITES_ID ? (name || 'Favorites') : name);
const body = await _body(userId, id);
const existing = new Set(body.tracks.map((tr) => tr.id));
const incoming = entries
.map((entry) => toStored(entry, groupId))
.filter((tr) => (id === FAVORITES_ID ? !existing.has(tr.id) : true));
if (!incoming.length) return 0;
const next = {
...body,
rev: (body.rev || 0) + 1,
device: deviceId(),
tracks: [...body.tracks, ...incoming],
};
await _saveBody(st, next);
e.body_rev = next.rev;
e.count = next.tracks.length;
e.updated_at = Math.floor(Date.now() / 1000);
await _saveManifest(st);
_schedulePush(userId);
return incoming.length;
}
async function removeTrackAt(userId, id, at) {
const st = await ensureLoaded(userId);
const body = await _body(userId, id);
if (at < 0 || at >= body.tracks.length) return false;
const next = {
...body,
rev: (body.rev || 0) + 1,
device: deviceId(),
tracks: [...body.tracks.slice(0, at), ...body.tracks.slice(at + 1)],
};
await _saveBody(st, next);
const e = _entry(st, id);
e.body_rev = next.rev;
e.count = next.tracks.length;
e.updated_at = Math.floor(Date.now() / 1000);
await _saveManifest(st);
_schedulePush(userId);
return true;
}
/**
* The current queue as a new playlist.
*
* The caller passes the tracks in **play order** — what the queue panel is
* showing — rather than in the order they were added, because that is what
* "save what I am listening to" means, shuffle included.
*/
async function saveQueueAsPlaylist(userId, name, entries) {
const id = await createPlaylist(userId, name);
await addTracks(userId, id, entries, null);
return id;
}
// ── sync ─────────────────────────────────────────────────────────────────────
/**
* Reconcile with one node, over a connection that was open anyway.
*
* Manifest first, always; bodies only when the merged manifest says this
* browser's copy is behind, or when the node's is. A reader with forty
* playlists who only ever plays two never transfers the other thirty-eight.
*
* Every failure is swallowed and reported in the return value rather than
* thrown: this rides someone else's connection, and a playlist that could not
* be reconciled must never break whatever that connection was opened for.
*/
async function syncWith(transport, userId) {
const result = {
ok: false, pulled: 0, pushed: 0, reason: null,
// Named, not counted: "one playlist is too large" is only actionable if
// the reader is told which one.
tooLarge: [], failed: [],
};
if (!transport || !transport.connected) {
result.reason = 'offline';
return result;
}
const key = await playlistKey(userId);
if (!key) {
// No HKDF handle: a session from before it existed. Nothing to fall back
// to, and silently doing nothing would be the worse answer.
result.reason = 'no_key';
return result;
}
const st = await ensureLoaded(userId);
// What this node holds, and at what revision — kinds and numbers, no
// payloads, one message. Without it there is no way to tell "the node is
// behind" from "the node is up to date", and the first version of this
// pushed every body on every sync because it could not tell the difference.
const have = new Map();
try {
for (const b of await transport.listUserBlobs()) have.set(b.kind, b.rev);
} catch (err) {
// A node too old to know these messages at all. Nothing to reconcile with,
// and nothing lost: the local copy is the authority and the next node this
// browser reaches will take the writes.
result.reason = err.message || 'unsupported';
return result;
}
let theirs = null;
if (have.has(MANIFEST_KIND)) {
let row = null;
try {
row = await transport.fetchUserBlob(MANIFEST_KIND);
} catch (err) {
// The transport failed. Nothing to reconcile against and nothing to
// overwrite, so leave this node alone.
result.reason = err.message || 'fetch failed';
return result;
}
if (row && row.blob_enc) {
try {
theirs = await open(row.blob_enc, MANIFEST_KIND, userId, key);
} catch (err) {
// A blob this account cannot open with this passphrase is not an older
// copy of anything — it is unreadable, and treating it as a *fetch*
// failure wedged the sync permanently: the first write landed because
// the node was empty, and every sync after it returned here without
// ever pushing again. The client is the authority (§6.4), so an
// unreadable remote copy is an absence, and gets overwritten.
console.warn('[MeshBay] playlist manifest on this node will not open:',
err.message, '— overwriting it with the local copy');
result.unreadable = true;
theirs = null;
}
}
}
const merged = mergeManifests(st.manifest, theirs || emptyManifest());
merged.rev = Math.max(st.manifest.rev || 0, (theirs && theirs.rev) || 0);
const grew = JSON.stringify(merged.playlists) !== JSON.stringify(st.manifest.playlists);
if (grew) {
st.manifest = merged;
await _idbPut(userId, MANIFEST_KIND, merged);
result.pulled += 1;
}
// Bodies, compared against what *this node* holds rather than against the
// merged watermark: the watermark is the highest revision anybody has seen,
// which says nothing about whether this particular node has it.
//
// A body is fetched only when this node is ahead, and pushed only when it is
// behind. A reader with forty playlists who only ever plays two never
// transfers the other thirty-eight, and a sync with nothing to do writes
// nothing at all.
for (const p of livePlaylists(merged)) {
const local = await _body(userId, p.id);
const kind = bodyKind(p.id);
const nodeRev = have.has(kind) ? (have.get(kind) || 0) : -1;
const localRev = local.rev || 0;
if (nodeRev > localRev) {
try {
const row = await transport.fetchUserBlob(kind);
if (row && row.blob_enc) {
const remote = await open(row.blob_enc, kind, userId, key);
const best = mergeBodies(local, remote);
if (best && (best.rev || 0) > localRev) {
await _saveBody(st, best);
result.pulled += 1;
}
}
} catch {
// Same reasoning as the manifest above, and already the right shape:
// one body that will not open must not stop the rest, and the local
// copy is pushed over it on the next write to that playlist.
result.unreadable = true;
}
} else if (localRev > nodeRev && localRev > 0) {
// Was: one `catch {}` covering both of the cases below. A node that went
// away mid-sweep and a playlist that can never be sent are not the same
// event, and swallowing the second is the silent loss this whole design
// exists to prevent — the reader keeps adding tracks to a playlist that
// stopped leaving the browser, and nothing anywhere says so.
let sealed;
try {
sealed = await seal(local, kind, userId, key);
} catch {
result.failed.push(p.name);
continue;
}
if (sealed.byteLength > MAX_BODY_BYTES) {
result.tooLarge.push(p.name);
continue;
}
try {
await transport.storeUserBlob(kind, localRev, sealed);
result.pushed += 1;
} catch {
// A node that went away mid-sweep: the next sync pushes this, because
// the node's revision is still behind the local one.
result.failed.push(p.name);
}
}
}
// A tombstoned playlist's body is dropped from every node as it is reached.
// The tombstone in the manifest is what has to survive, not the tracks —
// and without this the body of every playlist ever deleted stays on every
// node for ever, filling the account's quota with graves.
for (const [id, e] of Object.entries(merged.playlists)) {
if (!e.deleted) continue;
const kind = bodyKind(id);
if (!have.has(kind)) continue;
try { await transport.deleteUserBlob(kind); } catch { /* next time round */ }
}
// The manifest goes last, so a node never advertises a body it has not been
// given: a reader on a third device would fetch a watermark, ask for the
// body behind it and be told there is none.
const mine = JSON.stringify(st.manifest.playlists);
if (!theirs || mine !== JSON.stringify(theirs.playlists)) {
try {
await transport.storeUserBlob(
MANIFEST_KIND, st.manifest.rev || 1,
await seal(st.manifest, MANIFEST_KIND, userId, key));
result.pushed += 1;
} catch (err) {
result.reason = err.message || 'store failed';
return result;
}
}
result.ok = true;
return result;
}
export {
FAVORITES_ID,
setPlaylistTransport, flushPush, pullOnce, forgetLocal, lastSync,
setPushTimings,
deviceId, playlistKey, forgetPlaylistKey, ensureLoaded,
listPlaylists, getPlaylistTracks,
createPlaylist, renamePlaylist, deletePlaylist,
addTracks, removeTrackAt, saveQueueAsPlaylist,
syncWith,
};
|