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

Configure Feed

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

at 71250e3e9ffd1e14fcc2db839b625bb50cffcd6c 288 lines 9.4 kB view raw
1import { xxh32r } from "xxh32/dist/raw.js"; 2 3/** 4 * @import {Track} from "~/definitions/types.d.ts" 5 */ 6 7/** 8 * @template T 9 * @param {Array<T>} array 10 * @returns Array<T> 11 * 12 * @example Returns same elements in (possibly different) order without mutating the original 13 * ```js 14 * import { arrayShuffle } from "~/common/utils.js"; 15 * 16 * if (arrayShuffle([]).length !== 0) throw new Error("empty array should return empty"); 17 * 18 * const sorted = arrayShuffle([1, 2, 3, 4, 5]).sort((a, b) => a - b); 19 * if (sorted.join(",") !== "1,2,3,4,5") throw new Error("shuffled array should contain same elements"); 20 * 21 * const original = [1, 2, 3]; 22 * arrayShuffle(original); 23 * if (original.join(",") !== "1,2,3") throw new Error("original array should not be mutated"); 24 * 25 * if (arrayShuffle([42]).join(",") !== "42") throw new Error("single-element array should be unchanged"); 26 * ``` 27 */ 28export function arrayShuffle(array) { 29 if (array.length === 0) { 30 return []; 31 } 32 33 array = [...array]; 34 35 for (let index = array.length - 1; index > 0; index--) { 36 const randArr = crypto.getRandomValues(new Uint32Array(1)); 37 const randVal = randArr[0] / 2 ** 32; 38 const newIndex = Math.floor(randVal * (index + 1)); 39 [array[index], array[newIndex]] = [array[newIndex], array[index]]; 40 } 41 42 return array; 43} 44 45/** 46 * @param {string | undefined | null} value 47 * 48 * @example Returns true only for empty string (present attribute) 49 * ```js 50 * import { boolAttr } from "~/common/utils.js"; 51 * 52 * if (!boolAttr("")) throw new Error("empty string should return true"); 53 * if (boolAttr(null)) throw new Error("null should return false"); 54 * if (boolAttr(undefined)) throw new Error("undefined should return false"); 55 * if (boolAttr("true")) throw new Error("non-empty string should return false"); 56 * ``` 57 */ 58export function boolAttr(value) { 59 return value === ""; 60} 61 62 63/** 64 * @param {any} object 65 * 66 * @example Returns a consistent string hash for the same object 67 * ```js 68 * import { hash } from "~/common/utils.js"; 69 * 70 * if (typeof hash({ a: 1 }) !== "string") throw new Error("hash should return a string"); 71 * if (hash({ a: 1, b: 2 }) !== hash({ a: 1, b: 2 })) throw new Error("same objects should produce same hash"); 72 * if (hash({ a: 1 }) === hash({ a: 2 })) throw new Error("different objects should produce different hashes"); 73 * ``` 74 */ 75export function hash(object) { 76 return xxh32r(jsonEncode(object)).toString(); 77} 78 79/** 80 * @param {Track[]} tracks 81 * @param {Record<string, Track[]>} initial 82 * @returns {Record<string, Track[]>} 83 * 84 * @example Groups tracks by URI scheme 85 * ```js 86 * import { groupTracksPerScheme } from "~/common/utils.js"; 87 * 88 * const tracks = [ 89 * { $type: "sh.diffuse.output.track", id: "1", uri: "http://example.com/a.mp3" }, 90 * { $type: "sh.diffuse.output.track", id: "2", uri: "s3://bucket/b.mp3" }, 91 * { $type: "sh.diffuse.output.track", id: "3", uri: "http://example.com/c.mp3" }, 92 * ]; 93 * // @ts-ignore 94 * const groups = groupTracksPerScheme(tracks); 95 * if (groups["http"].length !== 2) throw new Error("expected 2 http tracks"); 96 * if (groups["s3"].length !== 1) throw new Error("expected 1 s3 track"); 97 * ``` 98 * 99 * @example Merges into a provided initial object 100 * ```js 101 * import { groupTracksPerScheme } from "~/common/utils.js"; 102 * 103 * const initial = { http: [{ $type: "sh.diffuse.output.track", id: "0", uri: "http://existing.com/x.mp3" }] }; 104 * const tracks = [{ $type: "sh.diffuse.output.track", id: "1", uri: "http://example.com/a.mp3" }]; 105 * // @ts-ignore 106 * const merged = groupTracksPerScheme(tracks, initial); 107 * if (merged["http"].length !== 2) throw new Error("expected 2 http tracks after merge"); 108 * ``` 109 */ 110export function groupTracksPerScheme( 111 tracks, 112 initial = {}, 113) { 114 /** @type {Record<string, Track[]>} */ 115 const acc = initial; 116 117 tracks.forEach((track) => { 118 const scheme = track.uri.substring(0, track.uri.indexOf(":")); 119 acc[scheme] ??= []; 120 acc[scheme].push(track); 121 }); 122 123 return acc; 124} 125 126/** 127 * @param {string[]} uris 128 * @returns {Record<string, string[]>} 129 * 130 * @example Groups URIs by scheme 131 * ```js 132 * import { groupUrisPerScheme } from "~/common/utils.js"; 133 * 134 * const groups = groupUrisPerScheme(["http://a.com/t.mp3", "s3://b/t.flac", "http://c.com/t.mp3"]); 135 * if (groups["http"].length !== 2) throw new Error("expected 2 http URIs"); 136 * if (groups["s3"].length !== 1) throw new Error("expected 1 s3 URI"); 137 * 138 * if (Object.keys(groupUrisPerScheme([])).length !== 0) throw new Error("expected empty object for empty input"); 139 * ``` 140 */ 141export function groupUrisPerScheme(uris) { 142 /** @type {Record<string, string[]>} */ 143 const acc = {}; 144 145 uris.forEach((uri) => { 146 const scheme = uri.substring(0, uri.indexOf(":")); 147 acc[scheme] ??= []; 148 acc[scheme].push(uri); 149 }); 150 151 return acc; 152} 153 154/** 155 * @param {unknown} test 156 * 157 * @example Returns true for primitives and false for objects/arrays 158 * ```js 159 * import { isPrimitive } from "~/common/utils.js"; 160 * 161 * if (!isPrimitive(42)) throw new Error("number should be primitive"); 162 * if (!isPrimitive("hello")) throw new Error("string should be primitive"); 163 * if (!isPrimitive(true)) throw new Error("boolean should be primitive"); 164 * if (!isPrimitive(null)) throw new Error("null should be primitive"); 165 * if (isPrimitive({ a: 1 })) throw new Error("object should not be primitive"); 166 * if (isPrimitive([1, 2])) throw new Error("array should not be primitive"); 167 * ``` 168 */ 169export function isPrimitive(test) { 170 return test !== Object(test); 171} 172 173/** 174 * @template T 175 * @param {any} a 176 * @returns {T} 177 */ 178export function jsonDecode(a) { 179 return JSON.parse(new TextDecoder().decode(a)); 180} 181 182/** 183 * @template T 184 * @param {T} a 185 * @returns Uint8Array 186 * 187 * @example jsonEncode returns a Uint8Array that round-trips through jsonDecode 188 * ```js 189 * import { jsonEncode, jsonDecode } from "~/common/utils.js"; 190 * 191 * const original = { a: 1, b: "hello", c: [1, 2, 3] }; 192 * if (!(jsonEncode(original) instanceof Uint8Array)) throw new Error("jsonEncode should return a Uint8Array"); 193 * 194 * const roundTripped = jsonDecode(jsonEncode(original)); 195 * if (JSON.stringify(roundTripped) !== JSON.stringify(original)) throw new Error("round-trip should preserve values"); 196 * ``` 197 */ 198export function jsonEncode(a) { 199 return new TextEncoder().encode(JSON.stringify(a)); 200} 201 202/** 203 * @template {Record<string, any>} T 204 * @param {T} rec 205 * 206 * @example Removes keys with undefined values without mutating the original 207 * ```js 208 * import { removeUndefinedValuesFromRecord } from "~/common/utils.js"; 209 * 210 * const result = removeUndefinedValuesFromRecord({ a: 1, b: undefined, c: "x" }); 211 * if ("b" in result) throw new Error("undefined key should be removed"); 212 * if (result["a"] !== 1 || result["c"] !== "x") throw new Error("defined keys should be preserved"); 213 * 214 * const original = { a: 1, b: undefined }; 215 * removeUndefinedValuesFromRecord(original); 216 * if (!("b" in original)) throw new Error("original should not be mutated"); 217 * 218 * const noUndef = removeUndefinedValuesFromRecord({ a: 1, b: 2 }); 219 * if (noUndef["a"] !== 1 || noUndef["b"] !== 2) throw new Error("record without undefined values should be unchanged"); 220 * ``` 221 */ 222export function removeUndefinedValuesFromRecord(rec) { 223 const recClone = { ...rec }; 224 225 Object.entries(recClone).forEach(([key, value]) => { 226 if (value === undefined) { 227 delete recClone[key]; 228 } 229 }); 230 231 return recClone; 232} 233 234/** 235 * @template {Record<string, any>} T 236 * @param {T} rec 237 * 238 * @example Deep-clones nested records without sharing references 239 * ```js 240 * import { recursivelyCloneRecords } from "~/common/utils.js"; 241 * 242 * const original = { a: 1, b: "x" }; 243 * const clone = recursivelyCloneRecords(original); 244 * if (clone === original) throw new Error("clone should be a different object"); 245 * if (clone.a !== 1 || clone.b !== "x") throw new Error("values should be preserved"); 246 * 247 * const nested = { outer: { inner: 42 } }; 248 * const nestedClone = recursivelyCloneRecords(nested); 249 * if (nestedClone.outer === nested.outer) throw new Error("nested objects should not share references"); 250 * if (nestedClone.outer.inner !== 42) throw new Error("nested values should be preserved"); 251 * ``` 252 */ 253export function recursivelyCloneRecords(rec) { 254 const recClone = { ...rec }; 255 256 Object.entries(recClone).forEach(([key, value]) => { 257 if (typeof value === "object") { 258 /** @ts-ignore */ 259 recClone[key] = recursivelyCloneRecords(value); 260 } 261 }); 262 263 return recClone; 264} 265 266/** 267 * @param {string} str 268 * @returns {string} 269 * 270 * @example Decodes percent-encoded and %u unicode escapes, leaves plain strings unchanged 271 * ```js 272 * import { safeDecodeURIComponent } from "~/common/utils.js"; 273 * 274 * if (safeDecodeURIComponent("hello%20world") !== "hello world") throw new Error("should decode %20 as space"); 275 * if (safeDecodeURIComponent("%u0041") !== "A") throw new Error("should decode %u unicode escape"); 276 * if (safeDecodeURIComponent("plain-string") !== "plain-string") throw new Error("plain string should be unchanged"); 277 * if (safeDecodeURIComponent("hello%2Fworld") !== "hello/world") throw new Error("should decode %2F as slash"); 278 * ``` 279 */ 280export function safeDecodeURIComponent(str) { 281 return str.replace( 282 /%u([0-9A-Fa-f]{4})|%([0-9A-Fa-f]{2})/g, 283 (_, unicode, byte) => 284 unicode 285 ? String.fromCharCode(parseInt(unicode, 16)) 286 : String.fromCharCode(parseInt(byte, 16)), 287 ); 288}