A music player that connects to your cloud/distributed storage.
at v4 261 lines 6.4 kB view raw
1import { cachedConsult } from "~/components/input/common.js"; 2 3/** 4 * @import {Track} from "~/definitions/types.d.ts" 5 */ 6 7/** 8 * Group tracks by host. 9 * 10 * @param {Track[]} tracks 11 * @returns {Record<string, { host: string; tracks: Track[] }>} 12 * 13 * @example Group tracks by domain 14 * ```ts 15 * import { expect } from "@std/expect"; 16 * import { groupTracksByHost } from "./common.js"; 17 * import type { Track } from "~/definitions/types.d.ts"; 18 * 19 * const tracks: Track[] = [ 20 * { 21 * $type: "sh.diffuse.output.track", 22 * id: "1", 23 * uri: "https://example.com/a.mp3", 24 * }, 25 * { 26 * $type: "sh.diffuse.output.track", 27 * id: "2", 28 * uri: "https://cdn.example.com/b.mp3", 29 * }, 30 * { 31 * $type: "sh.diffuse.output.track", 32 * id: "3", 33 * uri: "https://example.com/c.mp3", 34 * }, 35 * ]; 36 * 37 * const groups = groupTracksByHost(tracks); 38 * expect(Object.keys(groups).length).toBe(2); 39 * expect(groups["example.com"].tracks.length).toBe(2); 40 * expect(groups["cdn.example.com"].tracks.length).toBe(1); 41 * ``` 42 * 43 * @example Group tracks by host including port 44 * ```ts 45 * import { expect } from "@std/expect"; 46 * import { groupTracksByHost } from "./common.js"; 47 * import type { Track } from "~/definitions/types.d.ts"; 48 * 49 * const tracks: Track[] = [ 50 * { 51 * $type: "sh.diffuse.output.track", 52 * id: "1", 53 * uri: "https://example.com/a.mp3", 54 * }, 55 * { 56 * $type: "sh.diffuse.output.track", 57 * id: "2", 58 * uri: "https://example.com:8443/b.mp3", 59 * }, 60 * ]; 61 * 62 * const groups = groupTracksByHost(tracks); 63 * expect(Object.keys(groups).length).toBe(2); 64 * expect(groups["example.com"].tracks.length).toBe(1); 65 * expect(groups["example.com:8443"].tracks.length).toBe(1); 66 * ``` 67 */ 68export function groupTracksByHost(tracks) { 69 /** @type {Record<string, { host: string; tracks: Track[] }>} */ 70 const acc = {}; 71 72 tracks.forEach((track) => { 73 const parsed = parseURI(track.uri); 74 if (!parsed) return; 75 76 const host = parsed.host; 77 78 if (acc[host]) { 79 acc[host].tracks.push(track); 80 } else { 81 acc[host] = { host, tracks: [track] }; 82 } 83 }); 84 85 return acc; 86} 87 88/** 89 * Group URIs by host. 90 * 91 * @param {string[]} uris 92 * @returns {Record<string, { host: string; uris: string[] }>} 93 */ 94export function groupUrisByHost(uris) { 95 /** @type {Record<string, { host: string; uris: string[] }>} */ 96 const acc = {}; 97 98 uris.forEach((uri) => { 99 const parsed = parseURI(uri); 100 if (!parsed) return; 101 102 const host = parsed.host; 103 104 if (acc[host]) { 105 acc[host].uris.push(uri); 106 } else { 107 acc[host] = { host, uris: [uri] }; 108 } 109 }); 110 111 return acc; 112} 113 114/** 115 * Extract unique hosts from tracks. 116 * 117 * @param {Track[]} tracks 118 * @returns {Record<string, string>} 119 * 120 * @example Extract unique hosts 121 * ```ts 122 * import { expect } from "@std/expect"; 123 * import { hostsFromTracks } from "./common.js"; 124 * import type { Track } from "~/definitions/types.d.ts"; 125 * 126 * const tracks: Track[] = [ 127 * { 128 * $type: "sh.diffuse.output.track", 129 * id: "1", 130 * uri: "https://example.com/a.mp3", 131 * }, 132 * { 133 * $type: "sh.diffuse.output.track", 134 * id: "2", 135 * uri: "https://example.com/b.mp3", 136 * }, 137 * { 138 * $type: "sh.diffuse.output.track", 139 * id: "3", 140 * uri: "https://cdn.example.com/c.mp3", 141 * }, 142 * ]; 143 * 144 * const hosts = hostsFromTracks(tracks); 145 * expect(Object.keys(hosts).length).toBe(2); 146 * expect(hosts["example.com"]).toBe("example.com"); 147 * expect(hosts["cdn.example.com"]).toBe("cdn.example.com"); 148 * ``` 149 */ 150export function hostsFromTracks(tracks) { 151 /** @type {Record<string, string>} */ 152 const acc = {}; 153 154 tracks.forEach((track) => { 155 const parsed = parseURI(track.uri); 156 if (!parsed) return; 157 158 const host = parsed.host; 159 if (acc[host]) return; 160 161 acc[host] = host; 162 }); 163 164 return acc; 165} 166 167/** 168 * Parse an HTTPS URI. 169 * Validates and extracts components from a standard HTTPS URL. 170 * 171 * @param {string} uriString 172 * @returns {{ url: string; domain: string; path: string; host: string } | undefined} 173 * 174 * @example Parse a valid HTTPS URI 175 * ```ts 176 * import { expect } from "@std/expect"; 177 * import { parseURI } from "./common.js"; 178 * 179 * const result = parseURI("https://example.com/song.mp3"); 180 * expect(result?.domain).toBe("example.com"); 181 * expect(result?.host).toBe("example.com"); 182 * expect(result?.path).toBe("/song.mp3"); 183 * expect(result?.url).toBe("https://example.com/song.mp3"); 184 * ``` 185 * 186 * @example Parse HTTPS URI with port 187 * ```ts 188 * import { expect } from "@std/expect"; 189 * import { parseURI } from "./common.js"; 190 * 191 * const result = parseURI("https://example.com:8443/audio.mp3"); 192 * expect(result?.domain).toBe("example.com"); 193 * expect(result?.host).toBe("example.com:8443"); 194 * expect(result?.path).toBe("/audio.mp3"); 195 * ``` 196 * 197 * @example Parse HTTPS URI with query parameters 198 * ```ts 199 * import { expect } from "@std/expect"; 200 * import { parseURI } from "./common.js"; 201 * 202 * const result = parseURI("https://example.com/song.mp3?token=abc123"); 203 * expect(result?.domain).toBe("example.com"); 204 * expect(result?.path).toBe("/song.mp3"); 205 * expect(result?.url).toContain("token=abc123"); 206 * ``` 207 * 208 * @example Reject non-HTTPS URI 209 * ```ts 210 * import { expect } from "@std/expect"; 211 * import { parseURI } from "./common.js"; 212 * 213 * const result = parseURI("http://example.com/song.mp3"); 214 * expect(result).toBeUndefined(); 215 * ``` 216 * 217 * @example Reject invalid URI 218 * ```ts 219 * import { expect } from "@std/expect"; 220 * import { parseURI } from "./common.js"; 221 * 222 * const result = parseURI("not-a-url"); 223 * expect(result).toBeUndefined(); 224 * ``` 225 */ 226export function parseURI(uriString) { 227 try { 228 const url = new URL(uriString); 229 if (url.protocol !== "https:") return undefined; 230 231 return { 232 url: url.href, 233 domain: url.hostname, 234 host: url.host, // includes port if present 235 path: url.pathname, 236 }; 237 } catch { 238 return undefined; 239 } 240} 241 242/** @param {string} uri */ 243async function consultHost(uri) { 244 try { 245 const controller = new AbortController(); 246 const timeoutId = setTimeout(() => controller.abort(), 5000); 247 const response = await fetch(uri, { 248 method: "HEAD", 249 signal: controller.signal, 250 }); 251 clearTimeout(timeoutId); 252 return response.ok; 253 } catch { 254 return false; 255 } 256} 257 258export const consultHostCached = cachedConsult( 259 consultHost, 260 (uri) => new URL(uri).host, 261);