Experiment to rebuild Diffuse using web applets.
0
fork

Configure Feed

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

refactor: put opensubsonic actions into a worker and only render dynamic ui when needed

+385 -324
+1
deno.lock
··· 30 30 "npm:@orama/plugin-qps@^3.1.7", 31 31 "npm:@phosphor-icons/web@^2.1.2", 32 32 "npm:@picocss/pico@^2.1.1", 33 + "npm:@remote-ui/rpc@^1.4.7", 33 34 "npm:@tokenizer/http@~0.9.2", 34 35 "npm:@tokenizer/range@0.13", 35 36 "npm:@types/throttle-debounce@^5.0.2",
+7
package-lock.json
··· 12 12 "@orama/plugin-qps": "^3.1.7", 13 13 "@phosphor-icons/web": "^2.1.2", 14 14 "@picocss/pico": "^2.1.1", 15 + "@remote-ui/rpc": "^1.4.7", 15 16 "@std/media-types": "npm:@jsr/std__media-types@^1.1.0", 16 17 "@tokenizer/http": "^0.9.2", 17 18 "@tokenizer/range": "^0.13.0", ··· 1452 1453 "version": "2.1.1", 1453 1454 "resolved": "https://registry.npmjs.org/@picocss/pico/-/pico-2.1.1.tgz", 1454 1455 "integrity": "sha512-kIDugA7Ps4U+2BHxiNHmvgPIQDWPDU4IeU6TNRdvXQM1uZX+FibqDQT2xUOnnO2yq/LUHcwnGlu1hvf4KfXnMg==", 1456 + "license": "MIT" 1457 + }, 1458 + "node_modules/@remote-ui/rpc": { 1459 + "version": "1.4.7", 1460 + "resolved": "https://registry.npmjs.org/@remote-ui/rpc/-/rpc-1.4.7.tgz", 1461 + "integrity": "sha512-ORiaKsbVBSEi3Z4YWOj5Ucrp70NrkNktI1hdqqfBW7Z3o0YoxTX9MIqtLmsc6721IbjmExvLrLip5I5Y7uAbng==", 1455 1462 "license": "MIT" 1456 1463 }, 1457 1464 "node_modules/@rollup/pluginutils": {
+1
package.json
··· 7 7 "@orama/plugin-qps": "^3.1.7", 8 8 "@phosphor-icons/web": "^2.1.2", 9 9 "@picocss/pico": "^2.1.1", 10 + "@remote-ui/rpc": "^1.4.7", 10 11 "@std/media-types": "npm:@jsr/std__media-types@^1.1.0", 11 12 "@tokenizer/http": "^0.9.2", 12 13 "@tokenizer/range": "^0.13.0",
+1
src/pages/index.astro
··· 42 42 43 43 const input = [ 44 44 { url: "input/native-fs/", title: "Native File System" }, 45 + { url: "input/opensubsonic/", title: "Opensubsonic" }, 45 46 { url: "input/s3/", title: "S3-Compatible API" }, 46 47 ]; 47 48
+13 -323
src/pages/input/opensubsonic/_applet.astro
··· 17 17 <form id="form"></form> 18 18 </main> 19 19 20 - <style is:global> 21 - iframe { 22 - display: none; 23 - } 24 - </style> 25 - 26 20 <script> 27 - import { SubsonicAPI, type Child } from "subsonic-api"; 28 - import { computed, effect, type Signal, signal } from "spellcaster"; 29 - import { type Props, repeat, tags, text } from "spellcaster/hyperscript.js"; 30 - import * as IDB from "idb-keyval"; 31 - import * as URI from "uri-js"; 32 - import QS from "query-string"; 21 + import { createEndpoint } from "@remote-ui/rpc"; 33 22 23 + import type { Actions as AppletWorkerActions } from "./worker"; 34 24 import type { Track } from "@applets/core/types.d.ts"; 35 - import { register } from "@scripts/applet/common"; 36 - import manifest from "./_manifest.json"; 37 - 38 - // https://opensubsonic.netlify.app/docs/api-reference/ 39 - type Server = { 40 - apiKey?: string; 41 - host: string; 42 - password?: string; 43 - tls: boolean; 44 - username?: string; 45 - }; 25 + import { inIframe, register } from "@scripts/applet/common"; 26 + import AppletWorker from "./worker?worker"; 46 27 47 28 //////////////////////////////////////////// 48 29 // SETUP 49 30 //////////////////////////////////////////// 50 - const IDB_PREFIX = "@applets/input/opensubsonic"; 51 - const IDB_SERVERS = `${IDB_PREFIX}/servers`; 52 - const SCHEME = manifest.input_properties.scheme; 31 + const worker = createEndpoint<AppletWorkerActions>(new AppletWorker()); 53 32 54 33 // Register applet 55 34 const context = register(); ··· 58 37 // ACTIONS 59 38 //////////////////////////////////////////// 60 39 const consult = async (fileUriOrScheme: string) => { 61 - // TODO: Check if server is available + CORS works? 62 - return { supported: true }; 40 + return await worker.call.consult(fileUriOrScheme); 63 41 }; 64 42 65 43 const contextualize = async (tracks: Track[]) => { 66 - const s = serversFromTracks(tracks); 67 - setServers({ ...servers(), ...s }); 44 + const s = await worker.call.contextualize(tracks); 45 + ui?.setServers({ ...ui?.servers(), ...s }); 68 46 }; 69 47 70 48 const list = async (cachedTracks: Track[] = []) => { 71 - const cache = cachedTracks.reduce((acc: Record<string, Track>, t: Track) => { 72 - const uri = URI.parse(t.uri); 73 - if (!uri.path) return acc; 74 - return { ...acc, [URI.unescapeComponent(uri.path)]: t }; 75 - }, {}); 76 - 77 - async function search(client: SubsonicAPI, offset = 0): Promise<Child[]> { 78 - const result = await client.search3({ 79 - query: "", 80 - artistCount: 0, 81 - albumCount: 0, 82 - songCount: 1000, 83 - songOffset: offset, 84 - }); 85 - 86 - const songs = result.searchResult3.song || []; 87 - 88 - if (songs.length === 1000) { 89 - const moreSongs = await search(client, offset + 1000); 90 - return [...songs, ...moreSongs]; 91 - } 92 - 93 - return songs; 94 - } 95 - 96 - const promises = Object.values(servers()).map(async (server) => { 97 - const client = createClient(server); 98 - const list = await search(client, 0); 99 - 100 - return list 101 - .filter((song) => !song.isVideo) 102 - .map((song) => { 103 - if (song.path && cache[song.path]) return cache[song.path]; 104 - 105 - const track: Track = { 106 - id: crypto.randomUUID(), 107 - kind: autoTypeToTrackKind(song.type), 108 - uri: buildURI(server, { songId: song.id, path: song.path }), 109 - 110 - stats: { 111 - bitrate: song.bitRate, 112 - duration: song.duration, 113 - }, 114 - tags: { 115 - album: song.album, 116 - artist: song.artist, 117 - disc: { no: song.discNumber || 1 }, 118 - genre: song.genre, 119 - title: song.title, 120 - track: { no: song.track || 1 }, 121 - year: song.year, 122 - }, 123 - }; 124 - 125 - return track; 126 - }); 127 - }); 128 - 129 - return (await Promise.all(promises)).flat(1); 49 + return await worker.call.list(cachedTracks); 130 50 }; 131 51 132 - const resolve = async ({ uri }: { method: string; uri: string }) => { 133 - const server = parseURI(uri); 134 - if (!server) return undefined; 135 - 136 - const client = createClient(server); 137 - const parsedURI = URI.parse(uri); 138 - const qs = QS.parse(parsedURI.query || ""); 139 - 140 - const songId = typeof qs.songId === "string" ? qs.songId : undefined; 141 - if (!songId) return undefined; 142 - 143 - // TODO: 144 - // const expiresInSeconds = 60 * 60 * 24 * 7; // 7 days 145 - // const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; 146 - 147 - const url = await client 148 - .download({ 149 - id: songId, 150 - format: "raw", 151 - }) 152 - .then((a) => a.blob()) 153 - .then((blob) => URL.createObjectURL(blob)); 154 - 155 - // NOTE: 156 - // First idea was to get the URL for the download and use that instead. 157 - // Problem is, more often than not, servers don't allow for CORS Range requests, 158 - // so it's basically useless. 159 - 160 - return { expiresAt: Infinity, url }; 52 + const resolve = async (args: { method: string; uri: string }) => { 53 + return await worker.call.resolve(args); 161 54 }; 162 55 163 56 const mount = async () => {}; ··· 174 67 //////////////////////////////////////////// 175 68 // UI 176 69 //////////////////////////////////////////// 177 - const [servers, setServers] = signal<Record<string, Server>>(await loadServers()); 178 - const [form, setForm] = signal<{ 179 - api_key?: string; 180 - host?: string; 181 - password?: string; 182 - username?: string; 183 - }>({}); 184 - 185 - const serversMap = computed(() => { 186 - return new Map(Object.entries(servers())); 187 - }); 188 - 189 - effect(() => { 190 - saveServers(servers()); 191 - }); 192 - 193 - //////////////////////////////////////////// 194 - // UI ~ SERVERS 195 - //////////////////////////////////////////// 196 - const Server = (server: Signal<Server>) => { 197 - const onclick = () => { 198 - const b = server(); 199 - const id = serverId(b); 200 - 201 - const col = { ...servers() }; 202 - delete col[id]; 203 - 204 - setServers(col); 205 - }; 206 - 207 - return tags.li({ onclick, style: "cursor: pointer" }, text(server().host)); 208 - }; 209 - 210 - const ServerList = computed(() => { 211 - if (serversMap().size === 0) { 212 - return tags.p({ id: "servers" }, [tags.small({}, text("Nothing added so far."))]); 213 - } 214 - 215 - return tags.ul({ id: "servers" }, repeat(serversMap, Server)); 216 - }); 217 - 218 - effect(() => { 219 - document.querySelector("#servers")?.replaceWith(ServerList()); 220 - }); 221 - 222 - //////////////////////////////////////////// 223 - // UI ~ FORM 224 - //////////////////////////////////////////// 225 - function addServer(event: Event) { 226 - event.preventDefault(); 227 - 228 - const f = form(); 229 - 230 - const server: Server = { 231 - apiKey: f.api_key, 232 - host: f.host?.replace(/^https?:\/\//, "").replace(/\/+$/, "") || "localhost:4533", 233 - username: f.username, 234 - tls: f.host?.startsWith("http://") || f.host?.startsWith("localhost") ? false : true, 235 - password: f.password, 236 - }; 237 - 238 - setServers({ 239 - ...servers(), 240 - [serverId(server)]: server, 241 - }); 242 - } 243 - 244 - function Form() { 245 - return tags.form({ onsubmit: addServer }, [ 246 - tags.fieldset({ className: "grid" }, [ 247 - Input("host", "Server host", "my.opensubsonic.server:4747", { required: true }), 248 - ]), 249 - tags.fieldset({ className: "grid" }, [ 250 - Input("username", "Server name", "username", { required: true }), 251 - Input("password", "Password", "password", { required: true, type: "password" }), 252 - ]), 253 - tags.fieldset({ className: "grid" }, [tags.input({ type: "submit", value: "Connect" }, [])]), 254 - ]); 255 - } 256 - 257 - function Input(name: string, label: string, placeholder: string, opts: Props = {}) { 258 - return tags.label({}, [ 259 - tags.span({}, [ 260 - tags.span({}, text(label)), 261 - tags.small({}, text("required" in opts ? "" : " (optional)")), 262 - ]), 263 - tags.input({ 264 - ...opts, 265 - name, 266 - placeholder, 267 - oninput: (event: InputEvent) => formInput(name, (event.target as HTMLInputElement).value), 268 - }), 269 - ]); 270 - } 271 - 272 - function formInput(name: string, value: string) { 273 - setForm({ ...form(), [name]: value }); 274 - } 275 - 276 - // 🚀 277 - document.querySelector("#form")?.replaceWith(Form()); 278 - 279 - //////////////////////////////////////////// 280 - // 🛠️ 281 - //////////////////////////////////////////// 282 - function autoTypeToTrackKind(type: Child["type"]): Track["kind"] { 283 - switch (type?.toLowerCase()) { 284 - case "audiobook": 285 - return "audiobook"; 286 - 287 - case "music": 288 - return "music"; 289 - 290 - case "podcast": 291 - return "podcast"; 292 - 293 - default: 294 - return "miscellaneous"; 295 - } 296 - } 297 - 298 - function buildURI(server: Server, args: { songId: string; path?: string }) { 299 - return URI.serialize({ 300 - scheme: SCHEME, 301 - userinfo: server.apiKey 302 - ? URI.escapeComponent(server.apiKey) 303 - : `${URI.escapeComponent(server.username || "")}:${URI.escapeComponent(server.password || "")}`, 304 - host: server.host.replace(/^https?:\/\//, ""), 305 - path: args.path, 306 - query: QS.stringify({ 307 - songId: args.songId, 308 - tls: server.tls ? "t" : "f", 309 - }), 310 - }); 311 - } 312 - 313 - function createClient(server: Server) { 314 - return new SubsonicAPI({ 315 - url: `http${server.tls ? "s" : ""}://${server.host}`, 316 - auth: server.apiKey 317 - ? { apiKey: URI.unescapeComponent(server.apiKey) } 318 - : { 319 - username: URI.unescapeComponent(server.username || ""), 320 - password: URI.unescapeComponent(server.password || ""), 321 - }, 322 - }); 323 - } 324 - 325 - async function loadServers() { 326 - const i = await IDB.get(IDB_SERVERS); 327 - return i ? i : {}; 328 - } 329 - 330 - function parseURI(uriString: string): Server | undefined { 331 - const uri = URI.parse(uriString); 332 - if (uri.scheme !== SCHEME) return undefined; 333 - if (!uri.host) return undefined; 334 - 335 - let apiKey: string | undefined = undefined; 336 - let username: string | undefined = undefined; 337 - let password: string | undefined = undefined; 338 - 339 - if (uri.userinfo?.includes(":")) { 340 - // Username + Password 341 - const [u, p] = uri.userinfo.split(":"); 342 - username = u; 343 - password = p; 344 - if (!username || !password) return undefined; 345 - } else { 346 - // API key 347 - apiKey = uri.userinfo; 348 - if (!apiKey) return undefined; 349 - } 350 - 351 - const qs = QS.parse(uri.query || ""); 352 - 353 - return { 354 - apiKey, 355 - host: uri.port ? `${uri.host}:${uri.port}` : uri.host, 356 - password, 357 - tls: qs.tls === "f" ? false : true, 358 - username, 359 - }; 360 - } 361 - 362 - async function saveServers(items: Record<string, Server>) { 363 - await IDB.set(IDB_SERVERS, items); 364 - } 365 - 366 - function serversFromTracks(tracks: Track[]) { 367 - return tracks.reduce((acc: Record<string, Server>, track: Track) => { 368 - const server = parseURI(track.uri); 369 - if (!server) return acc; 370 - 371 - const id = serverId(server); 372 - if (acc[id]) return acc; 373 - 374 - return { ...acc, [id]: server }; 375 - }, {}); 376 - } 377 - 378 - function serverId(server: Server) { 379 - if (server.apiKey) return `${server.apiKey}@${server.host}`; 380 - return `${server.username}:${server.password}@${server.host}`; 381 - } 70 + // Only load dynamic UI when not embedded 71 + const ui = inIframe() ? undefined : await import("./ui"); 382 72 </script>
+18
src/pages/input/opensubsonic/common.ts
··· 1 + import * as IDB from "idb-keyval"; 2 + 3 + import type { Server } from "./types"; 4 + import { IDB_SERVERS } from "./constants"; 5 + 6 + export async function loadServers(): Promise<Record<string, Server>> { 7 + const i = await IDB.get(IDB_SERVERS); 8 + return i ? i : {}; 9 + } 10 + 11 + export async function saveServers(items: Record<string, Server>) { 12 + await IDB.set(IDB_SERVERS, items); 13 + } 14 + 15 + export function serverId(server: Server) { 16 + if (server.apiKey) return `${server.apiKey}@${server.host}`; 17 + return `${server.username}:${server.password}@${server.host}`; 18 + }
+5
src/pages/input/opensubsonic/constants.ts
··· 1 + import manifest from "./_manifest.json"; 2 + 3 + export const IDB_PREFIX = "@applets/input/opensubsonic"; 4 + export const IDB_SERVERS = `${IDB_PREFIX}/servers`; 5 + export const SCHEME = manifest.input_properties.scheme;
+8
src/pages/input/opensubsonic/types.d.ts
··· 1 + // https://opensubsonic.netlify.app/docs/api-reference/ 2 + export type Server = { 3 + apiKey?: string; 4 + host: string; 5 + password?: string; 6 + tls: boolean; 7 + username?: string; 8 + };
+110
src/pages/input/opensubsonic/ui.ts
··· 1 + import { computed, effect, type Signal, signal } from "spellcaster"; 2 + import { type Props, repeat, tags, text } from "spellcaster/hyperscript.js"; 3 + 4 + import type { Server } from "./types.d.ts"; 5 + import { loadServers, saveServers, serverId } from "./common"; 6 + 7 + //////////////////////////////////////////// 8 + // UI 9 + //////////////////////////////////////////// 10 + export const [servers, setServers] = signal<Record<string, Server>>(await loadServers()); 11 + const [form, setForm] = signal<{ 12 + api_key?: string; 13 + host?: string; 14 + password?: string; 15 + username?: string; 16 + }>({}); 17 + 18 + const serversMap = computed(() => { 19 + return new Map(Object.entries(servers())); 20 + }); 21 + 22 + effect(() => { 23 + saveServers(servers()); 24 + }); 25 + 26 + //////////////////////////////////////////// 27 + // UI ~ SERVERS 28 + //////////////////////////////////////////// 29 + const Server = (server: Signal<Server>) => { 30 + const onclick = () => { 31 + const b = server(); 32 + const id = serverId(b); 33 + 34 + const col = { ...servers() }; 35 + delete col[id]; 36 + 37 + setServers(col); 38 + }; 39 + 40 + return tags.li({ onclick, style: "cursor: pointer" }, text(server().host)); 41 + }; 42 + 43 + const ServerList = computed(() => { 44 + if (serversMap().size === 0) { 45 + return tags.p({ id: "servers" }, [tags.small({}, text("Nothing added so far."))]); 46 + } 47 + 48 + return tags.ul({ id: "servers" }, repeat(serversMap, Server)); 49 + }); 50 + 51 + effect(() => { 52 + document.querySelector("#servers")?.replaceWith(ServerList()); 53 + }); 54 + 55 + //////////////////////////////////////////// 56 + // UI ~ FORM 57 + //////////////////////////////////////////// 58 + function addServer(event: Event) { 59 + event.preventDefault(); 60 + 61 + const f = form(); 62 + 63 + const server: Server = { 64 + apiKey: f.api_key, 65 + host: f.host?.replace(/^https?:\/\//, "").replace(/\/+$/, "") || "localhost:4533", 66 + username: f.username, 67 + tls: f.host?.startsWith("http://") || f.host?.startsWith("localhost") ? false : true, 68 + password: f.password, 69 + }; 70 + 71 + setServers({ 72 + ...servers(), 73 + [serverId(server)]: server, 74 + }); 75 + } 76 + 77 + function Form() { 78 + return tags.form({ onsubmit: addServer }, [ 79 + tags.fieldset({ className: "grid" }, [ 80 + Input("host", "Server host", "my.opensubsonic.server:4747", { required: true }), 81 + ]), 82 + tags.fieldset({ className: "grid" }, [ 83 + Input("username", "Server name", "username", { required: true }), 84 + Input("password", "Password", "password", { required: true, type: "password" }), 85 + ]), 86 + tags.fieldset({ className: "grid" }, [tags.input({ type: "submit", value: "Connect" }, [])]), 87 + ]); 88 + } 89 + 90 + function Input(name: string, label: string, placeholder: string, opts: Props = {}) { 91 + return tags.label({}, [ 92 + tags.span({}, [ 93 + tags.span({}, text(label)), 94 + tags.small({}, text("required" in opts ? "" : " (optional)")), 95 + ]), 96 + tags.input({ 97 + ...opts, 98 + name, 99 + placeholder, 100 + oninput: (event: InputEvent) => formInput(name, (event.target as HTMLInputElement).value), 101 + }), 102 + ]); 103 + } 104 + 105 + function formInput(name: string, value: string) { 106 + setForm({ ...form(), [name]: value }); 107 + } 108 + 109 + // 🚀 110 + document.querySelector("#form")?.replaceWith(Form());
+216
src/pages/input/opensubsonic/worker.ts
··· 1 + import { createEndpoint, type MessageEndpoint } from "@remote-ui/rpc"; 2 + import { SubsonicAPI, type Child } from "subsonic-api"; 3 + import * as URI from "uri-js"; 4 + import QS from "query-string"; 5 + 6 + import type { Track } from "@applets/core/types.d.ts"; 7 + import type { Server } from "./types.d.ts"; 8 + import { SCHEME } from "./constants.ts"; 9 + import { loadServers, serverId } from "./common.ts"; 10 + 11 + //////////////////////////////////////////// 12 + // ACTIONS 13 + //////////////////////////////////////////// 14 + const actions = createEndpoint<Actions>(self as MessageEndpoint); 15 + actions.expose({ consult, contextualize, list, resolve }); 16 + 17 + export type Actions = { 18 + consult: typeof consult; 19 + contextualize: typeof contextualize; 20 + list: typeof list; 21 + resolve: typeof resolve; 22 + }; 23 + 24 + async function consult(fileUriOrScheme: string) { 25 + // TODO: Check if server is available + CORS works? 26 + return { supported: true }; 27 + } 28 + 29 + async function contextualize(tracks: Track[]) { 30 + return serversFromTracks(tracks); 31 + } 32 + 33 + async function list(cachedTracks: Track[] = []) { 34 + const cache = cachedTracks.reduce((acc: Record<string, Track>, t: Track) => { 35 + const uri = URI.parse(t.uri); 36 + if (!uri.path) return acc; 37 + return { ...acc, [URI.unescapeComponent(uri.path)]: t }; 38 + }, {}); 39 + 40 + async function search(client: SubsonicAPI, offset = 0): Promise<Child[]> { 41 + const result = await client.search3({ 42 + query: "", 43 + artistCount: 0, 44 + albumCount: 0, 45 + songCount: 1000, 46 + songOffset: offset, 47 + }); 48 + 49 + const songs = result.searchResult3.song || []; 50 + 51 + if (songs.length === 1000) { 52 + const moreSongs = await search(client, offset + 1000); 53 + return [...songs, ...moreSongs]; 54 + } 55 + 56 + return songs; 57 + } 58 + 59 + const servers = await loadServers(); 60 + const promises = Object.values(servers).map(async (server) => { 61 + const client = createClient(server); 62 + const list = await search(client, 0); 63 + 64 + return list 65 + .filter((song) => !song.isVideo) 66 + .map((song) => { 67 + if (song.path && cache[song.path]) return cache[song.path]; 68 + 69 + const track: Track = { 70 + id: crypto.randomUUID(), 71 + kind: autoTypeToTrackKind(song.type), 72 + uri: buildURI(server, { songId: song.id, path: song.path }), 73 + 74 + stats: { 75 + bitrate: song.bitRate, 76 + duration: song.duration, 77 + }, 78 + tags: { 79 + album: song.album, 80 + artist: song.artist, 81 + disc: { no: song.discNumber || 1 }, 82 + genre: song.genre, 83 + title: song.title, 84 + track: { no: song.track || 1 }, 85 + year: song.year, 86 + }, 87 + }; 88 + 89 + return track; 90 + }); 91 + }); 92 + 93 + return (await Promise.all(promises)).flat(1); 94 + } 95 + 96 + async function resolve({ uri }: { method: string; uri: string }) { 97 + console.log("RESOLVE", uri); 98 + const server = parseURI(uri); 99 + if (!server) return undefined; 100 + 101 + const client = createClient(server); 102 + const parsedURI = URI.parse(uri); 103 + const qs = QS.parse(parsedURI.query || ""); 104 + 105 + const songId = typeof qs.songId === "string" ? qs.songId : undefined; 106 + if (!songId) return undefined; 107 + 108 + // TODO: 109 + // const expiresInSeconds = 60 * 60 * 24 * 7; // 7 days 110 + // const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; 111 + 112 + const url = await client 113 + .download({ 114 + id: songId, 115 + format: "raw", 116 + }) 117 + .then((a) => a.blob()) 118 + .then((blob) => URL.createObjectURL(blob)); 119 + 120 + // NOTE: 121 + // First idea was to get the URL for the download and use that instead. 122 + // Problem is, more often than not, servers don't allow for CORS Range requests, 123 + // so it's basically useless. 124 + 125 + return { expiresAt: Infinity, url }; 126 + } 127 + 128 + //////////////////////////////////////////// 129 + // 🛠️ 130 + //////////////////////////////////////////// 131 + function autoTypeToTrackKind(type: Child["type"]): Track["kind"] { 132 + switch (type?.toLowerCase()) { 133 + case "audiobook": 134 + return "audiobook"; 135 + 136 + case "music": 137 + return "music"; 138 + 139 + case "podcast": 140 + return "podcast"; 141 + 142 + default: 143 + return "miscellaneous"; 144 + } 145 + } 146 + 147 + function buildURI(server: Server, args: { songId: string; path?: string }) { 148 + return URI.serialize({ 149 + scheme: SCHEME, 150 + userinfo: server.apiKey 151 + ? URI.escapeComponent(server.apiKey) 152 + : `${URI.escapeComponent(server.username || "")}:${URI.escapeComponent(server.password || "")}`, 153 + host: server.host.replace(/^https?:\/\//, ""), 154 + path: args.path, 155 + query: QS.stringify({ 156 + songId: args.songId, 157 + tls: server.tls ? "t" : "f", 158 + }), 159 + }); 160 + } 161 + 162 + function createClient(server: Server) { 163 + return new SubsonicAPI({ 164 + url: `http${server.tls ? "s" : ""}://${server.host}`, 165 + auth: server.apiKey 166 + ? { apiKey: URI.unescapeComponent(server.apiKey) } 167 + : { 168 + username: URI.unescapeComponent(server.username || ""), 169 + password: URI.unescapeComponent(server.password || ""), 170 + }, 171 + }); 172 + } 173 + 174 + function parseURI(uriString: string): Server | undefined { 175 + const uri = URI.parse(uriString); 176 + if (uri.scheme !== SCHEME) return undefined; 177 + if (!uri.host) return undefined; 178 + 179 + let apiKey: string | undefined = undefined; 180 + let username: string | undefined = undefined; 181 + let password: string | undefined = undefined; 182 + 183 + if (uri.userinfo?.includes(":")) { 184 + // Username + Password 185 + const [u, p] = uri.userinfo.split(":"); 186 + username = u; 187 + password = p; 188 + if (!username || !password) return undefined; 189 + } else { 190 + // API key 191 + apiKey = uri.userinfo; 192 + if (!apiKey) return undefined; 193 + } 194 + 195 + const qs = QS.parse(uri.query || ""); 196 + 197 + return { 198 + apiKey, 199 + host: uri.port ? `${uri.host}:${uri.port}` : uri.host, 200 + password, 201 + tls: qs.tls === "f" ? false : true, 202 + username, 203 + }; 204 + } 205 + 206 + function serversFromTracks(tracks: Track[]) { 207 + return tracks.reduce((acc: Record<string, Server>, track: Track) => { 208 + const server = parseURI(track.uri); 209 + if (!server) return acc; 210 + 211 + const id = serverId(server); 212 + if (acc[id]) return acc; 213 + 214 + return { ...acc, [id]: server }; 215 + }, {}); 216 + }
+5 -1
src/scripts/applet/common.ts
··· 292 292 // 🔮 Reactive state management 293 293 //////////////////////////////////////////// 294 294 export function reactive<D, T>( 295 - applet: Applet<D>, 295 + applet: Applet<D> | AppletScope<D>, 296 296 dataFn: (data: D) => T, 297 297 effectFn: (t: T, setter: (t: T) => void) => void, 298 298 ) { ··· 383 383 384 384 export function comparable(value: unknown) { 385 385 return xxh32(JSON.stringify(value)); 386 + } 387 + 388 + export function inIframe() { 389 + return window.self !== window.top; 386 390 } 387 391 388 392 export function hs(