Experiment to rebuild Diffuse using web applets.
0
fork

Configure Feed

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

fix: perf issues

+197 -135
+8 -4
src/pages/constituent/blur/artwork-controller/_applet.astro
··· 409 409 engine.audio, 410 410 (data) => 411 411 data.isPlaying && (data.items[engine.queue.data.now?.id ?? Infinity]?.isPlaying ?? false), 412 - (isPlaying) => setTimeout(() => setIsPlaying(isPlaying), 0), 412 + (isPlaying) => setIsPlaying(isPlaying), 413 413 ); 414 414 415 415 reactive( 416 416 engine.audio, 417 417 (data) => data.items[engine.queue.data.now?.id ?? Infinity]?.progress ?? 0, 418 - (progress) => setTimeout(() => setProgress(progress), 0), 418 + (progress) => setProgress(progress), 419 419 ); 420 420 421 421 reactive( 422 422 engine.audio, 423 423 (data) => data.volume.default, 424 - (volume) => setTimeout(() => setVolume(volume), 0), 424 + (volume) => setVolume(volume), 425 425 ); 426 426 427 427 //////////////////////////////////////////// ··· 460 460 }, 461 461 ); 462 462 463 - setArtwork(art); 463 + const currTrack = activeTrack(); 464 + const currCacheId = currTrack ? await trackArtworkCacheId(currTrack) : undefined; 465 + if (cacheId === currCacheId) setArtwork(art); 464 466 }, 465 467 ); 466 468 ··· 486 488 487 489 effect(() => { 488 490 const art = artwork(); 491 + 492 + console.log("ART", art[0]); 489 493 490 494 // TODO: Remove existing art? 491 495 if (art.length === 0) {
+3 -3
src/pages/engine/queue/_applet.astro
··· 29 29 // ACTIONS 30 30 //////////////////////////////////////////// 31 31 context.setActionHandler("add", add); 32 - context.setActionHandler("fill", fill); 32 + context.setActionHandler("pool", pool); 33 33 context.setActionHandler("shift", shift); 34 34 context.setActionHandler("unshift", unshift); 35 35 ··· 37 37 context.data = await worker.call.add(context.data, items); 38 38 } 39 39 40 - async function fill(availableItems: Track[]) { 41 - context.data = await worker.call.fill(context.data, availableItems); 40 + async function pool(items: Track[]) { 41 + context.data = await worker.call.pool(context.data, items); 42 42 } 43 43 44 44 async function shift() {
+24 -11
src/pages/engine/queue/_manifest.json
··· 5 5 "actions": { 6 6 "add": { 7 7 "title": "Add", 8 - "description": "Add items to the queue.", 8 + "description": "Add tracks to the queue.", 9 9 "params_schema": { 10 10 "type": "array", 11 + "description": "Array of tracks", 11 12 "items": { 12 - "anyOf": [ 13 - { 14 - "type": "object", 15 - "properties": { 16 - "id": { "type": "string" }, 17 - "uri": { "type": "string" } 18 - }, 19 - "required": ["id", "uri"] 20 - } 21 - ] 13 + "type": "object", 14 + "properties": { 15 + "id": { "type": "string" }, 16 + "uri": { "type": "string" } 17 + }, 18 + "required": ["id", "uri"] 19 + } 20 + } 21 + }, 22 + "pool": { 23 + "title": "Pool", 24 + "description": "Set the queue pool.", 25 + "params_schema": { 26 + "type": "array", 27 + "description": "Array of tracks", 28 + "items": { 29 + "type": "object", 30 + "properties": { 31 + "id": { "type": "string" }, 32 + "uri": { "type": "string" } 33 + }, 34 + "required": ["id", "uri"] 22 35 } 23 36 } 24 37 },
+3 -1
src/pages/orchestrator/queue-tracks/_applet.astro
··· 40 40 { timeoutDuration: 60000 * 5 }, 41 41 ); 42 42 43 + // Available tracks 43 44 const tracks = Object.values(groups).reduce((acc: Track[], value) => { 44 45 if (value.available === false) return acc; 45 46 return [...acc, ...value.tracks]; 46 47 }, []); 47 48 48 - engine.queue.sendAction("fill", tracks); 49 + // Clear 50 + engine.queue.sendAction("pool", tracks); 49 51 }, 50 52 ); 51 53 });
+8 -7
src/scripts/applet/common.ts
··· 292 292 export function reactive<D, T>( 293 293 applet: Applet<D> | AppletScope<D>, 294 294 dataFn: (data: D) => T, 295 - effectFn: (t: T, setter: (t: T) => void) => void, 295 + effectFn: (t: T) => void, 296 296 ) { 297 - const [getter, setter] = signal(dataFn(applet.data)); 298 - 299 - effect(() => { 300 - effectFn(getter(), setter); 301 - }); 297 + let value = dataFn(applet.data); 298 + effectFn(value); 302 299 303 300 applet.addEventListener("data", (event: AppletEvent) => { 304 - setter(dataFn(event.data)); 301 + const newData = dataFn(event.data); 302 + if (newData !== value) { 303 + value = newData; 304 + effectFn(value); 305 + } 305 306 }); 306 307 } 307 308
+14 -10
src/scripts/common.ts
··· 46 46 return xxh32(JSON.stringify(value)); 47 47 } 48 48 49 - export function endpoint<T extends Record<string, any>>(port: MessagePort) { 50 - const e = createEndpoint<T>(port); 51 - if ("start" in port) port.start(); 52 - else 53 - console.warn("Missing `start` function in port, probably using a regular worker:", port as any); 49 + export function endpoint<T extends Record<string, any>>(ini: MessageEndpoint) { 50 + const e = createEndpoint<T>(ini); 51 + if ("start" in ini && typeof ini.start === "function") ini.start(); 54 52 return e; 55 53 } 56 54 57 55 export function expose<T extends Record<string, any>>(actions: T): T { 58 - (self as unknown as SharedWorkerGlobalScope).onconnect = (event: MessageEvent) => { 59 - const port = event.ports[0]; 60 - createEndpoint<T>(port).expose(actions); 61 - port.start(); 62 - }; 56 + if (globalThis.SharedWorkerGlobalScope && self instanceof SharedWorkerGlobalScope) { 57 + self.onconnect = (event: MessageEvent) => { 58 + const port = event.ports[0]; 59 + createEndpoint<T>(port).expose(actions); 60 + port.start(); 61 + }; 62 + 63 + (self as any).connected = true; 64 + } else { 65 + createEndpoint<T>(self).expose(actions); 66 + } 63 67 64 68 return actions; 65 69 }
+36 -5
src/scripts/engine/queue/worker.ts
··· 3 3 import { expose } from "@scripts/common.ts"; 4 4 5 5 //////////////////////////////////////////// 6 + // STATE 7 + //////////////////////////////////////////// 8 + 9 + const QUEUE_SIZE = 25; 10 + 11 + const internal: { pool: Track[] } = { 12 + pool: [], 13 + }; 14 + 15 + //////////////////////////////////////////// 6 16 // ACTIONS 7 17 //////////////////////////////////////////// 8 18 const actions = expose({ 9 19 add, 10 - fill, 20 + pool, 11 21 shift, 12 22 unshift, 13 23 }); ··· 20 30 return { ...state, future: [...state.future, ...items] }; 21 31 } 22 32 23 - // TODO: Shuffle, limit track amount, etc. 24 - function fill(state: State, availableItems: Track[]): State { 25 - state = add(state, availableItems); 33 + function pool(state: State, tracks: Track[]): State { 34 + internal.pool = tracks; 35 + 36 + // TODO: If the pool changes, only remove non-existing tracks 37 + // instead of resetting the whole future queue. 38 + // 39 + // What about past queue items? 40 + 41 + state = fill({ ...state, future: [] }); 26 42 27 43 // Automatically insert track if there isn't any 28 44 if (!state.now) return shift(state); ··· 34 50 const future = state.future.slice(1); 35 51 const past = state.now ? [...state.past, state.now] : state.past; 36 52 37 - return { past, now, future }; 53 + return fill({ past, now, future }); 38 54 } 39 55 40 56 function unshift(state: State): State { ··· 47 63 48 64 return { past, now, future }; 49 65 } 66 + 67 + // 🛠️ 68 + 69 + // TODO: Shuffle, limit track amount, etc. 70 + function fill(state: State): State { 71 + return state.future.length < QUEUE_SIZE 72 + ? add( 73 + state, 74 + internal.pool.slice( 75 + state.past.length, 76 + state.past.length + (QUEUE_SIZE - state.future.length), 77 + ), 78 + ) 79 + : state; 80 + }
+34 -19
src/scripts/processor/artwork/worker.ts
··· 1 1 import type { IPicture } from "music-metadata"; 2 2 import * as IDB from "idb-keyval"; 3 3 4 - import type { Actions as MetadataActions } from "../metadata/worker"; 5 4 import type { Artwork, ArtworkRequest } from "./types"; 6 5 import { expose } from "@scripts/common"; 7 6 import { IDB_ARTWORK_PREFIX } from "./constants"; 8 - import { createEndpoint } from "@remote-ui/rpc"; 7 + import { musicMetadataTags } from "../metadata/common"; 9 8 10 9 // State 11 10 let queue: ArtworkRequest[] = []; 12 11 13 - // Metadata worker 14 - const metadataWorker = createEndpoint<MetadataActions>( 15 - new Worker(new URL("../metadata/worker", import.meta.url), { type: "module" }), 16 - ); 17 - 18 12 //////////////////////////////////////////// 19 13 // ACTIONS 20 14 //////////////////////////////////////////// ··· 28 22 // Actions 29 23 30 24 function artwork(request: ArtworkRequest) { 25 + console.log("INSERT REQ", request); 31 26 return processRequest(request); 32 27 } 33 28 ··· 49 44 `https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`, 50 45 ) 51 46 .then((r) => r.json()) 52 - .then((r) => lastFmCover(r.results.albummatches.album)); 47 + .then((r) => lastFmCover(r.results.albummatches.album)) 48 + .catch((err) => { 49 + console.error(err); 50 + return []; 51 + }); 53 52 } 54 53 55 - function lastFmCover(remainingMatches: any[]): Promise<Artwork[]> { 54 + async function lastFmCover(remainingMatches: any[]): Promise<Artwork[]> { 56 55 const album = remainingMatches[0]; 57 56 const url = album ? album.image[album.image.length - 1]["#text"] : null; 58 57 59 58 return url && url !== "" 60 - ? fetch(url) 59 + ? await fetch(url) 61 60 .then((r) => r.blob()) 62 - .then(async (b) => [{ bytes: await b.bytes(), mime: b.type }]) 63 - .catch((_) => lastFmCover(remainingMatches.slice(1))) 64 - : album && lastFmCover(remainingMatches.slice(1)); 61 + .then(async (b) => [ 62 + { bytes: await b.arrayBuffer().then((buf) => new Uint8Array(buf)), mime: b.type }, 63 + ]) 64 + .catch((err) => { 65 + console.error(err); 66 + return lastFmCover(remainingMatches.slice(1)); 67 + }) 68 + : album 69 + ? lastFmCover(remainingMatches.slice(1)) 70 + : []; 65 71 } 66 72 67 73 async function musicBrainz(req: ArtworkRequest): Promise<Artwork[]> { ··· 79 85 80 86 return await fetch(`https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`) 81 87 .then((r) => r.json()) 82 - .then((r) => musicBrainzCover(r.releases)); 88 + .then((r) => musicBrainzCover(r.releases)) 89 + .catch((err) => { 90 + console.error(err); 91 + return []; 92 + }); 83 93 } 84 94 85 95 async function musicBrainzCover(remainingReleases: any[]): Promise<Artwork[]> { ··· 89 99 return await fetch(`https://coverartarchive.org/release/${release.id}/front-500`) 90 100 .then((r) => r.blob()) 91 101 .then(async (b) => { 92 - if (b && b.type.startsWith("image/")) { 93 - return [{ bytes: await b.bytes(), mime: b.type }]; 102 + if (b.type.startsWith("image/")) { 103 + return [{ bytes: await b.arrayBuffer().then((buf) => new Uint8Array(buf)), mime: b.type }]; 94 104 } else { 95 105 return musicBrainzCover(remainingReleases.slice(1)); 96 106 } 97 107 }) 98 - .catch(() => musicBrainzCover(remainingReleases.slice(1))); 108 + .catch((err) => { 109 + console.error(err); 110 + return musicBrainzCover(remainingReleases.slice(1)); 111 + }); 99 112 } 100 113 101 114 async function processRequest(req: ArtworkRequest): Promise<Artwork[]> { 102 115 // Check if already processed 103 116 // TODO: Retry if none was found? 104 117 const cache = await IDB.get(`${IDB_ARTWORK_PREFIX}/${req.cacheId}`); 105 - if (cache) return cache; 118 + if (cache && Array.isArray(cache) && cache.length) return cache; 106 119 107 120 // 🚀 108 121 let art: Artwork[] = []; 109 122 110 123 // Get metadata + possible artwork from file metadata 111 - const meta = await metadataWorker.call.supply({ ...req, includeArtwork: true }); 124 + console.log("ART REQ", req); 125 + const meta = await musicMetadataTags({ ...req, includeArtwork: true }); 126 + console.log("ART META", meta); 112 127 if (!req.tags) req.tags = meta.tags; 113 128 114 129 // Add artwork from metadata
+66
src/scripts/processor/metadata/common.ts
··· 1 + import { parseBlob, parseFromTokenizer, parseWebStream } from "music-metadata"; 2 + import { contentType } from "@std/media-types"; 3 + import * as URI from "uri-js"; 4 + import * as HTTP_TOKENIZER from "@tokenizer/http"; 5 + import * as RANGE_TOKENIZER from "@tokenizer/range"; 6 + 7 + import type { TrackStats, TrackTags } from "@applets/core/types"; 8 + import type { Extraction, Urls } from "./types"; 9 + 10 + // 🛠️ 11 + 12 + export async function musicMetadataTags({ 13 + includeArtwork, 14 + mimeType, 15 + stream, 16 + urls, 17 + }: { 18 + includeArtwork?: boolean; 19 + mimeType?: string; 20 + stream?: ReadableStream; 21 + urls?: Urls; 22 + }): Promise<Extraction> { 23 + const uri = urls ? URI.parse(urls.get) : undefined; 24 + const pathParts = uri?.path?.split("/"); 25 + const filename = pathParts?.[pathParts.length - 1]; 26 + 27 + let meta; 28 + 29 + console.log(urls?.get, stream, includeArtwork); 30 + 31 + if (urls?.get.startsWith("blob:")) { 32 + const blob = await fetch(urls.get).then((r) => r.blob()); 33 + meta = await parseBlob(blob, { skipCovers: !includeArtwork }); 34 + } else if (urls) { 35 + const httpClient = new HTTP_TOKENIZER.HttpClient(urls.head, { resolveUrl: false }); 36 + httpClient.resolvedUrl = urls.get; 37 + 38 + const tokenizer = await RANGE_TOKENIZER.tokenizer(httpClient); 39 + 40 + meta = await parseFromTokenizer(tokenizer, { skipCovers: !includeArtwork }); 41 + } else if (stream) { 42 + meta = await parseWebStream(stream, { mimeType }, { skipCovers: !includeArtwork }); 43 + } else { 44 + throw new Error("Missing args, need either some urls or a stream."); 45 + } 46 + 47 + const stats: TrackStats = { 48 + duration: meta.format.duration, 49 + }; 50 + 51 + const tags: TrackTags = { 52 + album: meta.common.album, 53 + artist: meta.common.artist, 54 + disc: { no: meta.common.disk.no || 1, of: meta.common.disk.of ?? undefined }, 55 + genre: Array.isArray(meta.common.genre) ? meta.common.genre[0] : meta.common.genre, 56 + title: meta.common.title || filename || urls?.head || "Unknown", 57 + track: { no: meta.common.track.no || 1, of: meta.common.track.of ?? undefined }, 58 + year: meta.common.year, 59 + }; 60 + 61 + return { 62 + artwork: includeArtwork ? meta.common.picture : undefined, 63 + stats, 64 + tags, 65 + }; 66 + }
+1 -75
src/scripts/processor/metadata/worker.ts
··· 1 - import { parseFromTokenizer, parseWebStream } from "music-metadata"; 2 - import { contentType } from "@std/media-types"; 3 - import * as URI from "uri-js"; 4 - import * as HTTP_TOKENIZER from "@tokenizer/http"; 5 - import * as RANGE_TOKENIZER from "@tokenizer/range"; 6 - 7 - import type { TrackStats, TrackTags } from "@applets/core/types"; 8 1 import type { Extraction, Urls } from "./types.d.ts"; 9 2 import { expose } from "@scripts/common"; 3 + import { musicMetadataTags } from "./common.ts"; 10 4 11 5 //////////////////////////////////////////// 12 6 // ACTIONS ··· 37 31 // Fin 38 32 return response; 39 33 } 40 - 41 - //////////////////////////////////////////// 42 - // 🛠️ 43 - //////////////////////////////////////////// 44 - async function musicMetadataTags({ 45 - includeArtwork, 46 - mimeType, 47 - stream, 48 - urls, 49 - }: { 50 - includeArtwork?: boolean; 51 - mimeType?: string; 52 - stream?: ReadableStream; 53 - urls?: Urls; 54 - }): Promise<Extraction> { 55 - const uri = urls ? URI.parse(urls.get) : undefined; 56 - const pathParts = uri?.path?.split("/"); 57 - const filename = pathParts?.[pathParts.length - 1]; 58 - 59 - let meta; 60 - 61 - if (urls?.get.startsWith("blob:")) { 62 - const mimeFallback = filename?.includes(".") 63 - ? contentType(filename.split(".").reverse()[0]) 64 - : undefined; 65 - 66 - const resp = await fetch(urls.get); 67 - const stream = resp.body; 68 - 69 - if (!stream) return {}; 70 - meta = await parseWebStream( 71 - stream, 72 - { mimeType: mimeType || mimeFallback }, 73 - { skipCovers: !includeArtwork }, 74 - ); 75 - } else if (urls) { 76 - const httpClient = new HTTP_TOKENIZER.HttpClient(urls.head, { resolveUrl: false }); 77 - httpClient.resolvedUrl = urls.get; 78 - 79 - const tokenizer = await RANGE_TOKENIZER.tokenizer(httpClient); 80 - 81 - meta = await parseFromTokenizer(tokenizer, { skipCovers: !includeArtwork }); 82 - } else if (stream) { 83 - meta = await parseWebStream(stream, { mimeType }, { skipCovers: !includeArtwork }); 84 - } else { 85 - throw new Error("Missing args, need either some urls or a stream."); 86 - } 87 - 88 - const stats: TrackStats = { 89 - duration: meta.format.duration, 90 - }; 91 - 92 - const tags: TrackTags = { 93 - album: meta.common.album, 94 - artist: meta.common.artist, 95 - disc: { no: meta.common.disk.no || 1, of: meta.common.disk.of ?? undefined }, 96 - genre: Array.isArray(meta.common.genre) ? meta.common.genre[0] : meta.common.genre, 97 - title: meta.common.title || filename || urls?.head || "Unknown", 98 - track: { no: meta.common.track.no || 1, of: meta.common.track.of ?? undefined }, 99 - year: meta.common.year, 100 - }; 101 - 102 - return { 103 - artwork: includeArtwork ? meta.common.picture : undefined, 104 - stats, 105 - tags, 106 - }; 107 - }