aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/playlist-merge.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/playlist-merge.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlist-merge.js258
1 files changed, 258 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlist-merge.js b/packages/meshbay-hub/src/meshbay_hub/static/playlist-merge.js
new file mode 100644
index 0000000..ab67aa8
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/playlist-merge.js
@@ -0,0 +1,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,
+};