Experiment to rebuild Diffuse using web applets.
0
fork

Configure Feed

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

feat: opensubsonic input

+578 -42
+1
deno.lock
··· 47 47 "npm:query-string@^9.1.2", 48 48 "npm:sass@^1.87.0", 49 49 "npm:spellcaster@6", 50 + "npm:subsonic-api@^3.1.2", 50 51 "npm:throttle-debounce@^5.0.2", 51 52 "npm:uint8arrays@^5.1.0", 52 53 "npm:uri-js@^4.4.1",
+13 -2
package-lock.json
··· 24 24 "native-file-system-adapter": "^3.0.1", 25 25 "query-string": "^9.1.2", 26 26 "spellcaster": "^6.0.0", 27 + "subsonic-api": "^3.1.2", 27 28 "throttle-debounce": "^5.0.2", 28 29 "uint8arrays": "^5.1.0", 29 30 "uri-js": "^4.4.1", ··· 21720 21721 "url": "https://github.com/sponsors/Borewit" 21721 21722 } 21722 21723 }, 21724 + "node_modules/subsonic-api": { 21725 + "version": "3.1.2", 21726 + "resolved": "https://registry.npmjs.org/subsonic-api/-/subsonic-api-3.1.2.tgz", 21727 + "integrity": "sha512-EPuqd+z/6v/AbZhd25/5AN+QWsdFQ9K1SHd3N9PIN7Jheo9+L2bsmrbpjJ7D/AgnrmiSmlwhdfnkiaC83hVsfQ==", 21728 + "license": "MIT", 21729 + "dependencies": { 21730 + "typescript": "^5.7.3" 21731 + }, 21732 + "engines": { 21733 + "node": ">=18" 21734 + } 21735 + }, 21723 21736 "node_modules/then-read-stream": { 21724 21737 "version": "1.5.1", 21725 21738 "resolved": "https://registry.npmjs.org/then-read-stream/-/then-read-stream-1.5.1.tgz", ··· 21899 21912 "version": "5.8.3", 21900 21913 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", 21901 21914 "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", 21902 - "dev": true, 21903 21915 "license": "Apache-2.0", 21904 - "peer": true, 21905 21916 "bin": { 21906 21917 "tsc": "bin/tsc", 21907 21918 "tsserver": "bin/tsserver"
+1
package.json
··· 19 19 "native-file-system-adapter": "^3.0.1", 20 20 "query-string": "^9.1.2", 21 21 "spellcaster": "^6.0.0", 22 + "subsonic-api": "^3.1.2", 22 23 "throttle-debounce": "^5.0.2", 23 24 "uint8arrays": "^5.1.0", 24 25 "uri-js": "^4.4.1",
+56 -13
src/pages/configurator/input/_applet.astro
··· 12 12 <strong>My device</strong> 13 13 </a> 14 14 <br /> 15 + <a href="../../input/opensubsonic/" class="with-icon"> 16 + <i class="iconoir-open-in-window"></i> 17 + <strong>Opensubsonic server</strong> 18 + </a> 19 + <br /> 15 20 <a href="../../input/s3/" class="with-icon"> 16 21 <i class="iconoir-open-in-window"></i> 17 22 <strong>S3-compatible service</strong> ··· 41 46 // Applet connections 42 47 const input = { 43 48 nativeFs: applet("../../input/native-fs"), 49 + opensubsonic: applet("../../input/opensubsonic"), 44 50 s3: applet("../../input/s3"), 45 51 }; 46 52 ··· 48 54 // ACTIONS 49 55 //////////////////////////////////////////// 50 56 const contextualize = async (tracks: Track[]) => { 57 + const opensubsonic = await input.opensubsonic; 51 58 const s3 = await input.s3; 52 - await s3.sendAction("contextualize", tracks, { timeoutDuration: 60000 * 5 }); 59 + 60 + const groups = await groupTracksPerScheme(tracks); 61 + 62 + await Promise.all([ 63 + opensubsonic.sendAction( 64 + "contextualize", 65 + groups[opensubsonic.manifest.input_properties.scheme], 66 + { timeoutDuration: 60000 * 5 }, 67 + ), 68 + s3.sendAction("contextualize", groups[s3.manifest.input_properties.scheme], { 69 + timeoutDuration: 60000 * 5, 70 + }), 71 + ]); 53 72 }; 54 73 55 74 const list = async (cachedTracks: Track[] = []) => { 56 - const [nativeFs, s3] = [await input.nativeFs, await input.s3]; 75 + const [nativeFs, opensubsonic, s3] = [ 76 + await input.nativeFs, 77 + await input.opensubsonic, 78 + await input.s3, 79 + ]; 57 80 58 - const groups = cachedTracks.reduce( 59 - (acc: Record<string, Track[]>, track: Track) => { 60 - const scheme = track.uri.split(":", 1)[0]; 61 - return { ...acc, [scheme]: [...(acc[scheme] || []), track] }; 62 - }, 63 - { 64 - [nativeFs.manifest.input_properties.scheme]: [], 65 - [s3.manifest.input_properties.scheme]: [], 66 - }, 67 - ); 81 + const groups = await groupTracksPerScheme(cachedTracks); 68 82 69 83 const promises = Object.entries(groups).map( 70 84 async ([scheme, cachedTracksGroup]: [string, Track[]]) => { 71 85 switch (scheme) { 72 86 case nativeFs.manifest.input_properties.scheme: 73 87 return await nativeFs.sendAction("list", cachedTracksGroup, { 88 + timeoutDuration: 60000 * 60 * 24, 89 + }); 90 + 91 + case opensubsonic.manifest.input_properties.scheme: 92 + return await opensubsonic.sendAction("list", cachedTracksGroup, { 74 93 timeoutDuration: 60000 * 60 * 24, 75 94 }); 76 95 ··· 92 111 }; 93 112 94 113 const resolve = async (args: { method: string; uri: string }) => { 95 - const [nativeFs, s3] = [await input.nativeFs, await input.s3]; 96 114 const scheme = args.uri.split(":", 1)[0]; 115 + const [nativeFs, opensubsonic, s3] = [ 116 + await input.nativeFs, 117 + await input.opensubsonic, 118 + await input.s3, 119 + ]; 97 120 98 121 switch (scheme) { 99 122 case nativeFs.manifest.input_properties.scheme: 100 123 return await nativeFs.sendAction("resolve", args); 124 + 125 + case opensubsonic.manifest.input_properties.scheme: 126 + return await opensubsonic.sendAction("resolve", args); 101 127 102 128 case s3.manifest.input_properties.scheme: 103 129 return await s3.sendAction("resolve", args); ··· 110 136 context.setActionHandler("contextualize", contextualize); 111 137 context.setActionHandler("list", list); 112 138 context.setActionHandler("resolve", resolve); 139 + 140 + //////////////////////////////////////////// 141 + // 🛠️ 142 + //////////////////////////////////////////// 143 + async function groupTracksPerScheme(tracks: Track[]) { 144 + return tracks.reduce( 145 + (acc: Record<string, Track[]>, track: Track) => { 146 + const scheme = track.uri.split(":", 1)[0]; 147 + return { ...acc, [scheme]: [...(acc[scheme] || []), track] }; 148 + }, 149 + { 150 + [(await input.nativeFs).manifest.input_properties.scheme]: [], 151 + [(await input.opensubsonic).manifest.input_properties.scheme]: [], 152 + [(await input.s3).manifest.input_properties.scheme]: [], 153 + }, 154 + ); 155 + } 113 156 </script>
+4 -4
src/pages/configurator/output/_applet.astro
··· 145 145 default: 146 146 const conn = await connection(method); 147 147 try { 148 - await conn.sendAction("mount"); 148 + await conn.sendAction("mount", undefined, { timeoutDuration: 60000 }); 149 149 setActive(method); 150 150 } catch (err) { 151 151 const msg: string = ··· 159 159 async function unmountStorageMethod(method: Method) { 160 160 const conn = await connection(method); 161 161 conn.removeEventListener("data", dateEventHandler); 162 - await conn.sendAction("unmount"); 162 + await conn.sendAction("unmount", undefined, { timeoutDuration: 60000 }); 163 163 } 164 164 165 165 //////////////////////////////////////////// ··· 167 167 //////////////////////////////////////////// 168 168 const tracks = async (...args: unknown[]) => { 169 169 const conn = await connection(active()); 170 - await conn.sendAction("tracks", ...args); 170 + await conn.sendAction("tracks", ...args, { timeoutDuration: 60000 * 5 }); 171 171 }; 172 172 173 173 context.setActionHandler("tracks", tracks); ··· 355 355 } 356 356 357 357 localStorage.setItem(CUSTOM_KEY, url); 358 - await apl.sendAction("mount"); 358 + await apl.sendAction("mount", undefined, { timeoutDuration: 60000 }); 359 359 360 360 setActive("custom"); 361 361 setModalIsOpen(false);
+1
src/pages/core/types.d.ts
··· 31 31 } 32 32 33 33 export interface TrackStats { 34 + bitrate?: number; 34 35 duration?: number; 35 36 } 36 37
+5
src/pages/index.astro
··· 88 88 >abstractions</a 89 89 > for non-browser systems. 90 90 </p> 91 + <p> 92 + <strong 93 + >TODO: Figure out how to present this to users who just want to use the damn thing.</strong 94 + > 95 + </p> 91 96 </header> 92 97 <main> 93 98 <div class="columns">
+382
src/pages/input/opensubsonic/_applet.astro
··· 1 + <main class="container"> 2 + <h1>OpenSubsonic input</h1> 3 + 4 + <h4>Mounted servers</h4> 5 + 6 + <div id="servers"> 7 + <p> 8 + <span class="with-icon"> 9 + <i class="iconoir-bonfire"></i> 10 + <small>Just a moment, loading mounted servers.</small> 11 + </span> 12 + </p> 13 + </div> 14 + 15 + <h4>Add a new OpenSubsonic-compatible server</h4> 16 + 17 + <form id="form"></form> 18 + </main> 19 + 20 + <style is:global> 21 + iframe { 22 + display: none; 23 + } 24 + </style> 25 + 26 + <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"; 33 + 34 + 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 + }; 46 + 47 + //////////////////////////////////////////// 48 + // SETUP 49 + //////////////////////////////////////////// 50 + const IDB_PREFIX = "@applets/input/opensubsonic"; 51 + const IDB_SERVERS = `${IDB_PREFIX}/servers`; 52 + const SCHEME = manifest.input_properties.scheme; 53 + 54 + // Register applet 55 + const context = register(); 56 + 57 + //////////////////////////////////////////// 58 + // ACTIONS 59 + //////////////////////////////////////////// 60 + const consult = async (fileUriOrScheme: string) => { 61 + // TODO: Check if server is available + CORS works? 62 + return { supported: true }; 63 + }; 64 + 65 + const contextualize = async (tracks: Track[]) => { 66 + const s = serversFromTracks(tracks); 67 + setServers({ ...servers(), ...s }); 68 + }; 69 + 70 + 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); 130 + }; 131 + 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 }; 161 + }; 162 + 163 + const mount = async () => {}; 164 + 165 + const unmount = async () => {}; 166 + 167 + context.setActionHandler("consult", consult); 168 + context.setActionHandler("contextualize", contextualize); 169 + context.setActionHandler("list", list); 170 + context.setActionHandler("resolve", resolve); 171 + context.setActionHandler("mount", mount); 172 + context.setActionHandler("unmount", unmount); 173 + 174 + //////////////////////////////////////////// 175 + // UI 176 + //////////////////////////////////////////// 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 + } 382 + </script>
+63
src/pages/input/opensubsonic/_manifest.json
··· 1 + { 2 + "name": "diffuse/input/opensubsonic", 3 + "title": "Diffuse Input | OpenSubsonic API", 4 + "entrypoint": "index.html", 5 + "input_properties": { 6 + "scheme": "opensubsonic" 7 + }, 8 + "actions": { 9 + "consult": { 10 + "title": "Consult", 11 + "params_schema": { 12 + "type": "string", 13 + "description": "The uri to check the availability of." 14 + } 15 + }, 16 + "contextualize": { 17 + "title": "Contextualize", 18 + "params_schema": { 19 + "type": "array", 20 + "description": "Array of tracks", 21 + "items": { "type": "object" } 22 + } 23 + }, 24 + "list": { 25 + "title": "List", 26 + "description": "List tracks.", 27 + "params_schema": { 28 + "type": "array", 29 + "description": "A list of (cached) tracks with an uri matching the scheme", 30 + "items": { 31 + "type": "object" 32 + } 33 + } 34 + }, 35 + "resolve": { 36 + "title": "Resolve", 37 + "description": "Potentially translates a track uri with a matching scheme into a URL pointing at the audio bytes or an audio stream. If it can be resolved that is, otherwise you'll get `undefined`. Use the `consult` action to get a more detailed answer.", 38 + "params_schema": { 39 + "type": "object", 40 + "properties": { 41 + "method": { 42 + "type": "string", 43 + "description": "The HTTP method that is going to be used on the resolved URI." 44 + }, 45 + "uri": { "type": "string", "description": "The URI to resolve." } 46 + }, 47 + "required": ["method", "uri"] 48 + } 49 + }, 50 + "mount": { 51 + "title": "Mount", 52 + "description": "Prepare for usage." 53 + }, 54 + "unmount": { 55 + "title": "Unmount", 56 + "description": "Callback after usage.", 57 + "params_schema": { 58 + "type": "string", 59 + "description": "The handle id to unmount" 60 + } 61 + } 62 + } 63 + }
+9
src/pages/input/opensubsonic/index.astro
··· 1 + --- 2 + import Layout from "@layouts/applet-pico-ui.astro"; 3 + import Applet from "./_applet.astro"; 4 + import { title } from "./_manifest.json"; 5 + --- 6 + 7 + <Layout title={title}> 8 + <Applet /> 9 + </Layout>
+2 -2
src/pages/input/s3/_applet.astro
··· 306 306 return URI.serialize({ 307 307 scheme: SCHEME, 308 308 userinfo: `${bucket.accessKey}:${bucket.secretKey}`, 309 - host: bucket.host, 309 + host: bucket.host.replace(/^https?:\/\//, ""), 310 310 path: path, 311 311 query: QS.stringify({ 312 312 bucketName: bucket.bucketName, ··· 319 319 function createClient(bucket: Bucket) { 320 320 return new S3Client({ 321 321 bucket: bucket.bucketName, 322 - endPoint: bucket.host.includes("://") ? bucket.host : `https://${bucket.host}`, 322 + endPoint: `http${bucket.host.startsWith("localhost") ? "" : "s"}://${bucket.host}`, 323 323 region: bucket.region, 324 324 pathStyle: false, 325 325 accessKey: bucket.accessKey,
+1 -1
src/pages/orchestrator/input-cache/_applet.astro
··· 52 52 }); 53 53 54 54 // Process 55 - let changed = false; 55 + let changed = true; // TODO 56 56 57 57 const tracksWithMetadata = await tracks.reduce( 58 58 async (promise: Promise<Track[]>, track: Track) => {
+6 -2
src/pages/processor/metadata/_applet.astro
··· 67 67 const stream = resp.body; 68 68 69 69 if (!stream) return {}; 70 - meta = await parseWebStream(stream, { mimeType: mimeType || mimeFallback }); 70 + meta = await parseWebStream( 71 + stream, 72 + { mimeType: mimeType || mimeFallback }, 73 + { skipCovers: !includeArtwork }, 74 + ); 71 75 } else if (urls) { 72 76 const httpClient = new HTTP_TOKENIZER.HttpClient(urls.head, { resolveUrl: false }); 73 77 httpClient.resolvedUrl = urls.get; ··· 76 80 77 81 meta = await parseFromTokenizer(tokenizer, { skipCovers: !includeArtwork }); 78 82 } else if (stream) { 79 - meta = await parseWebStream(stream, { mimeType }); 83 + meta = await parseWebStream(stream, { mimeType }, { skipCovers: !includeArtwork }); 80 84 } else { 81 85 throw new Error("Missing args, need either some urls or a stream."); 82 86 }
+34 -18
src/scripts/applet/common.ts
··· 3 3 import * as Uint8 from "uint8arrays"; 4 4 import { applets } from "@web-applets/sdk"; 5 5 import { type ElementConfigurator, h } from "spellcaster/hyperscript.js"; 6 - import { effect, isSignal, type Signal, signal, throttled } from "spellcaster/spellcaster.js"; 6 + import { effect, isSignal, type Signal, signal } from "spellcaster/spellcaster.js"; 7 7 import { xxh32 } from "xxh32"; 8 8 import QS from "query-string"; 9 9 ··· 49 49 frame = existingFrame; 50 50 } else { 51 51 frame = document.createElement("iframe"); 52 + frame.loading = "eager"; 52 53 frame.src = src; 53 54 if (opts.frameId) frame.id = opts.frameId; 54 55 ··· 170 171 }); 171 172 172 173 // Promise that fullfills whenever it figures out its the main instance or not. 173 - const promise = new Promise<void>((resolve) => { 174 - const timeoutId = setTimeout(() => { 175 - channel.removeEventListener("message", handler); 176 - resolve(undefined); 177 - }, 1000); 178 - 179 - const handler = (event: MessageEvent) => { 180 - if (event.data === "pong" || event.data === "ping") { 181 - clearTimeout(timeoutId); 174 + function makeMainPromise() { 175 + return new Promise<{ isMain: boolean }>((resolve) => { 176 + const timeoutId = setTimeout(() => { 182 177 channel.removeEventListener("message", handler); 183 - resolve(undefined); 184 - } 185 - }; 178 + resolve({ isMain: true }); 179 + }, 1000); 186 180 187 - channel.addEventListener("message", handler); 188 - }); 181 + const handler = (event: MessageEvent) => { 182 + if (event.data === "pong" || event.data === "ping") { 183 + clearTimeout(timeoutId); 184 + channel.removeEventListener("message", handler); 185 + resolve({ isMain: false }); 186 + } 187 + }; 188 + 189 + channel.addEventListener("message", handler); 190 + }); 191 + } 192 + 193 + const promise = makeMainPromise(); 189 194 190 195 // Send out ping 191 196 channel.postMessage({ ··· 212 217 scope, 213 218 214 219 settled() { 215 - return promise; 220 + return promise.then(() => {}); 216 221 }, 217 222 218 223 get instanceId() { ··· 237 242 }, 238 243 239 244 setActionHandler: <H extends Function>(actionId: string, actionHandler: H) => { 240 - const handler = (...args: any) => { 245 + const handler = async (...args: any) => { 246 + if (isMainInstance) { 247 + return actionHandler(...args); 248 + } 249 + 250 + // Check if a main instance is still available, 251 + // if not, then this is the new main. 252 + const { isMain } = await makeMainPromise(); 253 + isMainInstance = isMain; 254 + 241 255 if (isMainInstance) { 242 256 return actionHandler(...args); 243 257 } ··· 249 263 arguments: args, 250 264 }; 251 265 252 - return new Promise((resolve) => { 266 + console.log("📣", actionMessage); 267 + 268 + return await new Promise((resolve) => { 253 269 const actionCallback = (event: MessageEvent) => { 254 270 if ( 255 271 event.data?.type === "actioncomplete" &&