aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 16:07:18 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 16:07:18 +0200
commit2916aa376009283305a7acec4aafa3c96544499e (patch)
treebc19d010e8bebf39c175271564b3be01d1c06aa0 /packages/meshbay-hub/src
parent25f62e169e049f0757ef611d5b409e7523958dee (diff)
downloadmeshbay-2916aa376009283305a7acec4aafa3c96544499e.tar.gz
playlists: make the writes actually leave the browser
Reported from a phone: signing in with the same account showed no playlists. syncWith was called from exactly one place in the interface, so creating a playlist, deleting one, removing a track and saving the queue all wrote to IndexedDB and stopped there. The store pushes itself now, coalesced, so a new mutation cannot forget to. Silence was the real defect. The node audited only successes, so a refusal left no trace and user_blob_list none at all; the background push swallowed its reason; the interface said nothing. All three report now, and "Sync now" says what happened either way. An unreadable blob on a node was treated as a fetch failure and returned before the push — permanent, once the node held anything. It is an absence: the client is the authority, and it gets overwritten. A sign-in reconciles whatever this browser already holds, a pending push is flushed when the page goes away, and a push that did not land is retried once. no_key is spelled out: a client that signs in with its remembered device key only ever has a bundle key persisted before the playlist subkey existed, and an AES handle is non-extractable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlists.js211
13 files changed, 295 insertions, 7 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 38314e7..92dcb28 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -20,7 +20,9 @@ import { GroupPage } from './group-page.js';
import { SearchPage, ConnectionPool } from './search-page.js';
import { MusicPlayerBar } from './music-player.js';
import { NameModal } from './playlist-menu.js';
-import { saveQueueAsPlaylist } from './playlists.js';
+import {
+ saveQueueAsPlaylist, setPlaylistTransport, pullOnce, flushPush,
+} from './playlists.js';
import { IndexingDock } from './index-dock.js';
import { SettingsPage } from './settings-page.js';
import { ProfilePage } from './profile-page.js';
@@ -752,6 +754,63 @@ function App() {
const handleStopMusic = useCallback(() => setMusicQueue(null), []);
+ // ── Playlists reach a node through here ────────────────────────────────
+ //
+ // `playlists.js` pushes on every write and needs a transport to push
+ // through; the shell is what owns connections. Preferring the group already
+ // open costs nothing, and dialing when there is none is a change of position
+ // that is worth stating: §7 refused dialing *at sign-in for a feature nobody
+ // had asked to use*. Somebody who has just made a playlist has asked, and one
+ // dial to save their work is cheaper than losing it.
+ useEffect(() => {
+ if (!user) { setPlaylistTransport(null); return undefined; }
+ setPlaylistTransport(async () => {
+ const gt = groupTransportRef.current;
+ const live = gt && gt.transportRef && gt.transportRef.current;
+ if (live && live.connected) return live;
+ // Bounded: an account with twenty groups, all offline, must not spend
+ // twenty ten-second timeouts on a background push.
+ for (const g of groups.slice(0, 3)) {
+ try {
+ const conn = await getMusicConnection(g.id);
+ if (conn && conn.transport && conn.transport.connected) return conn.transport;
+ } catch { /* try the next one */ }
+ }
+ return null;
+ });
+ // A pending push has a second and a half to wait, and closing a laptop
+ // inside that window is not rare. `pagehide` covers a tab closing and a
+ // navigation; `visibilitychange` covers a phone being backgrounded, which
+ // on iOS is the only one of the two that reliably fires at all.
+ const flush = () => { flushPush().catch(() => {}); };
+ const onHidden = () => { if (document.visibilityState === 'hidden') flush(); };
+ window.addEventListener('pagehide', flush);
+ document.addEventListener('visibilitychange', onHidden);
+
+ return () => {
+ window.removeEventListener('pagehide', flush);
+ document.removeEventListener('visibilitychange', onHidden);
+ setPlaylistTransport(null);
+ };
+ }, [user, groups, getMusicConnection]);
+
+ // Reconcile once per sign-in, whatever this browser already holds.
+ //
+ // Nothing went looking until a group's Music tab happened to be opened, which
+ // is not where anybody looks for a playlist — and bounding this to an empty
+ // device, as the first version did, left out the ordinary case: a phone that
+ // has nine playlists and is missing the tenth.
+ //
+ // `groups.length` moves as the sidebar fills, so the ref is what makes "once"
+ // mean once rather than once per group that arrives.
+ const pulledForRef = useRef(null);
+ useEffect(() => {
+ if (!user || !groups.length) return;
+ if (pulledForRef.current === user.userId) return;
+ pulledForRef.current = user.userId;
+ pullOnce(user.userId).catch(() => {});
+ }, [user, groups.length]);
+
// Saving the queue is a shell-level action because the queue is: the player
// bar outlives every page, and the account it belongs to is here.
const [saveQueue, setSaveQueue] = useState(null);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 9dec910..4cfa0a7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -335,6 +335,7 @@ export default {
'playlists.deleted': '„{name}“ gelöscht',
'playlists.synced': 'Playlists synchronisiert',
'playlists.sync_failed': 'Synchronisierung fehlgeschlagen',
+ 'playlists.err_no_key': 'Melde dich einmal ab und mit deinem Passwort wieder an, damit Playlists zwischen Geräten synchronisiert werden.',
'playlists.track_removed': 'Titel entfernt',
'playlists.save_queue': 'Warteschlange als Playlist speichern…',
'playlists.cancel': 'Abbrechen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 37c7c1b..1616eb5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -333,6 +333,7 @@ export default {
'playlists.deleted': '"{name}" deleted',
'playlists.synced': 'Playlists synced',
'playlists.sync_failed': 'Could not sync playlists',
+ 'playlists.err_no_key': 'Sign out and sign in with your password once to sync playlists across devices.',
'playlists.track_removed': 'Track removed',
'playlists.save_queue': 'Save the queue as a playlist…',
'playlists.cancel': 'Cancel',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 3edc78a..ff74857 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -333,6 +333,7 @@ export default {
'playlists.deleted': '«{name}» eliminada',
'playlists.synced': 'Listas sincronizadas',
'playlists.sync_failed': 'No se pudieron sincronizar',
+ 'playlists.err_no_key': 'Cierra sesión y vuelve a entrar con tu contraseña una vez para sincronizar las listas entre dispositivos.',
'playlists.track_removed': 'Pista quitada',
'playlists.save_queue': 'Guardar la cola como lista…',
'playlists.cancel': 'Cancelar',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index c7e1ed3..52af974 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -334,6 +334,7 @@ export default {
'playlists.deleted': '« {name} » supprimée',
'playlists.synced': 'Playlists synchronisées',
'playlists.sync_failed': 'Synchronisation impossible',
+ 'playlists.err_no_key': 'Déconnecte-toi puis reconnecte-toi avec ton mot de passe une fois, pour synchroniser les playlists entre appareils.',
'playlists.track_removed': 'Morceau retiré',
'playlists.save_queue': 'Enregistrer la file comme playlist…',
'playlists.cancel': 'Annuler',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index bfeb20d..75b420d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -334,6 +334,7 @@ export default {
'playlists.deleted': '«{name}» eliminata',
'playlists.synced': 'Playlist sincronizzate',
'playlists.sync_failed': 'Sincronizzazione non riuscita',
+ 'playlists.err_no_key': 'Esci e rientra con la tua password una volta per sincronizzare le playlist tra dispositivi.',
'playlists.track_removed': 'Brano rimosso',
'playlists.save_queue': 'Salva la coda come playlist…',
'playlists.cancel': 'Annulla',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index caf729a..c87d795 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -331,6 +331,7 @@ export default {
'playlists.deleted': '「{name}」を削除しました',
'playlists.synced': 'プレイリストを同期しました',
'playlists.sync_failed': '同期できませんでした',
+ 'playlists.err_no_key': 'プレイリストを端末間で同期するには、一度サインアウトしてパスワードでサインインしてください。',
'playlists.track_removed': '曲を削除しました',
'playlists.save_queue': 'キューをプレイリストとして保存…',
'playlists.cancel': 'キャンセル',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index b9e719e..4fa3d01 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -335,6 +335,7 @@ export default {
'playlists.deleted': '“{name}” verwijderd',
'playlists.synced': 'Afspeellijsten gesynchroniseerd',
'playlists.sync_failed': 'Synchroniseren mislukt',
+ 'playlists.err_no_key': 'Meld je één keer af en met je wachtwoord weer aan om afspeellijsten tussen apparaten te synchroniseren.',
'playlists.track_removed': 'Nummer verwijderd',
'playlists.save_queue': 'Wachtrij opslaan als afspeellijst…',
'playlists.cancel': 'Annuleren',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 7f87537..d0fd387 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -344,6 +344,7 @@ export default {
'playlists.deleted': 'Usunięto „{name}”',
'playlists.synced': 'Playlisty zsynchronizowane',
'playlists.sync_failed': 'Nie udało się zsynchronizować',
+ 'playlists.err_no_key': 'Wyloguj się i zaloguj raz hasłem, aby synchronizować playlisty między urządzeniami.',
'playlists.track_removed': 'Utwór usunięty',
'playlists.save_queue': 'Zapisz kolejkę jako playlistę…',
'playlists.cancel': 'Anuluj',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 961b353..94c56a6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -335,6 +335,7 @@ export default {
'playlists.deleted': '"{name}" excluída',
'playlists.synced': 'Playlists sincronizadas',
'playlists.sync_failed': 'Não foi possível sincronizar',
+ 'playlists.err_no_key': 'Saia e entre com sua senha uma vez para sincronizar as playlists entre dispositivos.',
'playlists.track_removed': 'Faixa removida',
'playlists.save_queue': 'Salvar a fila como playlist…',
'playlists.cancel': 'Cancelar',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 893b2eb..bc77d5f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -328,6 +328,7 @@ export default {
'playlists.deleted': '已删除“{name}”',
'playlists.synced': '播放列表已同步',
'playlists.sync_failed': '无法同步',
+ 'playlists.err_no_key': '请退出并用密码重新登录一次,以便在设备间同步播放列表。',
'playlists.track_removed': '已移除曲目',
'playlists.save_queue': '将队列保存为播放列表…',
'playlists.cancel': '取消',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js b/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js
index 8d85b68..e2fb3f9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/playlist-menu.js
@@ -183,7 +183,25 @@ function PlaylistMenuButton({ userId, lists, reload, onPlayQueue, onSync, cached
onSelect: async () => {
const r = await onSync();
await reload();
- say(r && r.ok ? t('playlists.synced') : t('playlists.sync_failed'));
+ // The reason, not just "it failed": a sync that quietly does nothing
+ // is the one failure mode nobody can act on, and the reason is what
+ // separates an offline node from a wedged one.
+ // On success, say how much moved; on failure, say why. Either way
+ // the reader learns something they can act on — the one thing this
+ // never did.
+ const last = P.lastSync();
+ const why = (r && r.reason) || last.reason || '?';
+ // `no_key` is not a fault the reader can do anything about unless it
+ // is spelled out. This browser signed in with its remembered device
+ // key, so the only bundle key it has is one persisted before the
+ // playlist subkey existed — and an AES handle is non-extractable, so
+ // there is nothing to derive it from. One sign-in with the passphrase
+ // fixes it for good; a code on screen does not say that.
+ say(r && r.ok
+ ? `${t('playlists.synced')} (${r.pushed || 0}↑ ${r.pulled || 0}↓)`
+ : (why === 'no_key'
+ ? t('playlists.err_no_key')
+ : `${t('playlists.sync_failed')}: ${why}`));
},
},
]);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js
index 11525eb..5aa501a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js
@@ -94,6 +94,19 @@ function forgetPlaylistKey() {
_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
@@ -135,6 +148,166 @@ async function _idbPut(userId, kind, value) {
} 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) }
@@ -246,6 +419,7 @@ async function createPlaylist(userId, name) {
_entry(st, id, wanted);
await _saveManifest(st);
await _saveBody(st, emptyBody(id));
+ _schedulePush(userId);
return id;
}
@@ -257,6 +431,7 @@ async function renamePlaylist(userId, id, name) {
e.device = deviceId();
e.updated_at = Math.floor(Date.now() / 1000);
await _saveManifest(st);
+ _schedulePush(userId);
}
/**
@@ -278,6 +453,7 @@ async function deletePlaylist(userId, id) {
await _saveManifest(st);
st.bodies.delete(id);
await _idbPut(st.userId, bodyKind(id), null);
+ _schedulePush(userId);
}
/**
@@ -308,6 +484,7 @@ async function addTracks(userId, id, entries, groupId, name) {
e.count = next.tracks.length;
e.updated_at = Math.floor(Date.now() / 1000);
await _saveManifest(st);
+ _schedulePush(userId);
return incoming.length;
}
@@ -327,6 +504,7 @@ async function removeTrackAt(userId, id, at) {
e.count = next.tracks.length;
e.updated_at = Math.floor(Date.now() / 1000);
await _saveManifest(st);
+ _schedulePush(userId);
return true;
}
@@ -388,15 +566,31 @@ async function syncWith(transport, userId) {
let theirs = null;
if (have.has(MANIFEST_KIND)) {
+ let row = null;
try {
- const row = await transport.fetchUserBlob(MANIFEST_KIND);
- if (row && row.blob_enc) {
- theirs = await open(row.blob_enc, MANIFEST_KIND, userId, key);
- }
+ 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());
@@ -433,7 +627,12 @@ async function syncWith(transport, userId) {
result.pulled += 1;
}
}
- } catch { /* one body failing must not stop the rest */ }
+ } 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) {
try {
await transport.storeUserBlob(
@@ -476,6 +675,8 @@ async function syncWith(transport, userId) {
export {
FAVORITES_ID,
+ setPlaylistTransport, flushPush, pullOnce, forgetLocal, lastSync,
+ setPushTimings,
deviceId, playlistKey, forgetPlaylistKey, ensureLoaded,
listPlaylists, getPlaylistTracks,
createPlaylist, renamePlaylist, deletePlaylist,