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

fix: various atproto things

+181 -93
+1 -1
.env
··· 1 - ATPROTO_CLIENT_ID=https://handed-pixels-solo-folks.trycloudflare.com/oauth-client-metadata.tunnel.json 1 + ATPROTO_CLIENT_ID=https://cimd-service.fly.dev/clients/bafyreidf2esqfai4xh2osfrq6oekkkvburcjvomebn4age7hks7teb77fi 2 2 # DISABLE_AUTOMATIC_TRACKS_PROCESSING=t
+19 -1
_config.ts
··· 89 89 name: "atcute-multibase-browser", 90 90 setup(build) { 91 91 build.onLoad( 92 - { filter: /@atcute\+multibase.*-node\.js$/ }, 92 + { filter: /@atcute[+/]multibase.*-node\.js$/ }, 93 93 async (args) => { 94 94 const browserPath = args.path.replace( 95 95 "-node.js", 96 96 "-web.js", 97 + ); 98 + const contents = await Deno.readTextFile(browserPath); 99 + return { contents, loader: "js" }; 100 + }, 101 + ); 102 + }, 103 + }, 104 + // nanoid ships a browser entry (index.browser.js) but esbuild resolves 105 + // the default condition (index.js) which uses Buffer.allocUnsafe. 106 + { 107 + name: "nanoid-browser", 108 + setup(build) { 109 + build.onLoad( 110 + { filter: /nanoid\/index\.js$/ }, 111 + async (args) => { 112 + const browserPath = args.path.replace( 113 + "index.js", 114 + "index.browser.js", 97 115 ); 98 116 const contents = await Deno.readTextFile(browserPath); 99 117 return { contents, loader: "js" };
+2
deno.jsonc
··· 4 4 "vendor": true, 5 5 "imports": { 6 6 "98.css": "npm:98.css@^0.1.21", 7 + "@atcute/car": "npm:@atcute/car@^5.1.1", 7 8 "@atcute/cbor": "npm:@atcute/cbor@^2.3.2", 8 9 "@atcute/cid": "npm:@atcute/cid@^2.4.1", 9 10 "@atcute/tid": "npm:@atcute/tid@^1.1.2", 10 11 "@atcute/client": "npm:@atcute/client@^4.2.1", 12 + "@atcute/repo": "npm:@atcute/repo@^0.1.3", 11 13 "@atcute/identity-resolver": "npm:@atcute/identity-resolver@^1.2.2", 12 14 "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.9", 13 15 "@atcute/oauth-browser-client": "npm:@atcute/oauth-browser-client@^3.0.0",
+127 -64
src/components/output/raw/atproto/element.js
··· 1 1 import { Client, ClientResponseError, ok } from "@atcute/client"; 2 + import { encode } from "@atcute/cbor"; 3 + import { xxh32r } from "xxh32/dist/raw.js"; 4 + import * as Repo from "@atcute/repo"; 5 + import * as IDB from "idb-keyval"; 2 6 import * as TID from "@atcute/tid"; 3 7 4 8 import { computed, signal } from "~/common/signal.js"; 5 9 import { BroadcastedOutputElement, outputManager } from "../../common.js"; 10 + import * as Output from "~/common/output.js"; 6 11 7 12 import { 8 13 clearStoredSession, ··· 14 19 } from "./oauth.js"; 15 20 16 21 /** 17 - * @import {PlaylistItemBundle, TrackBundle} from "~/definitions/types.d.ts" 22 + * @import {TrackBundle} from "~/definitions/types.d.ts" 18 23 * @import {OutputManager} from "../../types.d.ts" 19 24 * @import {ATProtoOutputElement} from "./types.d.ts" 20 25 */ ··· 52 57 }, 53 58 playlistItems: { 54 59 empty: () => [], 55 - get: async () => { 56 - const bundles = await this.listRecords( 57 - "sh.diffuse.output.playlistItemBundle", 58 - ); 59 - 60 - return bundles.flatMap((bundle) => bundle.playlistItems ?? []); 61 - }, 62 - put: (data) => { 63 - /** @type {PlaylistItemBundle[]} */ 64 - const bundles = []; 65 - 66 - for (let i = 0; i < data.length; i += 100) { 67 - bundles.push({ 68 - $type: "sh.diffuse.output.playlistItemBundle", 69 - id: TID.now(), 70 - playlistItems: data.slice(i, i + 100), 71 - }); 72 - } 73 - 74 - return this.#putRecords( 75 - "sh.diffuse.output.playlistItemBundle", 76 - bundles, 77 - { upsertBatchSize: 1 }, 78 - ); 79 - }, 60 + get: () => this.listRecords("sh.diffuse.output.playlistItem"), 61 + put: (data) => this.#putRecords("sh.diffuse.output.playlistItem", data), 80 62 }, 81 63 themes: { 82 64 empty: () => [], ··· 92 74 93 75 return bundles.flatMap((bundle) => bundle.tracks ?? []); 94 76 }, 95 - put: (data) => { 77 + put: async (data) => { 78 + const current = await Output.data(this.#manager.tracks); 79 + const hashCurrent = xxh32r(encode(current)); 80 + const hashNew = xxh32r(encode(data)); 81 + 82 + if (hashCurrent === hashNew) { 83 + return; 84 + } 85 + 96 86 /** @type {TrackBundle[]} */ 97 87 const bundles = []; 98 88 ··· 285 275 } 286 276 287 277 /** 278 + * Fetch the full repo CAR for the authenticated user, cached in IDB by rev. 279 + * Returns null if not authenticated or if the commit rev cannot be determined. 280 + * 281 + * @returns {Promise<Uint8Array | null>} 282 + */ 283 + async #getRepoCar() { 284 + const did = this.#did.value; 285 + const rpc = this.#rpc; 286 + if (!rpc || !did) return null; 287 + 288 + const latestRev = await this.getLatestCommit(); 289 + if (!latestRev) return null; 290 + 291 + const IDB_KEY = `diffuse/output/raw/atproto/repo/${did}`; 292 + const cached = 293 + /** @type {{ rev: string, bytes: Uint8Array } | undefined} */ ( 294 + await IDB.get(IDB_KEY) 295 + ); 296 + 297 + if (cached?.rev === latestRev) { 298 + return cached.bytes; 299 + } 300 + 301 + const bytes = await ok(rpc.get("com.atproto.sync.getRepo", { 302 + params: { did }, 303 + as: "bytes", 304 + })); 305 + 306 + await IDB.set(IDB_KEY, { rev: latestRev, bytes }); 307 + return bytes; 308 + } 309 + 310 + /** 288 311 * @template T 289 312 * @param {string} collection 290 313 * @param {string} [did] ··· 293 316 async listRecords(collection, did) { 294 317 did ??= this.#did.value ?? undefined; 295 318 296 - const rpc = this.#rpc; 297 - if (!rpc || !did) return []; 319 + if (!this.#rpc || !did) return []; 298 320 299 321 try { 300 - const records = []; 301 - 302 - /** @type {any} */ 303 - let cursor; 322 + const bytes = await this.#getRepoCar(); 323 + if (!bytes) return []; 304 324 305 - do { 306 - const page = await ok(rpc.get( 307 - "com.atproto.repo.listRecords", 308 - { params: { repo: did, collection, limit: 100, cursor } }, 309 - )); 310 - 311 - for (const record of (page?.records ?? [])) { 312 - records.push(record.value); 325 + const records = []; 326 + for (const entry of Repo.fromUint8Array(bytes)) { 327 + if (entry.collection === collection) { 328 + records.push(/** @type {T} */ (entry.record)); 313 329 } 314 - 315 - cursor = page?.cursor; 316 - } while (cursor); 317 - 330 + } 318 331 return records; 319 332 } catch (err) { 320 333 if (this.#isSessionError(err)) { ··· 344 357 /** @type {Map<string, { rkey: string, value: unknown }>} */ 345 358 const existing = new Map(); 346 359 347 - /** @type {any} */ 348 - let cursor; 349 - 350 - do { 351 - const page = await ok(rpc.get( 352 - "com.atproto.repo.listRecords", 353 - { 354 - params: { repo: this.#did.value, collection, limit: 100, cursor }, 355 - }, 356 - )); 357 - 358 - for (const record of (page?.records ?? [])) { 359 - const rkey = record.uri.split("/").pop(); 360 - existing.set(record.value.id, { rkey, value: record.value }); 360 + const repoBytes = await this.#getRepoCar(); 361 + if (repoBytes) { 362 + for (const entry of Repo.fromUint8Array(repoBytes)) { 363 + if (entry.collection === collection) { 364 + const record = /** @type {any} */ (entry.record); 365 + existing.set(record.id, { rkey: entry.rkey, value: record }); 366 + } 361 367 } 362 - 363 - cursor = page?.cursor; 364 - } while (cursor); 368 + } 365 369 366 370 // 2. Build desired state 367 371 const desired = new Map( ··· 405 409 } 406 410 } 407 411 408 - // 4. Apply in batches 409 - const applyBatch = async (/** @type {unknown[]} */ batch) => { 412 + // 4. Apply in batches, throttled to 1500 ops/hour via a persisted sliding window 413 + const WINDOW_MS = 3_600_000; 414 + const RATE_LIMIT = 1500; 415 + 416 + const IDB_KEY = "diffuse/output/raw/atproto/writes"; 417 + 418 + /** 419 + * Returns all record IDs written within the last hour, loaded from IDB. 420 + * 421 + * @returns {Promise<{ id: string, ts: number }[]>} 422 + */ 423 + const loadWindow = async () => { 424 + const now = Date.now(); 425 + const all = await IDB.get(IDB_KEY) ?? []; 426 + return all.filter( 427 + /** 428 + * @param {{ id: string, ts: number }} entry 429 + * @returns {boolean} 430 + */ 431 + (entry) => now - entry.ts < WINDOW_MS, 432 + ); 433 + }; 434 + 435 + /** 436 + * Records IDs as written, pruning entries older than one hour. 437 + * 438 + * @param {string[]} ids 439 + */ 440 + const recordWritten = async (ids) => { 441 + const now = Date.now(); 442 + const window = await loadWindow(); 443 + await IDB.set(IDB_KEY, [ 444 + ...window, 445 + ...ids.map((id) => ({ id, ts: now })), 446 + ]); 447 + }; 448 + 449 + const applyBatch = async (/** @type {any[]} */ batch) => { 450 + // Wait until the sliding window has room for this batch 451 + while (true) { 452 + const window = await loadWindow(); 453 + const uniqueInWindow = new Set(window.map((e) => e.id)); 454 + const batchIds = batch.map((op) => op.rkey ?? op.value?.id).filter( 455 + Boolean, 456 + ); 457 + const newIds = batchIds.filter((id) => !uniqueInWindow.has(id)); 458 + 459 + if (uniqueInWindow.size + newIds.length <= RATE_LIMIT) break; 460 + 461 + // Wait until the oldest entry in the window expires 462 + const oldest = window.reduce((a, b) => a.ts < b.ts ? a : b); 463 + const waitMs = WINDOW_MS - (Date.now() - oldest.ts) + 1; 464 + await new Promise((resolve) => setTimeout(resolve, waitMs)); 465 + } 466 + 410 467 const result = await ok(rpc.post("com.atproto.repo.applyWrites", { 411 468 input: { repo: this.#did.value, writes: batch }, 412 469 })); 470 + 471 + const writtenIds = batch.map((op) => op.rkey ?? op.value?.id).filter( 472 + Boolean, 473 + ); 474 + 475 + await recordWritten(writtenIds); 413 476 414 477 if (result?.commit?.rev) { 415 478 this.#rev.value = result.commit.rev;
+1 -11
src/components/output/raw/atproto/oauth.js
··· 35 35 36 36 const location = globalThis.location; 37 37 38 - let redirect_uri = (location.origin + location.pathname + location.search) 39 - .replace( 40 - "://localhost", 41 - "://127.0.0.1", 42 - ); 38 + let redirect_uri = location.origin + location.pathname + location.search; 43 39 44 40 const isLocalDev = redirect_uri.startsWith("http://127.0.0.1"); 45 41 ··· 83 79 */ 84 80 export async function login(handle) { 85 81 const location = globalThis.location; 86 - 87 - if (location.origin.startsWith("http://localhost")) { 88 - location.assign( 89 - location.href.replace("http://localhost:", "http://127.0.0.1:"), 90 - ); 91 - } 92 82 93 83 sessionStorage.setItem( 94 84 "diffuse/output/raw/atproto/oauth/redirect_path",
+3 -1
src/components/transformer/output/raw/atproto-sync/element.js
··· 52 52 collection: computed(() => { 53 53 const l = local(); 54 54 if (!l) return { state: "loading" }; 55 - return l[name].collection(); 55 + const c = l[name].collection(); 56 + if (c.state === "loading") return c; 57 + return { state: "loaded", data: c.data ?? [] }; 56 58 }), 57 59 reload: async () => { 58 60 await this.#sync();
+2 -1
src/components/transformer/output/refiner/track-uri-passkey/element.js
··· 51 51 // Tracks 52 52 this.#tracks = () => { 53 53 const col = base.tracks.collection(); 54 - if (col.state === "loading") { 54 + 55 + if (col?.state !== "loaded") { 55 56 return { state: "loading", locked: [], unlocked: [] }; 56 57 } 57 58
+1 -1
src/oauth-client-metadata.json
··· 3 3 "client_name": "Diffuse", 4 4 "client_uri": "https://elements.diffuse.sh", 5 5 "redirect_uris": ["https://elements.diffuse.sh/oauth/callback"], 6 - "scope": "atproto repo?collection=sh.diffuse.output.facet&collection=sh.diffuse.output.playlistItem&collection=sh.diffuse.output.playlistItemBundle&collection=sh.diffuse.output.theme&collection=sh.diffuse.output.track&collection=sh.diffuse.output.trackBundle", 6 + "scope": "atproto repo?collection=sh.diffuse.output.facet&collection=sh.diffuse.output.playlistItem&collection=sh.diffuse.output.playlistItemBundle&collection=sh.diffuse.output.track&collection=sh.diffuse.output.trackBundle", 7 7 "grant_types": ["authorization_code", "refresh_token"], 8 8 "response_types": ["code"], 9 9 "token_endpoint_auth_method": "none",
-12
src/oauth-client-metadata.tunnel.json
··· 1 - { 2 - "client_id": "https://handed-pixels-solo-folks.trycloudflare.com/oauth-client-metadata.tunnel.json", 3 - "client_name": "Diffuse", 4 - "client_uri": "https://handed-pixels-solo-folks.trycloudflare.com", 5 - "redirect_uris": ["https://handed-pixels-solo-folks.trycloudflare.com/oauth/callback"], 6 - "scope": "atproto repo?collection=sh.diffuse.output.facet&collection=sh.diffuse.output.playlistItem&collection=sh.diffuse.output.playlistItemBundle&collection=sh.diffuse.output.theme&collection=sh.diffuse.output.track&collection=sh.diffuse.output.trackBundle", 7 - "grant_types": ["authorization_code", "refresh_token"], 8 - "response_types": ["code"], 9 - "token_endpoint_auth_method": "none", 10 - "application_type": "web", 11 - "dpop_bound_access_tokens": true 12 - }
+1 -1
src/themes/winamp/configurators/input/element.js
··· 8 8 } from "~/common/element.js"; 9 9 import { signal } from "~/common/signal.js"; 10 10 11 + import { buildURI as buildIcecastURI } from "~/components/input/icecast/common.js"; 11 12 import { buildURI as buildOpenSubsonicURI } from "~/components/input/opensubsonic/common.js"; 12 13 import { buildURI as buildS3URI } from "~/components/input/s3/common.js"; 13 14 import { isSupported as supportsLocalFsAccess } from "~/components/input/local/common.js"; 14 15 15 16 import { SCHEME as HTTPS_SCHEME } from "~/components/input/https/constants.js"; 16 - import { buildURI as buildIcecastURI } from "~/components/input/icecast/common.js"; 17 17 import { SCHEME as ICECAST_SCHEME } from "~/components/input/icecast/constants.js"; 18 18 import { SCHEME as LOCAL_SCHEME } from "~/components/input/local/constants.js"; 19 19 import { SCHEME as OPENSUBSONIC_SCHEME } from "~/components/input/opensubsonic/constants.js";
+24
src/themes/winamp/configurators/output/element.js
··· 103 103 const atproto = this.$atproto.value; 104 104 if (!atproto) return; 105 105 106 + const output = this.$output.value; 107 + if (!output || !("select" in output)) return; 108 + 106 109 /** @type {HTMLButtonElement | null} */ 107 110 const button = this.root().querySelector("#atproto-submit"); 108 111 if (button) { ··· 110 113 button.textContent = "Loading ..."; 111 114 } 112 115 116 + const option = (await output.options()).find((o) => 117 + o.label === "AT Protocol" 118 + ); 119 + 120 + if (option) await output.select(option.id); 113 121 this.$atprotoError.value = null; 114 122 115 123 try { 116 124 await atproto.login(handle); 117 125 } catch (err) { 126 + console.error(err); 127 + 118 128 this.$atprotoError.value = err instanceof Error 119 129 ? err.message 120 130 : "login failed"; 131 + 121 132 if (button) { 122 133 button.disabled = false; 123 134 button.textContent = "Sign in"; ··· 189 200 const option = (await output.options()).find((o) => 190 201 o.label === "AT Protocol" 191 202 ); 203 + 192 204 if (option) await output.select(option.id); 193 205 }; 194 206 ··· 201 213 const s3 = this.$s3.value; 202 214 if (!s3) return; 203 215 216 + const output = this.$output.value; 217 + if (!output || !("select" in output)) return; 218 + 204 219 /** @type {HTMLButtonElement | null} */ 205 220 const button = this.root().querySelector("#s3-submit"); 206 221 if (button) button.disabled = true; ··· 244 259 245 260 await s3.setBucket(bucket); 246 261 262 + const option = (await output.options()).find((o) => 263 + o.label === "AT Protocol" 264 + ); 265 + 266 + if (option) await output.select(option.id); 247 267 if (button) button.disabled = false; 248 268 }; 249 269 ··· 287 307 288 308 <style> 289 309 @import "./themes/winamp/98-vars.css"; 310 + 311 + input, select, textarea { 312 + color: rgb(34, 34, 34); 313 + } 290 314 291 315 .button-row { 292 316 display: inline-flex;