/** * 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, };