A music player that connects to your cloud/distributed storage.
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}