forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
1/**
2 * @import {Track} from "~/definitions/types.d.ts"
3 */
4
5/**
6 * Creates a time-cached version of an async consult function.
7 * Results are cached per key for the given TTL.
8 *
9 * @template T
10 * @param {(arg: T) => Promise<boolean>} fn
11 * @param {(arg: T) => string} keyFn
12 * @param {number} ttl - Cache TTL in milliseconds
13 * @returns {(arg: T) => Promise<boolean>}
14 *
15 * @example Caches results and avoids calling fn more than once per key
16 * ```js
17 * import { cachedConsult } from "~/components/input/common.js";
18 *
19 * let callCount = 0;
20 * const cached = cachedConsult(async (uri) => { callCount++; return true; }, (uri) => uri);
21 *
22 * const r1 = await cached("https://example.com/stream");
23 * const r2 = await cached("https://example.com/stream");
24 *
25 * if (r1 !== true || r2 !== true) throw new Error("should return cached value");
26 * if (callCount !== 1) throw new Error("fn should only be called once per key");
27 * ```
28 */
29export function cachedConsult(fn, keyFn, ttl = 60_000 * 5) {
30 /** @type {Map<string, { value: boolean; expiry: number }>} */
31 const cache = new Map();
32
33 return async (arg) => {
34 const key = keyFn(arg);
35 const now = Date.now();
36 const cached = cache.get(key);
37
38 if (cached && cached.expiry > now) {
39 return cached.value;
40 }
41
42 const value = await fn(arg);
43 cache.set(key, { value, expiry: now + ttl });
44 return value;
45 };
46}
47
48/**
49 * @param {{ fileUriOrScheme: string; handleFileUri: (args: { fileURI: string; tracks: Track[] }) => Track[]; inputScheme: string; tracks: Track[] }} _
50 *
51 * @example Removes all tracks when given a matching scheme, returns all when scheme doesn't match
52 * ```js
53 * import { detach } from "~/components/input/common.js";
54 *
55 * const tracks = JSON.parse('[{"$type":"sh.diffuse.output.track","id":"1","uri":"https://a.com/1.mp3"},{"$type":"sh.diffuse.output.track","id":"2","uri":"https://b.com/2.mp3"}]');
56 *
57 * // @ts-ignore
58 * const removed = detach({ fileUriOrScheme: "https", inputScheme: "https", handleFileUri: () => [], tracks });
59 * if (removed.length !== 0) throw new Error("matching scheme should remove all tracks");
60 *
61 * // @ts-ignore
62 * const kept = detach({ fileUriOrScheme: "ftp", inputScheme: "https", handleFileUri: () => [], tracks });
63 * if (kept.length !== 2) throw new Error("non-matching scheme should keep all tracks");
64 * ```
65 *
66 * @example Delegates to handleFileUri when a full URI is given
67 * ```js
68 * import { detach } from "~/components/input/common.js";
69 *
70 * const tracks = JSON.parse('[{"$type":"sh.diffuse.output.track","id":"1","uri":"https://a.com/1.mp3"},{"$type":"sh.diffuse.output.track","id":"2","uri":"https://b.com/2.mp3"}]');
71 *
72 * // @ts-ignore
73 * const result = detach({ fileUriOrScheme: "https://a.com/1.mp3", inputScheme: "https", handleFileUri: ({ tracks }) => tracks.filter((t) => t.id !== "1"), tracks });
74 * if (result.length !== 1 || result[0].id !== "2") throw new Error("handleFileUri should filter by URI");
75 * ```
76 */
77export function detach(
78 { fileUriOrScheme, handleFileUri, inputScheme, tracks },
79) {
80 if (!fileUriOrScheme.includes("://")) {
81 // Delete everything if scheme matches
82 if (fileUriOrScheme === inputScheme) return [];
83 return tracks;
84 }
85
86 return handleFileUri({ fileURI: fileUriOrScheme, tracks });
87}
88
89/**
90 * @param {string} scheme
91 * @param {string} groupId
92 *
93 * @example Returns scheme://groupId
94 * ```js
95 * import { groupKey } from "~/components/input/common.js";
96 *
97 * if (groupKey("https", "example.com") !== "https://example.com") throw new Error(`expected "https://example.com"`);
98 * ```
99 */
100export function groupKey(scheme, groupId) {
101 return `${scheme}://${groupId}`;
102}
103
104/**
105 * @param {string} filename
106 *
107 * @example Returns truthy for audio extensions and falsy for non-audio ones
108 * ```js
109 * import { isAudioFile } from "~/components/input/common.js";
110 *
111 * const audioExts = ["track.mp3", "track.flac", "track.ogg", "track.opus", "track.wav", "track.m4a", "track.webm"];
112 * for (const f of audioExts) {
113 * if (!isAudioFile(f)) throw new Error(`${f} should be recognised as audio`);
114 * }
115 *
116 * const nonAudio = ["track.txt", "track.jpg", "track.pdf", "track"];
117 * for (const f of nonAudio) {
118 * if (isAudioFile(f)) throw new Error(`${f} should not be recognised as audio`);
119 * }
120 * ```
121 */
122export function isAudioFile(filename) {
123 return filename.match(/\.(flac|m4a|mp3|mp4|ogg|opus|wav|webm)$/);
124}