Experiment to rebuild Diffuse using web applets.
0
fork

Configure Feed

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

feat: initial work for artwork fetcher + applet loading fixes

+338 -96
+1
deno.lock
··· 37 37 "npm:idb-keyval@^6.2.1", 38 38 "npm:music-metadata@^11.2.3", 39 39 "npm:native-file-system-adapter@^3.0.1", 40 + "npm:netlify@^22.1.0", 40 41 "npm:purgecss@^7.0.2", 41 42 "npm:query-string@^9.1.2", 42 43 "npm:sass@^1.87.0",
+18 -24
src/pages/configurator/input/_applet.astro
··· 36 36 //////////////////////////////////////////// 37 37 // SETUP 38 38 //////////////////////////////////////////// 39 - const context = register<{ ready: boolean }>(); 40 - 41 - // Initial state 42 - context.data = { 43 - ready: false, 44 - }; 39 + const context = register(); 45 40 46 41 // Applet connections 47 42 const input = { 48 - nativeFs: await applet("../../input/native-fs"), 49 - s3: await applet("../../input/s3"), 43 + nativeFs: applet("../../input/native-fs"), 44 + s3: applet("../../input/s3"), 50 45 }; 51 46 52 47 //////////////////////////////////////////// 53 48 // ACTIONS 54 49 //////////////////////////////////////////// 55 50 const contextualize = async (tracks: Track[]) => { 56 - await input.s3.sendAction("contextualize", tracks); 51 + const s3 = await input.s3; 52 + await s3.sendAction("contextualize", tracks, { timeoutDuration: 60000 * 5 }); 57 53 }; 58 54 59 55 const list = async (cachedTracks: Track[] = []) => { 56 + const [nativeFs, s3] = [await input.nativeFs, await input.s3]; 57 + 60 58 const groups = cachedTracks.reduce( 61 59 (acc: Record<string, Track[]>, track: Track) => { 62 60 const scheme = track.uri.split(":", 1)[0]; 63 61 return { ...acc, [scheme]: [...(acc[scheme] || []), track] }; 64 62 }, 65 63 { 66 - [input.nativeFs.manifest.input_properties.scheme]: [], 67 - [input.s3.manifest.input_properties.scheme]: [], 64 + [nativeFs.manifest.input_properties.scheme]: [], 65 + [s3.manifest.input_properties.scheme]: [], 68 66 }, 69 67 ); 70 68 71 69 const promises = Object.entries(groups).map( 72 70 async ([scheme, cachedTracksGroup]: [string, Track[]]) => { 73 71 switch (scheme) { 74 - case input.nativeFs.manifest.input_properties.scheme: 75 - return await input.nativeFs.sendAction("list", cachedTracksGroup, { 72 + case nativeFs.manifest.input_properties.scheme: 73 + return await nativeFs.sendAction("list", cachedTracksGroup, { 76 74 timeoutDuration: 60000 * 60 * 24, 77 75 }); 78 76 79 - case input.s3.manifest.input_properties.scheme: 80 - return await input.s3.sendAction("list", cachedTracksGroup, { 77 + case s3.manifest.input_properties.scheme: 78 + return await s3.sendAction("list", cachedTracksGroup, { 81 79 timeoutDuration: 60000 * 60 * 24, 82 80 }); 83 81 ··· 94 92 }; 95 93 96 94 const resolve = async (args: { method: string; uri: string }) => { 95 + const [nativeFs, s3] = [await input.nativeFs, await input.s3]; 97 96 const scheme = args.uri.split(":", 1)[0]; 98 97 99 98 switch (scheme) { 100 - case input.nativeFs.manifest.input_properties.scheme: 101 - return await input.nativeFs.sendAction("resolve", args); 99 + case nativeFs.manifest.input_properties.scheme: 100 + return await nativeFs.sendAction("resolve", args); 102 101 103 - case input.s3.manifest.input_properties.scheme: 104 - return await input.s3.sendAction("resolve", args); 102 + case s3.manifest.input_properties.scheme: 103 + return await s3.sendAction("resolve", args); 105 104 106 105 default: 107 106 return undefined; ··· 111 110 context.setActionHandler("contextualize", contextualize); 112 111 context.setActionHandler("list", list); 113 112 context.setActionHandler("resolve", resolve); 114 - 115 - //////////////////////////////////////////// 116 - // 🚦 117 - //////////////////////////////////////////// 118 - context.data = { ready: true }; 119 113 </script>
+1
src/pages/configurator/input/_manifest.json
··· 5 5 "actions": { 6 6 "contextualize": { 7 7 "title": "Contextualize", 8 + "description": "Provide context to all inputs.", 8 9 "params_schema": { 9 10 "type": "array", 10 11 "description": "Array of tracks",
+4 -3
src/pages/configurator/output/_applet.astro
··· 81 81 // Applet connections 82 82 const connections: Record<string, Applet<ManagedOutput>> = {}; 83 83 84 + // Initial state 85 + context.data = INITIAL_MANAGED_OUTPUT; 86 + 87 + // Applet connections 84 88 async function connection(method: Method) { 85 89 if (connections[method]) return connections[method]; 86 90 ··· 97 101 connections[method] = await applet(href); 98 102 return connections[method]; 99 103 } 100 - 101 - // Initial state 102 - context.data = INITIAL_MANAGED_OUTPUT; 103 104 104 105 // Signals 105 106 const stored = localStorage.getItem(LOCALSTORAGE_KEY);
+1 -1
src/pages/index.astro
··· 60 60 61 61 const processors = [ 62 62 { url: "processor/artwork/", title: "(TODO) Artwork fetcher" }, 63 - { url: "processor/metadata-fetcher/", title: "Metadata fetcher" }, 63 + { url: "processor/metadata/", title: "Metadata fetcher" }, 64 64 ]; 65 65 --- 66 66
+23 -29
src/pages/orchestrator/input-cache/_applet.astro
··· 1 1 <script> 2 2 import type { ManagedOutput, ResolvedUri, Track } from "@applets/core/types.d.ts"; 3 - 4 - import { 5 - applet, 6 - register, 7 - waitUntilAppletData, 8 - waitUntilAppletIsReady, 9 - } from "@scripts/applets/common"; 3 + import { applet, register, waitUntilAppletData } from "@scripts/applets/common"; 10 4 11 5 //////////////////////////////////////////// 12 6 // SETUP 13 7 //////////////////////////////////////////// 14 - const context = register<{ isProcessing: boolean; ready: boolean }>(); 8 + const context = register<{ isProcessing: boolean }>(); 15 9 16 10 // Initial data 17 11 context.data = { 18 12 isProcessing: false, 19 - ready: false, 20 13 }; 21 14 22 15 // Applet connections 23 16 const configurator = { 24 - input: await applet("../../configurator/input"), 25 - output: await applet<ManagedOutput>("../../configurator/output"), 17 + input: applet("../../configurator/input"), 18 + output: applet<ManagedOutput>("../../configurator/output"), 26 19 }; 27 20 28 21 const processor = { 29 - metadataFetcher: await applet("../../processor/metadata-fetcher"), 22 + metadata: applet("../../processor/metadata"), 30 23 }; 31 24 32 - // 🚀 33 - waitUntilAppletData(configurator.output, (d) => d?.tracks.state === "loaded").then(() => 34 - process(), 35 - ); 25 + // Start processing when tracks are loaded 26 + configurator.output 27 + .then((output) => { 28 + return waitUntilAppletData(output, (d) => d?.tracks.state === "loaded"); 29 + }) 30 + .then(process); 36 31 37 32 //////////////////////////////////////////// 38 33 // ACTIONS ··· 44 39 context.data = { ...context.data, isProcessing: true }; 45 40 console.log("🪵 Processing initiated"); 46 41 47 - await waitUntilAppletIsReady(configurator.input); 42 + const input = await configurator.input; 43 + const output = await configurator.output; 48 44 49 - const cachedTracks = configurator.output.data.tracks.collection; 50 - await configurator.input.sendAction("contextualize", cachedTracks); 45 + const cachedTracks = output.data.tracks.collection; 46 + await input.sendAction("contextualize", cachedTracks, { 47 + timeoutDuration: 60000 * 5, 48 + }); 51 49 52 - const tracks = await configurator.input.sendAction<Track[]>("list", cachedTracks, { 50 + const tracks = await input.sendAction<Track[]>("list", cachedTracks, { 53 51 timeoutDuration: 60000 * 60 * 24, 54 52 }); 55 53 ··· 60 58 61 59 if (track.tags && track.stats) return [...acc, track]; 62 60 63 - const resGet = await configurator.input.sendAction<ResolvedUri>( 61 + const resGet = await input.sendAction<ResolvedUri>( 64 62 "resolve", 65 63 { method: "GET", uri: track.uri }, 66 64 { ··· 68 66 }, 69 67 ); 70 68 71 - const resHead = await configurator.input.sendAction<ResolvedUri>( 69 + const resHead = await input.sendAction<ResolvedUri>( 72 70 "resolve", 73 71 { method: "HEAD", uri: track.uri }, 74 72 { ··· 78 76 79 77 if (!resGet) return acc; 80 78 81 - const { stats, tags } = await processor.metadataFetcher.sendAction( 79 + const metadataProcessor = await processor.metadata; 80 + const { stats, tags } = await metadataProcessor.sendAction( 82 81 "extract", 83 82 { urls: { get: resGet.url, head: resHead?.url || resGet.url } }, 84 83 { ··· 94 93 ); 95 94 96 95 // Save 97 - await configurator.output.sendAction("tracks", tracksWithMetadata, { 96 + await output.sendAction("tracks", tracksWithMetadata, { 98 97 timeoutDuration: 60000 * 5, 99 98 }); 100 99 ··· 102 101 console.log("🪵 Processing completed"); 103 102 context.data = { ...context.data, isProcessing: false }; 104 103 } 105 - 106 - //////////////////////////////////////////// 107 - // 🚦 108 - //////////////////////////////////////////// 109 - context.data = { ...context.data, ready: true }; 110 104 </script>
+10 -4
src/pages/orchestrator/single-queue/_applet.astro
··· 31 31 const queueItems = await tracks.reduce( 32 32 async (promise: Promise<QueueEngine.QueueItem[]>, track: Track) => { 33 33 const acc = await promise; 34 - const res = await configurator.input.sendAction<ResolvedUri>("resolve", { 35 - method: "GET", 36 - uri: track.uri, 37 - }); 34 + const res = await configurator.input.sendAction<ResolvedUri>( 35 + "resolve", 36 + { 37 + method: "GET", 38 + uri: track.uri, 39 + }, 40 + { 41 + timeoutDuration: 60000 * 5, 42 + }, 43 + ); 38 44 39 45 if (!res) return acc; 40 46
+76
src/pages/processor/artwork/_applet.astro
··· 1 + <script> 2 + import * as IDB from "idb-keyval"; 3 + 4 + import { applet, register } from "@scripts/applets/common"; 5 + import type { ArtworkRequest, State } from "./types.d.ts"; 6 + 7 + //////////////////////////////////////////// 8 + // SETUP 9 + //////////////////////////////////////////// 10 + const IDB_PREFIX = "@applets/processor/artwork"; 11 + 12 + const context = register<State>(); 13 + let queue: ArtworkRequest[] = []; 14 + 15 + // Initial data 16 + context.data = { 17 + artwork: [], 18 + }; 19 + 20 + // Applet connections 21 + const processor = { 22 + metadata: applet("../../processor/metadata"), 23 + }; 24 + 25 + // Load already-downloaded artwork 26 + IDB.keys().then((keys) => { 27 + console.log(keys); 28 + const artworkKeys = keys.filter((k) => k.toString().startsWith(`${IDB_PREFIX}/artwork/`)); 29 + }); 30 + 31 + //////////////////////////////////////////// 32 + // ACTIONS 33 + //////////////////////////////////////////// 34 + function supply(items: ArtworkRequest[]) { 35 + const exe = !queue[0]; 36 + queue = [...queue, ...items]; 37 + console.log("supply", queue); 38 + if (exe) shiftQueue(); 39 + } 40 + 41 + context.setActionHandler("supply", supply); 42 + 43 + //////////////////////////////////////////// 44 + // 🛠️ 45 + //////////////////////////////////////////// 46 + async function shiftQueue() { 47 + const next = queue.shift(); 48 + if (!next) return; 49 + 50 + // Check if already processed 51 + const cache = await IDB.get(`${IDB_PREFIX}/${next.cacheId}`); 52 + if (cache) return; 53 + 54 + // 🚀 55 + let art: Uint8Array | undefined; 56 + 57 + // Get metadata + possible artwork from file metadata 58 + const proc = await processor.metadata; 59 + const meta = await proc.sendAction("supply", { ...next, includeArtwork: true }); 60 + 61 + // 62 + art = meta.artwork?.sort((a: any, b: any) => { 63 + if (a.data.length > b.data.length) return -1; 64 + if (a.data.length < b.data.length) return 1; 65 + return 0; 66 + })?.[0]?.data; 67 + 68 + console.log(art); 69 + 70 + // Save artwork to IDB 71 + await IDB.set(`${IDB_PREFIX}/${next.cacheId}`, art || "TRIED"); 72 + 73 + // 🏹 74 + shiftQueue(); 75 + } 76 + </script>
+38
src/pages/processor/artwork/_manifest.json
··· 1 + { 2 + "name": "diffuse/processor/artwork", 3 + "title": "Diffuse Processor | Artwork fetcher", 4 + "description": "Tries to get artwork for a given URL or stream.", 5 + "entrypoint": "index.html", 6 + "actions": { 7 + "supply": { 8 + "title": "Supply", 9 + "description": "Get the artwork for a given URL.", 10 + "params_schema": { 11 + "type": "array", 12 + "items": { 13 + "type": "object", 14 + "properties": { 15 + "cacheId": { 16 + "type": "string" 17 + }, 18 + "mimeType": { 19 + "type": "string" 20 + }, 21 + "stream": { 22 + "type": "object" 23 + }, 24 + "urls": { 25 + "type": "object", 26 + "properties": { 27 + "get": { "type": "string" }, 28 + "head": { "type": "string" } 29 + }, 30 + "required": ["get", "head"] 31 + } 32 + }, 33 + "required": ["cacheId"] 34 + } 35 + } 36 + } 37 + } 38 + }
+9
src/pages/processor/artwork/index.astro
··· 1 + --- 2 + import Layout from "@layouts/applet.astro"; 3 + import Applet from "./_applet.astro"; 4 + import { title } from "./_manifest.json"; 5 + --- 6 + 7 + <Layout title={title}> 8 + <Applet /> 9 + </Layout>
+14
src/pages/processor/artwork/types.d.ts
··· 1 + export type Artwork = {}; 2 + 3 + export type ArtworkRequest = { 4 + cacheId: string; 5 + mimeType?: string; 6 + stream?: ReadableStream; 7 + urls?: Urls; 8 + }; 9 + 10 + export type State = { 11 + artwork: Artwork[]; 12 + }; 13 + 14 + export type Urls = { get: string; head: string };
+11 -11
src/pages/processor/metadata-fetcher/_applet.astro src/pages/processor/metadata/_applet.astro
··· 1 1 <script> 2 - import { type IPicture, parseFromTokenizer, parseWebStream } from "music-metadata"; 2 + import { parseFromTokenizer, parseWebStream } from "music-metadata"; 3 3 import { contentType } from "@std/media-types"; 4 4 import * as URI from "uri-js"; 5 5 import * as HTTP_TOKENIZER from "@tokenizer/http"; 6 6 import * as RANGE_TOKENIZER from "@tokenizer/range"; 7 7 8 8 import type { TrackStats, TrackTags } from "@applets/core/types"; 9 + import type { Extraction, Urls } from "./types.d.ts"; 9 10 import { register } from "@scripts/applets/common"; 10 11 11 12 //////////////////////////////////////////// 12 13 // SETUP 13 14 //////////////////////////////////////////// 14 15 const context = register(); 15 - 16 - type Extraction = { artwork?: IPicture[]; stats: TrackStats; tags: TrackTags }; 17 - type Urls = { get: string; head: string }; 18 16 19 17 //////////////////////////////////////////// 20 18 // ACTIONS 21 19 //////////////////////////////////////////// 22 - context.setActionHandler("extract", extract); 20 + context.setActionHandler("supply", supply); 23 21 24 - async function extract(args: { 22 + async function supply(args: { 25 23 includeArtwork?: boolean; 26 24 mimeType?: string; 27 25 stream?: ReadableStream; ··· 29 27 }) { 30 28 // Construct records 31 29 // TODO: Use other metadata lib as fallback: https://github.com/buzz/mediainfo.js 32 - const { stats, tags } = await musicMetadataTags(args).catch(() => ({ 33 - stats: undefined, 34 - tags: undefined, 35 - })); 30 + const response = await musicMetadataTags(args).catch((err): Extraction => { 31 + console.warn("Metadata processor error:", err); 32 + console.log(args); 33 + 34 + return {}; 35 + }); 36 36 37 37 // Fin 38 - return { stats, tags }; 38 + return response; 39 39 } 40 40 41 41 ////////////////////////////////////////////
+4 -4
src/pages/processor/metadata-fetcher/_manifest.json src/pages/processor/metadata/_manifest.json
··· 1 1 { 2 - "name": "diffuse/processor/metadata-fetcher", 2 + "name": "diffuse/processor/metadata", 3 3 "title": "Diffuse Processor | Metadata fetcher", 4 4 "entrypoint": "index.html", 5 5 "actions": { 6 - "extract": { 7 - "title": "Extract", 8 - "description": "Get the metadata for a given URL.", 6 + "supply": { 7 + "title": "Supply", 8 + "description": "Get the metadata for a given URL or stream.", 9 9 "params_schema": { 10 10 "type": "object", 11 11 "properties": {
src/pages/processor/metadata-fetcher/index.astro src/pages/processor/metadata/index.astro
+5
src/pages/processor/metadata/types.d.ts
··· 1 + import type { IPicture } from "music-metadata"; 2 + import type { TrackStats, TrackTags } from "@applets/core/types"; 3 + 4 + export type Extraction = { artwork?: IPicture[]; stats?: TrackStats; tags?: TrackTags }; 5 + export type Urls = { get: string; head: string };
+50 -18
src/scripts/applets/common.ts
··· 98 98 }; 99 99 100 100 export function register<DataType = any>() { 101 - const id = `${location.host}${location.pathname}`; 101 + const channelId = `${location.host}${location.pathname}`; 102 102 const scope = applets.register<DataType>(); 103 + const id = crypto.randomUUID(); 103 104 104 105 let isMainInstance = true; 105 106 ··· 110 111 // 111 112 // Actions are performed on the main instance, 112 113 // and data is replicated from main to the other instances. 113 - const channel = new BroadcastChannel(id); 114 + const channel = new BroadcastChannel(channelId); 114 115 115 116 channel.addEventListener("message", async (event) => { 116 - if (event.data === "PING") { 117 - channel.postMessage("PONG"); 118 - } else if (event.data?.type === "data") { 119 - scope.data = context.codec.decode(event.data.data); 120 - } else if (event.data === "PONG") { 121 - isMainInstance = false; 122 - } else if (isMainInstance && event.data?.type === "action" && event.data?.actionId) { 123 - const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments); 124 - channel.postMessage({ 125 - type: "actioncomplete", 126 - id: event.data.id, 127 - result, 128 - }); 117 + switch (event.data?.type) { 118 + case "PING": { 119 + channel.postMessage({ 120 + type: "PONG", 121 + id: event.data.id, 122 + }); 123 + 124 + if (isMainInstance) { 125 + channel.postMessage({ 126 + type: "data", 127 + data: context.codec.encode(scope.data), 128 + }); 129 + } 130 + break; 131 + } 132 + 133 + case "PONG": { 134 + if (event.data.id === id) { 135 + isMainInstance = false; 136 + } 137 + break; 138 + } 139 + 140 + case "action": { 141 + if (isMainInstance) { 142 + const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments); 143 + channel.postMessage({ 144 + type: "actioncomplete", 145 + id: event.data.id, 146 + result, 147 + }); 148 + } 149 + break; 150 + } 151 + 152 + case "data": { 153 + scope.data = context.codec.decode(event.data.data); 154 + break; 155 + } 129 156 } 130 157 }); 131 158 ··· 148 175 }); 149 176 150 177 // Send out ping 151 - channel.postMessage("PING"); 178 + channel.postMessage({ 179 + type: "PING", 180 + id, 181 + }); 152 182 153 183 // If the data on the main instance changes, 154 184 // pass it on to other instances. 155 - scope.ondata = (event) => { 185 + scope.addEventListener("data", async (event: AppletEvent) => { 186 + await promise; 187 + 156 188 if (isMainInstance) { 157 189 channel.postMessage({ 158 190 type: "data", 159 191 data: context.codec.encode(event.data), 160 192 }); 161 193 } 162 - }; 194 + }); 163 195 164 196 // Context 165 197 const context: BroadcastedApplet<DataType> = {
+72 -1
src/scripts/themes/desktop/index.ts
··· 1 - import { applet, reactive } from "@scripts/applets/common"; 1 + import * as Uint8 from "uint8arrays"; 2 + import { applet, reactive, waitUntilAppletData } from "@scripts/applets/common"; 2 3 3 4 //////////////////////////////////////////// 4 5 // 🎨 Styles ··· 10 11 //////////////////////////////////////////// 11 12 import type * as AudioEngine from "@applets/engine/audio/types.d.ts"; 12 13 import type * as QueueEngine from "@applets/engine/queue/types.d.ts"; 14 + import type { ManagedOutput } from "@applets/core/types"; 15 + import type { Urls } from "@applets/processor/artwork/types"; 16 + 17 + const configurator = { 18 + input: await applet("../../configurator/input"), 19 + output: await applet<ManagedOutput>("../../configurator/output"), 20 + }; 13 21 14 22 const engine = { 15 23 audio: await applet<AudioEngine.State>("../../engine/audio"), ··· 21 29 queue: await applet("../../orchestrator/single-queue"), 22 30 }; 23 31 32 + const processor = { 33 + artwork: await applet("../../processor/artwork"), 34 + }; 35 + 24 36 const ui = {}; 25 37 26 38 //////////////////////////////////////////// ··· 36 48 //////////////////////////////////////////// 37 49 38 50 // TODO 51 + 52 + // 🚀 53 + 54 + waitUntilAppletData(configurator.output, (d) => d?.tracks?.state === "loaded").then(async () => { 55 + console.log("🚀", configurator.output.data); 56 + 57 + const items = await configurator.output.data.tracks.collection.reduce( 58 + async ( 59 + promise: Promise<{ cacheId: string; urls: Urls }[]>, 60 + track, 61 + ): Promise<{ cacheId: string; urls: Urls }[]> => { 62 + const acc = await promise; 63 + 64 + const urls = { 65 + get: await configurator.input 66 + .sendAction( 67 + "resolve", 68 + { 69 + method: "GET", 70 + uri: track.uri, 71 + }, 72 + { 73 + timeoutDuration: 60000 * 5, 74 + }, 75 + ) 76 + .then((a) => a.url), 77 + head: await configurator.input 78 + .sendAction( 79 + "resolve", 80 + { 81 + method: "HEAD", 82 + uri: track.uri, 83 + }, 84 + { 85 + timeoutDuration: 60000 * 5, 86 + }, 87 + ) 88 + .then((a) => a.url), 89 + }; 90 + 91 + const cacheId = await crypto.subtle 92 + .digest({ name: "SHA-256" }, new TextEncoder().encode(track.uri)) 93 + .then((a) => Uint8.toString(new Uint8Array(a), "base64")); 94 + 95 + return [ 96 + ...acc, 97 + { 98 + cacheId, 99 + urls, 100 + }, 101 + ]; 102 + }, 103 + Promise.resolve([]), 104 + ); 105 + 106 + processor.artwork.sendAction("supply", items, { 107 + timeoutDuration: 60000 * 120, 108 + }); 109 + });
+1 -1
src/scripts/themes/webamp/index.ts
··· 64 64 "resolve", 65 65 { method: "GET", uri: track.uri }, 66 66 { 67 - timeoutDuration: 60000, 67 + timeoutDuration: 60000 * 5, 68 68 }, 69 69 ); 70 70