A music player that connects to your cloud/distributed storage.
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: atproto rate limit hits + make it background sync

+47 -14
+16 -10
src/components/output/raw/atproto/element.js
··· 4 4 import { xxh32r } from "xxh32/dist/raw.js"; 5 5 import * as Repo from "@atcute/repo"; 6 6 import * as IDB from "idb-keyval"; 7 - import * as TID from "@atcute/tid"; 8 7 9 8 import { computed, signal } from "~/common/signal.js"; 10 9 import { BroadcastedOutputElement, outputManager } from "../../common.js"; ··· 101 100 const bundles = []; 102 101 103 102 for (let i = 0; i < data.length; i += 100) { 103 + const chunk = data.slice(i, i + 100); 104 104 bundles.push({ 105 105 $type: "sh.diffuse.output.trackBundle", 106 - id: TID.now(), 107 - tracks: data.slice(i, i + 100), 106 + id: xxh32r(encode(chunk)).toString(16), 107 + tracks: chunk, 108 108 }); 109 109 } 110 110 ··· 127 127 #did = signal(/** @type {`did:${string}:${string}` | null} */ (null)); 128 128 #isOnline = signal(navigator.onLine); 129 129 #rev = signal(/** @type {string | null} */ (null)); 130 + #revFetchedAt = 0; 131 + #writing = 0; 130 132 131 133 did = this.#did.get; 132 134 rev = this.#rev.get; ··· 324 326 ); 325 327 326 328 if (touched.size === 0) return; 329 + if (this.#writing > 0) return; 327 330 328 331 if (touched.has("sh.diffuse.output.facet")) this.#manager.facets.reload(); 329 332 if (touched.has("sh.diffuse.output.playlistItem")) { ··· 355 358 )); 356 359 357 360 this.#rev.value = result?.rev; 361 + this.#revFetchedAt = Date.now(); 358 362 return result?.rev; 359 363 } catch (err) { 360 364 if (this.#isSessionError(err)) { ··· 377 381 const rpc = this.#rpc; 378 382 if (!rpc || !did) return null; 379 383 380 - const latestRev = await this.getLatestCommit(); 384 + const REV_TTL_MS = 5_000; 385 + const latestRev = 386 + (Date.now() - this.#revFetchedAt < REV_TTL_MS && this.#rev.value) 387 + ? this.#rev.value 388 + : await this.getLatestCommit(); 381 389 if (!latestRev) return null; 382 390 383 391 const IDB_KEY = `diffuse/output/raw/atproto/repo/${did}`; ··· 446 454 447 455 if (!rpc || !did) return; 448 456 457 + this.#writing++; 449 458 try { 450 459 // 1. Fetch current state 451 460 /** @type {Map<string, { rkey: string, value: unknown }>} */ ··· 544 553 // Wait until the sliding window has room for this batch 545 554 while (true) { 546 555 const window = await loadWindow(); 547 - const uniqueInWindow = new Set(window.map((e) => e.id)); 548 - const batchIds = batch.map((op) => op.rkey ?? op.value?.id).filter( 549 - Boolean, 550 - ); 551 - const newIds = batchIds.filter((id) => !uniqueInWindow.has(id)); 552 556 553 - if (uniqueInWindow.size + newIds.length <= RATE_LIMIT) break; 557 + if (window.length + batch.length <= RATE_LIMIT) break; 554 558 555 559 // Wait until the oldest entry in the window expires 556 560 const oldest = window.reduce((a, b) => a.ts < b.ts ? a : b); ··· 587 591 } 588 592 589 593 throw err; 594 + } finally { 595 + this.#writing--; 590 596 } 591 597 } 592 598 }
+7 -4
src/components/transformer/output/raw/atproto-sync/element.js
··· 82 82 await l[name].save(newData); 83 83 84 84 if (remote.ready()) { 85 - await remote[name].save(newData); 86 - const rev = this.#atproto()?.rev(); 87 - if (rev) this.#storeRev(rev); 88 - this.#clearDirty(); 85 + remote[name].save(newData).then(() => { 86 + const rev = this.#atproto()?.rev(); 87 + if (rev) this.#storeRev(rev); 88 + this.#clearDirty(); 89 + }).catch((err) => { 90 + console.error(err); 91 + }); 89 92 } else { 90 93 this.#markDirty(); 91 94 }
+24
src/facets/data/export-import/index.inline.js
··· 107 107 } 108 108 }; 109 109 110 + /** 111 + * @param {HTMLButtonElement} btn 112 + * @param {string} label 113 + */ 114 + function setButtonLabel(btn, label) { 115 + /** @type {ChildNode} */ (btn.querySelector(".with-icon")?.lastChild).textContent = label; 116 + } 117 + 110 118 // Import tracks 111 119 importTracksBtn.onclick = async () => { 112 120 /** @type {any[]} */ 113 121 const tracks = json?.tracks; 114 122 if (!Array.isArray(tracks) || tracks.length === 0) return; 115 123 124 + importTracksBtn.disabled = true; 125 + setButtonLabel(importTracksBtn, " Importing ..."); 116 126 try { 117 127 await output.tracks.save(tracks); 118 128 showStatus(`Imported ${tracks.length} track(s).`, "success"); 119 129 } catch (err) { 120 130 console.error("Import failed:", err); 121 131 showStatus(`Import failed: ${/** @type {Error} */ (err).message}`, "error"); 132 + } finally { 133 + setButtonLabel(importTracksBtn, " Import tracks"); 134 + importTracksBtn.disabled = false; 122 135 } 123 136 }; 124 137 ··· 128 141 const playlistItems = json?.playlistItems; 129 142 if (!Array.isArray(playlistItems) || playlistItems.length === 0) return; 130 143 144 + importPlaylistItemsBtn.disabled = true; 145 + setButtonLabel(importPlaylistItemsBtn, " Importing ..."); 131 146 try { 132 147 await output.playlistItems.save(playlistItems); 133 148 const playlistCount = new Set(playlistItems.map((p) => p.playlist)).size; ··· 135 150 } catch (err) { 136 151 console.error("Import failed:", err); 137 152 showStatus(`Import failed: ${/** @type {Error} */ (err).message}`, "error"); 153 + } finally { 154 + setButtonLabel(importPlaylistItemsBtn, " Import playlist items"); 155 + importPlaylistItemsBtn.disabled = false; 138 156 } 139 157 }; 158 + 140 159 // Import facets 141 160 importFacetsBtn.onclick = async () => { 142 161 /** @type {any[]} */ 143 162 const facets = json?.facets; 144 163 if (!Array.isArray(facets) || facets.length === 0) return; 145 164 165 + importFacetsBtn.disabled = true; 166 + setButtonLabel(importFacetsBtn, " Importing ..."); 146 167 try { 147 168 await output.facets.save(facets); 148 169 showStatus(`Imported ${facets.length} facet(s).`, "success"); 149 170 } catch (err) { 150 171 console.error("Import failed:", err); 151 172 showStatus(`Import failed: ${/** @type {Error} */ (err).message}`, "error"); 173 + } finally { 174 + setButtonLabel(importFacetsBtn, " Import facets"); 175 + importFacetsBtn.disabled = false; 152 176 } 153 177 }; 154 178