Experiment to rebuild Diffuse using web applets.

Compare changes

Choose any two refs to compare.

Changed files
+523 -59
src
pages
configurator
input
orchestrator
input-cache
output-management
single-queue
processor
metadata-fetcher
scripts
themes
webamp
+3 -2
deno.lock
··· 21 21 ], 22 22 "packageJson": { 23 23 "dependencies": [ 24 - "npm:98.css@~0.1.21", 25 - "npm:@atcute/cid@^2.2.2", 24 + "npm:@jsr/bradenmacdonald__s3-lite-client@0.9", 25 + "npm:@jsr/std__media-types@^1.1.0", 26 26 "npm:@picocss/pico@^2.1.1", 27 27 "npm:@types/throttle-debounce@^5.0.2", 28 28 "npm:astro-purgecss@^5.2.2", ··· 32 32 "npm:idb-keyval@^6.2.1", 33 33 "npm:music-metadata@^11.2.3", 34 34 "npm:native-file-system-adapter@^3.0.1", 35 + "npm:node-s3-url-encode@^0.0.4", 35 36 "npm:purgecss@^7.0.2", 36 37 "npm:query-string@^9.1.2", 37 38 "npm:sass@^1.87.0",
+14 -30
package-lock.json
··· 5 5 "packages": { 6 6 "": { 7 7 "dependencies": { 8 - "@atcute/cid": "^2.2.2", 9 8 "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@^0.9.0", 10 9 "@picocss/pico": "^2.1.1", 10 + "@std/media-types": "npm:@jsr/std__media-types@^1.1.0", 11 11 "@web-applets/sdk": "https://gitpkg.vercel.app/unternet-co/web-applets/sdk?tokono.ma/experiment&scripts.postinstall=npm%20i%20%40types%2Fnode%20%26%26%20npx%20tsc", 12 - "98.css": "^0.1.21", 13 12 "iconoir": "^7.11.0", 14 13 "idb-keyval": "^6.2.1", 15 14 "music-metadata": "^11.2.3", 16 15 "native-file-system-adapter": "^3.0.1", 16 + "node-s3-url-encode": "^0.0.4", 17 17 "query-string": "^9.1.2", 18 18 "spellcaster": "^6.0.0", 19 19 "throttle-debounce": "^5.0.2", ··· 107 107 "node": "^18.17.1 || ^20.3.0 || >=22.0.0" 108 108 } 109 109 }, 110 - "node_modules/@atcute/cid": { 111 - "version": "2.2.2", 112 - "resolved": "https://registry.npmjs.org/@atcute/cid/-/cid-2.2.2.tgz", 113 - "integrity": "sha512-deAGMqLAyplt7eIukhkjlsGubvrcMrtXkDKlUYZDo4WUdL7hSjBywtPXf6SbMK+Mjvst7l2+83OqTcY5AuuxtA==", 114 - "dependencies": { 115 - "@atcute/multibase": "^1.1.3", 116 - "@atcute/uint8array": "^1.0.1" 117 - } 118 - }, 119 - "node_modules/@atcute/multibase": { 120 - "version": "1.1.3", 121 - "resolved": "https://registry.npmjs.org/@atcute/multibase/-/multibase-1.1.3.tgz", 122 - "integrity": "sha512-vQQO0tDuQPguBvHdgV3ryn7R8U6beQ50KA/juYm+dCeT/3hOK2stMbX+IaW8JEuwkT5lJsU8wDIOicQT4mB7Ag==", 123 - "dependencies": { 124 - "@atcute/uint8array": "^1.0.1" 125 - } 126 - }, 127 - "node_modules/@atcute/uint8array": { 128 - "version": "1.0.1", 129 - "resolved": "https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.0.1.tgz", 130 - "integrity": "sha512-AAnlFKyfDRgb9GNZJbhQ6OuMhbmNPirQyapb8KnmcEhxQZ3+tt+4NcwqekEegY4MpNqSTYeeTdyxq0wGZv1JHg==" 131 - }, 132 110 "node_modules/@babel/helper-string-parser": { 133 111 "version": "7.25.9", 134 112 "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", ··· 1710 1688 "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", 1711 1689 "dev": true 1712 1690 }, 1691 + "node_modules/@std/media-types": { 1692 + "name": "@jsr/std__media-types", 1693 + "version": "1.1.0", 1694 + "resolved": "https://npm.jsr.io/~/11/@jsr/std__media-types/1.1.0.tgz", 1695 + "integrity": "sha512-dHvaxHL7ENWnltgL653uo3KnKFse3ZbopZop2gqsT7yrscx7irZEClu5Cba7gMPPRk4Lg1FbriNcaBViM2RSBw==" 1696 + }, 1713 1697 "node_modules/@swc/helpers": { 1714 1698 "version": "0.5.17", 1715 1699 "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", ··· 1848 1832 "hasInstallScript": true, 1849 1833 "license": "MIT" 1850 1834 }, 1851 - "node_modules/98.css": { 1852 - "version": "0.1.21", 1853 - "resolved": "https://registry.npmjs.org/98.css/-/98.css-0.1.21.tgz", 1854 - "integrity": "sha512-ddk5qtUWyapM0Bzd5jwGExoE5fdSEGrP+F5VbYjyZLf2c9UVmn6w2NPTvCsoD4BWdGsjdLjlkQGhWwWTJcYQJQ==", 1855 - "license": "MIT" 1856 - }, 1857 1835 "node_modules/acorn": { 1858 1836 "version": "8.14.1", 1859 1837 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", ··· 5039 5017 "integrity": "sha512-0uGYQ1WQL1M5kKvGRXWQ3uZCHtLTO8hln3oBjIusM75WoesZ909uQJs/Hb946i2SS+Gsrhkaa6iAO17jRIv6DQ==", 5040 5018 "dev": true 5041 5019 }, 5020 + "node_modules/node-s3-url-encode": { 5021 + "version": "0.0.4", 5022 + "resolved": "https://registry.npmjs.org/node-s3-url-encode/-/node-s3-url-encode-0.0.4.tgz", 5023 + "integrity": "sha512-l0IizfnxE1hb9dadzYBpA27syfL9LFkPzCKH6YWrssv2sPLjVuCent67A8GPe4isdj4bEsbgdPWLTcV4gxEg9w==", 5024 + "license": "MIT" 5025 + }, 5042 5026 "node_modules/normalize-path": { 5043 5027 "version": "3.0.0", 5044 5028 "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+1
package.json
··· 2 2 "dependencies": { 3 3 "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@^0.9.0", 4 4 "@picocss/pico": "^2.1.1", 5 + "@std/media-types": "npm:@jsr/std__media-types@^1.1.0", 5 6 "@web-applets/sdk": "https://gitpkg.vercel.app/unternet-co/web-applets/sdk?tokono.ma/experiment&scripts.postinstall=npm%20i%20%40types%2Fnode%20%26%26%20npx%20tsc", 6 7 "iconoir": "^7.11.0", 7 8 "idb-keyval": "^6.2.1",
+14 -4
src/pages/configurator/input/_applet.astro
··· 33 33 import { applets } from "@web-applets/sdk"; 34 34 35 35 import type { Track } from "@applets/core/types.d.ts"; 36 - import { applet, waitUntilAppletIsReady } from "@scripts/theme"; 36 + import { applet } from "@scripts/theme"; 37 37 38 38 //////////////////////////////////////////// 39 39 // SETUP ··· 51 51 // Applet connections 52 52 const input = { 53 53 nativeFs: await applet("../../input/native-fs", { container }), 54 + s3: await applet("../../input/s3", { container }), 54 55 }; 55 56 56 57 //////////////////////////////////////////// ··· 65 66 }, 66 67 { 67 68 [input.nativeFs.manifest.input_properties.scheme]: [], 69 + [input.s3.manifest.input_properties.scheme]: [], 68 70 }, 69 71 ); 70 72 ··· 76 78 timeoutDuration: 60000 * 60 * 24, 77 79 }); 78 80 81 + case input.s3.manifest.input_properties.scheme: 82 + return await input.s3.sendAction("list", cachedTracksGroup, { 83 + timeoutDuration: 60000 * 60 * 24, 84 + }); 85 + 79 86 default: 80 87 return cachedTracks; 81 88 } ··· 88 95 return tracks; 89 96 }; 90 97 91 - const resolve = async (fileUri: string) => { 92 - const scheme = fileUri.split(":", 1)[0]; 98 + const resolve = async (args: { method: string; uri: string }) => { 99 + const scheme = args.uri.split(":", 1)[0]; 93 100 94 101 switch (scheme) { 95 102 case input.nativeFs.manifest.input_properties.scheme: 96 - return await input.nativeFs.sendAction("resolve", fileUri); 103 + return await input.nativeFs.sendAction("resolve", args); 104 + 105 + case input.s3.manifest.input_properties.scheme: 106 + return await input.s3.sendAction("resolve", args); 97 107 98 108 default: 99 109 return undefined;
+9 -2
src/pages/configurator/input/_manifest.json
··· 18 18 "title": "Resolve", 19 19 "description": "Potentially translates a track uri with a matching scheme into a URL pointing at the audio bytes. If it can be resolved that is, otherwise you'll get `undefined`.", 20 20 "params_schema": { 21 - "type": "string", 22 - "description": "The uri to resolve" 21 + "type": "object", 22 + "properties": { 23 + "method": { 24 + "type": "string", 25 + "description": "The HTTP method that is going to be used on the resolved URI." 26 + }, 27 + "uri": { "type": "string", "description": "The URI to resolve." } 28 + }, 29 + "required": ["method", "uri"] 23 30 } 24 31 } 25 32 }
+2
src/pages/index.astro
··· 48 48 const output = [ 49 49 { url: "output/indexed-db/", title: "IndexedDB" }, 50 50 { url: "output/native-fs/", title: "Native File System" }, 51 + { url: "output/todo/", title: "(TODO) Keyhive/Beelay" }, 52 + { url: "output/todo/", title: "(TODO) Some local-first sync engine" }, 51 53 ]; 52 54 53 55 const processors = [
+4 -3
src/pages/input/native-fs/_applet.astro
··· 16 16 </main> 17 17 18 18 <script> 19 - import * as IDB from "idb-keyval"; 20 - 21 19 import { applets } from "@web-applets/sdk"; 22 20 import { computed, effect, Signal, signal } from "spellcaster"; 23 21 import { repeat, tags, text } from "spellcaster/hyperscript.js"; 24 22 import { type FileSystemDirectoryHandle, showDirectoryPicker } from "native-file-system-adapter"; 23 + import * as IDB from "idb-keyval"; 25 24 import * as URI from "uri-js"; 26 25 import QS from "query-string"; 27 26 ··· 163 162 return data; 164 163 }; 165 164 166 - const resolve = async (fileUri: string) => { 165 + const resolve = async (args: { uri: string }) => { 166 + const fileUri = args.uri; 167 + 167 168 if (!isSupported()) { 168 169 return undefined; 169 170 }
+9 -2
src/pages/input/native-fs/_manifest.json
··· 29 29 "title": "Resolve", 30 30 "description": "Potentially translates a track uri with a matching scheme into a URL pointing at the audio bytes. If it can be resolved that is, otherwise you'll get `undefined`. Use the `consult` action to get a more detailed answer.", 31 31 "params_schema": { 32 - "type": "string", 33 - "description": "The uri to resolve" 32 + "type": "object", 33 + "properties": { 34 + "method": { 35 + "type": "string", 36 + "description": "The HTTP method that is going to be used on the resolved URI." 37 + }, 38 + "uri": { "type": "string", "description": "The URI to resolve." } 39 + }, 40 + "required": ["method", "uri"] 34 41 } 35 42 }, 36 43 "mount": {
+362
src/pages/input/s3/_applet.astro
··· 1 + <main class="container"> 2 + <h1>S3-compatible input</h1> 3 + 4 + <h4>Mounted buckets</h4> 5 + 6 + <div id="buckets"> 7 + <p> 8 + <span class="with-icon"> 9 + <i class="iconoir-bonfire"></i> 10 + <small>Just a moment, loading mounted buckets.</small> 11 + </span> 12 + </p> 13 + </div> 14 + 15 + <h4>Add a new S3 bucket</h4> 16 + 17 + <form id="form"></form> 18 + 19 + <!-- Warning about hostnames/regions/buckets --> 20 + <!-- <p> 21 + <small> 22 + <span class="with-icon"> 23 + <i class="iconoir-warning-triangle"></i> 24 + <span 25 + >The bucket name and region are not automatically prefixed/inserted into the hostname, you 26 + must add them yourself to either the host or the path <strong>if needed</strong>.</span 27 + > 28 + </span> 29 + </small> 30 + </p> --> 31 + </main> 32 + 33 + <style is:global> 34 + iframe { 35 + display: none; 36 + } 37 + </style> 38 + 39 + <script> 40 + import { S3Client } from "@bradenmacdonald/s3-lite-client"; 41 + import { type AppletEvent, applets } from "@web-applets/sdk"; 42 + import { computed, effect, Signal, signal } from "spellcaster"; 43 + import { type Props, repeat, tags, text } from "spellcaster/hyperscript.js"; 44 + import * as IDB from "idb-keyval"; 45 + import * as URI from "uri-js"; 46 + import QS from "query-string"; 47 + 48 + // @ts-ignore 49 + import { encodeS3URI } from "node-s3-url-encode"; 50 + 51 + import type { Output, Track } from "@applets/core/types.d.ts"; 52 + import { applet } from "@scripts/theme"; 53 + 54 + import manifest from "./_manifest.json"; 55 + import { isAudioFile } from "@scripts/inputs/common"; 56 + 57 + type Bucket = { 58 + accessKey: string; 59 + bucketName: string; 60 + host: string; 61 + path: string; 62 + region: string; 63 + secretKey: string; 64 + }; 65 + 66 + const ENCODINGS = { 67 + "\+": "%2B", 68 + "\!": "%21", 69 + '\"': "%22", 70 + "\#": "%23", 71 + "\$": "%24", 72 + "\&": "%26", 73 + "'": "%27", 74 + "\(": "%28", 75 + "\)": "%29", 76 + "\*": "%2A", 77 + "\,": "%2C", 78 + "\:": "%3A", 79 + "\;": "%3B", 80 + "\=": "%3D", 81 + "\?": "%3F", 82 + "\@": "%40", 83 + }; 84 + 85 + //////////////////////////////////////////// 86 + // SETUP 87 + //////////////////////////////////////////// 88 + const IDB_PREFIX = "@applets/input/s3"; 89 + const IDB_BUCKETS = `${IDB_PREFIX}/buckets`; 90 + const SCHEME = manifest.input_properties.scheme; 91 + 92 + // Register applet 93 + const context = applets.register(); 94 + 95 + // Applet connections 96 + const orchestrator = { 97 + output: await applet<Output>("../../orchestrator/output-management", { 98 + context: self.top || self.parent, 99 + }), 100 + }; 101 + 102 + // Watch for data changes 103 + orchestrator.output.addEventListener("data", async (event: AppletEvent) => { 104 + await loadBuckets(); 105 + }); 106 + 107 + //////////////////////////////////////////// 108 + // UI 109 + //////////////////////////////////////////// 110 + const [buckets, setBuckets] = signal<Record<string, Bucket>>(await loadBuckets()); 111 + const [form, setForm] = signal<{ 112 + access_key?: string; 113 + bucket_name?: string; 114 + host?: string; 115 + path?: string; 116 + region?: string; 117 + secret_key?: string; 118 + }>({}); 119 + 120 + const bucketsMap = computed(() => { 121 + return new Map(Object.entries(buckets())); 122 + }); 123 + 124 + effect(() => { 125 + saveBuckets(buckets()); 126 + }); 127 + 128 + //////////////////////////////////////////// 129 + // UI ~ BUCKETS 130 + //////////////////////////////////////////// 131 + const Bucket = (bucket: Signal<Bucket>) => { 132 + const onclick = () => { 133 + const b = bucket(); 134 + const id = bucketId(b); 135 + 136 + const col = { ...buckets() }; 137 + delete col[id]; 138 + 139 + setBuckets(col); 140 + }; 141 + 142 + return tags.li({ onclick, style: "cursor: pointer" }, text(bucket().host)); 143 + }; 144 + 145 + const BucketList = computed(() => { 146 + if (bucketsMap().size === 0) { 147 + return tags.p({ id: "buckets" }, [tags.small({}, text("Nothing added so far."))]); 148 + } 149 + 150 + return tags.ul({ id: "buckets" }, repeat(bucketsMap, Bucket)); 151 + }); 152 + 153 + effect(() => { 154 + document.querySelector("#buckets")?.replaceWith(BucketList()); 155 + }); 156 + 157 + //////////////////////////////////////////// 158 + // UI ~ FORM 159 + //////////////////////////////////////////// 160 + function addBucket(event: Event) { 161 + event.preventDefault(); 162 + 163 + const f = form(); 164 + 165 + const bucket: Bucket = { 166 + accessKey: f.access_key || "", 167 + bucketName: f.bucket_name || "", 168 + host: f.host || "s3.amazonaws.com", 169 + path: f.path || "/", 170 + region: f.region || "us-east-1", 171 + secretKey: f.secret_key || "", 172 + }; 173 + 174 + setBuckets({ 175 + ...buckets(), 176 + [bucketId(bucket)]: bucket, 177 + }); 178 + } 179 + 180 + function Form() { 181 + return tags.form({ onsubmit: addBucket }, [ 182 + tags.fieldset({ className: "grid" }, [ 183 + Input("access_key", "Access key", "r31w7m9c", { required: true }), 184 + Input("secret_key", "Secret key", "v02g2l29", { required: true }), 185 + ]), 186 + tags.fieldset({ className: "grid" }, [ 187 + Input("bucket_name", "Bucket name", "bucket", { required: true }), 188 + Input("region", "Region", "us-east-1", { required: true }), 189 + ]), 190 + tags.fieldset({ className: "grid" }, [ 191 + Input("host", "Host", "s3.amazonaws.com", { required: true }), 192 + Input("path", "Path", "/"), 193 + ]), 194 + tags.fieldset({ className: "grid" }, [tags.input({ type: "submit", value: "Connect" }, [])]), 195 + ]); 196 + } 197 + 198 + function Input(name: string, label: string, placeholder: string, opts: Props = {}) { 199 + return tags.label({}, [ 200 + tags.span({}, [ 201 + tags.span({}, text(label)), 202 + tags.small({}, text("required" in opts ? "" : " (optional)")), 203 + ]), 204 + tags.input({ 205 + ...opts, 206 + name, 207 + placeholder, 208 + oninput: (event: InputEvent) => formInput(name, (event.target as HTMLInputElement).value), 209 + }), 210 + ]); 211 + } 212 + 213 + function formInput(name: string, value: string) { 214 + setForm({ ...form(), [name]: value }); 215 + } 216 + 217 + // ๐Ÿš€ 218 + document.querySelector("#form")?.replaceWith(Form()); 219 + 220 + //////////////////////////////////////////// 221 + // ACTIONS 222 + //////////////////////////////////////////// 223 + const consult = async (fileUriOrScheme: string) => { 224 + if (!navigator.onLine) return false; 225 + 226 + // TODO: Check if bucket is avail*able + CORS works? 227 + return true; 228 + }; 229 + 230 + const list = async (_cachedTracks: Track[] = []) => { 231 + // TODO: Do we need to do something with the old tracks here? 232 + 233 + const promises = Object.values(buckets()).map(async (bucket) => { 234 + const client = createClient(bucket); 235 + 236 + const list = await Array.fromAsync( 237 + client.listObjects({ 238 + prefix: bucket.path.replace(/^\//, ""), 239 + }), 240 + ); 241 + 242 + return list 243 + .filter((l) => isAudioFile(l.key)) 244 + .map((l) => { 245 + const track: Track = { 246 + id: crypto.randomUUID(), 247 + uri: buildURI(bucket, l.key), 248 + }; 249 + 250 + return track; 251 + }); 252 + }); 253 + 254 + return (await Promise.all(promises)).flat(1); 255 + }; 256 + 257 + const resolve = async ({ method, uri }: { method: string; uri: string }) => { 258 + const bucket = parseURI(uri); 259 + if (!bucket) return undefined; 260 + 261 + const client = createClient(bucket); 262 + const parsedURI = URI.parse(uri); 263 + const path = ( 264 + bucket.path.replace(/\/$/, "") + URI.unescapeComponent(parsedURI.path || "") 265 + ).replace(/^\//, ""); 266 + 267 + const url = await client.getPresignedUrl(method.toUpperCase() as any, path); 268 + return url; 269 + }; 270 + 271 + const mount = async () => {}; 272 + 273 + const unmount = async () => {}; 274 + 275 + context.setActionHandler("consult", consult); 276 + context.setActionHandler("list", list); 277 + context.setActionHandler("resolve", resolve); 278 + context.setActionHandler("mount", mount); 279 + context.setActionHandler("unmount", unmount); 280 + 281 + //////////////////////////////////////////// 282 + // ๐Ÿ› ๏ธ 283 + //////////////////////////////////////////// 284 + function bucketsFromTracks(tracks: Track[]) { 285 + return tracks.reduce((acc: Record<string, Bucket>, track: Track) => { 286 + const bucket = parseURI(track.uri); 287 + if (!bucket) return acc; 288 + 289 + const id = bucketId(bucket); 290 + if (acc[id]) return acc; 291 + 292 + return { ...acc, [id]: bucket }; 293 + }, {}); 294 + } 295 + 296 + function bucketId(bucket: Bucket) { 297 + return `${bucket.accessKey}:${bucket.secretKey}@${bucket.host}`; 298 + } 299 + 300 + function buildURI(bucket: Bucket, path: string) { 301 + return URI.serialize({ 302 + scheme: SCHEME, 303 + userinfo: `${bucket.accessKey}:${bucket.secretKey}`, 304 + host: bucket.host, 305 + path: path, 306 + query: QS.stringify({ 307 + bucketName: bucket.bucketName, 308 + bucketPath: bucket.path, 309 + region: bucket.region, 310 + }), 311 + }); 312 + } 313 + 314 + function createClient(bucket: Bucket) { 315 + return new S3Client({ 316 + bucket: bucket.bucketName, 317 + endPoint: bucket.host.includes("://") ? bucket.host : `https://${bucket.host}`, 318 + region: bucket.region, 319 + pathStyle: false, 320 + accessKey: bucket.accessKey, 321 + secretKey: bucket.secretKey, 322 + }); 323 + } 324 + 325 + function encodeAwsUriComponent(a: string) { 326 + return encodeURIComponent(a).replace( 327 + /(\+|!|"|#|\$|&|'|\(|\)|\*|\+|,|:|;|=|\?|@)/gim, 328 + (match) => (ENCODINGS as any)[match] ?? match, 329 + ); 330 + } 331 + 332 + async function loadBuckets() { 333 + const i = await IDB.get(IDB_BUCKETS); 334 + const t = bucketsFromTracks(orchestrator.output.data.tracks); 335 + 336 + return { ...i, ...t }; 337 + } 338 + 339 + function parseURI(uriString: string): Bucket | undefined { 340 + const uri = URI.parse(uriString); 341 + if (uri.scheme !== SCHEME) return undefined; 342 + if (!uri.host) return undefined; 343 + 344 + const [accessKey, secretKey] = uri.userinfo?.split(":") ?? []; 345 + if (!accessKey || !secretKey) return undefined; 346 + 347 + const qs = QS.parse(uri.query || ""); 348 + 349 + return { 350 + accessKey, 351 + bucketName: typeof qs.bucketName === "string" ? qs.bucketName : "", 352 + host: uri.host, 353 + path: qs.bucketPath === "string" ? qs.bucketPath : "/", 354 + region: typeof qs.region === "string" ? qs.region : "", 355 + secretKey, 356 + }; 357 + } 358 + 359 + async function saveBuckets(items: Record<string, Bucket>) { 360 + await IDB.set(IDB_BUCKETS, items); 361 + } 362 + </script>
+55
src/pages/input/s3/_manifest.json
··· 1 + { 2 + "name": "diffuse/input/s3", 3 + "title": "Diffuse Input | S3", 4 + "entrypoint": "index.html", 5 + "input_properties": { 6 + "scheme": "s3" 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 + "list": { 17 + "title": "List", 18 + "description": "List tracks.", 19 + "params_schema": { 20 + "type": "array", 21 + "description": "A list of (cached) tracks with an uri matching the scheme", 22 + "items": { 23 + "type": "object" 24 + } 25 + } 26 + }, 27 + "resolve": { 28 + "title": "Resolve", 29 + "description": "Potentially translates a track uri with a matching scheme into a URL pointing at the audio bytes. If it can be resolved that is, otherwise you'll get `undefined`. Use the `consult` action to get a more detailed answer.", 30 + "params_schema": { 31 + "type": "object", 32 + "properties": { 33 + "method": { 34 + "type": "string", 35 + "description": "The HTTP method that is going to be used on the resolved URI." 36 + }, 37 + "uri": { "type": "string", "description": "The URI to resolve." } 38 + }, 39 + "required": ["method", "uri"] 40 + } 41 + }, 42 + "mount": { 43 + "title": "Mount", 44 + "description": "Prepare for usage." 45 + }, 46 + "unmount": { 47 + "title": "Unmount", 48 + "description": "Callback after usage.", 49 + "params_schema": { 50 + "type": "string", 51 + "description": "The handle id to unmount" 52 + } 53 + } 54 + } 55 + }
+9
src/pages/input/s3/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>
+11 -6
src/pages/orchestrator/input-cache/_applet.astro
··· 16 16 17 17 // Applet connections 18 18 const configurator = { 19 - input: await applet("../../configurator/input", { context: self.parent }), 19 + input: await applet("../../configurator/input", { context: self.top || self.parent }), 20 20 }; 21 21 22 22 const orchestrator = { ··· 52 52 53 53 if (track.tags) return [...acc, track]; 54 54 55 - const url = await configurator.input.sendAction<string | undefined>("resolve", track.uri, { 56 - timeoutDuration: 60000, 57 - }); 55 + const getURL = await configurator.input.sendAction<string | undefined>( 56 + "resolve", 57 + { method: "GET", uri: track.uri }, 58 + { 59 + timeoutDuration: 60000, 60 + }, 61 + ); 58 62 59 - if (!url) return acc; 63 + if (!getURL) return acc; 60 64 61 - const meta = await processor.metadataFetcher.sendAction("extract", url, { 65 + // TODO: Do we need to pass the HEAD URL too? 66 + const meta = await processor.metadataFetcher.sendAction("extract", getURL, { 62 67 timeoutDuration: 60000, 63 68 }); 64 69
+1 -1
src/pages/orchestrator/output-management/_applet.astro
··· 20 20 21 21 // Applet connections 22 22 const configurator = { 23 - output: await applet("../../configurator/output", { context: self.parent }), 23 + output: await applet("../../configurator/output", { context: self.top || self.parent }), 24 24 }; 25 25 26 26 // Load tracks
+6 -2
src/pages/orchestrator/single-queue/_applet.astro
··· 15 15 16 16 // Applet connections 17 17 const engine = { 18 - audio: await applet<AudioEngine.State>("../../engine/audio", { context: self.parent }), 19 - queue: await applet<QueueEngine.State>("../../engine/queue", { context: self.parent }), 18 + audio: await applet<AudioEngine.State>("../../engine/audio", { 19 + context: self.top || self.parent, 20 + }), 21 + queue: await applet<QueueEngine.State>("../../engine/queue", { 22 + context: self.top || self.parent, 23 + }), 20 24 }; 21 25 22 26 const orchestrator = {
+8 -1
src/pages/processor/metadata-fetcher/_applet.astro
··· 1 1 <script> 2 2 import { applets } from "@web-applets/sdk"; 3 3 import { parseWebStream } from "music-metadata"; 4 + import { contentType } from "@std/media-types"; 5 + import * as URI from "uri-js"; 4 6 5 7 //////////////////////////////////////////// 6 8 // SETUP ··· 13 15 context.setActionHandler("extract", extract); 14 16 15 17 async function extract(url: string) { 18 + const uri = URI.parse(url); 19 + const pathParts = uri.path?.split("/"); 20 + const mimeType = pathParts?.[pathParts.length - 1]?.includes(".") 21 + ? contentType(pathParts[pathParts.length - 1].split(".").reverse()[0]) 22 + : undefined; 16 23 const resp = await fetch(url); 17 24 const stream = resp.body; 18 - const metadata = await parseWebStream(stream); 25 + const metadata = await parseWebStream(stream, { mimeType }); 19 26 20 27 return metadata; 21 28 }
+8 -3
src/scripts/theme.ts
··· 50 50 throw new Error("iframe does not have a contentWindow"); 51 51 } 52 52 53 - const applet = await applets.connect<D>(frame.contentWindow, { 54 - context: opts.context, 55 - }); 53 + const applet = await applets 54 + .connect<D>(frame.contentWindow, { 55 + context: opts.context, 56 + }) 57 + .catch((err) => { 58 + console.error("Error connecting to " + src, err); 59 + throw err; 60 + }); 56 61 57 62 if (opts.setHeight) { 58 63 applet.onresize = () => {
+7 -3
src/scripts/themes/webamp/index.ts
··· 54 54 // TODO: Ideally the URL should only be resolved when needed, 55 55 // but webamp doesn't allow for that. 56 56 // Maybe you could work around it with a service worker. 57 - const url = await configurator.input.sendAction<string | undefined>("resolve", track.uri, { 58 - timeoutDuration: 60000, 59 - }); 57 + const url = await configurator.input.sendAction<string | undefined>( 58 + "resolve", 59 + { method: "GET", uri: track.uri }, 60 + { 61 + timeoutDuration: 60000, 62 + }, 63 + ); 60 64 61 65 if (!url) return acc; 62 66