From d29e4e54d8edeec2f2313e3882d2334d45aba943 Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Tue, 1 Jul 2025 15:01:58 +0200 Subject: [PATCH] fix: perf issues --- .../blur/artwork-controller/_applet.astro | 12 ++- src/pages/engine/queue/_applet.astro | 6 +- src/pages/engine/queue/_manifest.json | 35 ++++++--- .../orchestrator/queue-tracks/_applet.astro | 4 +- src/scripts/applet/common.ts | 15 ++-- src/scripts/common.ts | 24 +++--- src/scripts/engine/queue/worker.ts | 41 ++++++++-- src/scripts/processor/artwork/worker.ts | 53 ++++++++----- src/scripts/processor/metadata/common.ts | 66 ++++++++++++++++ src/scripts/processor/metadata/worker.ts | 76 +------------------ 10 files changed, 197 insertions(+), 135 deletions(-) create mode 100644 src/scripts/processor/metadata/common.ts diff --git a/src/pages/constituent/blur/artwork-controller/_applet.astro b/src/pages/constituent/blur/artwork-controller/_applet.astro index 4e22db1..f1263b1 100644 --- a/src/pages/constituent/blur/artwork-controller/_applet.astro +++ b/src/pages/constituent/blur/artwork-controller/_applet.astro @@ -409,19 +409,19 @@ import "@styles/diffuse/fonts.css"; engine.audio, (data) => data.isPlaying && (data.items[engine.queue.data.now?.id ?? Infinity]?.isPlaying ?? false), - (isPlaying) => setTimeout(() => setIsPlaying(isPlaying), 0), + (isPlaying) => setIsPlaying(isPlaying), ); reactive( engine.audio, (data) => data.items[engine.queue.data.now?.id ?? Infinity]?.progress ?? 0, - (progress) => setTimeout(() => setProgress(progress), 0), + (progress) => setProgress(progress), ); reactive( engine.audio, (data) => data.volume.default, - (volume) => setTimeout(() => setVolume(volume), 0), + (volume) => setVolume(volume), ); //////////////////////////////////////////// @@ -460,7 +460,9 @@ import "@styles/diffuse/fonts.css"; }, ); - setArtwork(art); + const currTrack = activeTrack(); + const currCacheId = currTrack ? await trackArtworkCacheId(currTrack) : undefined; + if (cacheId === currCacheId) setArtwork(art); }, ); @@ -487,6 +489,8 @@ import "@styles/diffuse/fonts.css"; effect(() => { const art = artwork(); + console.log("ART", art[0]); + // TODO: Remove existing art? if (art.length === 0) { return; diff --git a/src/pages/engine/queue/_applet.astro b/src/pages/engine/queue/_applet.astro index 815692a..41f5f6c 100644 --- a/src/pages/engine/queue/_applet.astro +++ b/src/pages/engine/queue/_applet.astro @@ -29,7 +29,7 @@ // ACTIONS //////////////////////////////////////////// context.setActionHandler("add", add); - context.setActionHandler("fill", fill); + context.setActionHandler("pool", pool); context.setActionHandler("shift", shift); context.setActionHandler("unshift", unshift); @@ -37,8 +37,8 @@ context.data = await worker.call.add(context.data, items); } - async function fill(availableItems: Track[]) { - context.data = await worker.call.fill(context.data, availableItems); + async function pool(items: Track[]) { + context.data = await worker.call.pool(context.data, items); } async function shift() { diff --git a/src/pages/engine/queue/_manifest.json b/src/pages/engine/queue/_manifest.json index e7aacf9..8c64b83 100644 --- a/src/pages/engine/queue/_manifest.json +++ b/src/pages/engine/queue/_manifest.json @@ -5,20 +5,33 @@ "actions": { "add": { "title": "Add", - "description": "Add items to the queue.", + "description": "Add tracks to the queue.", "params_schema": { "type": "array", + "description": "Array of tracks", "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { "type": "string" }, - "uri": { "type": "string" } - }, - "required": ["id", "uri"] - } - ] + "type": "object", + "properties": { + "id": { "type": "string" }, + "uri": { "type": "string" } + }, + "required": ["id", "uri"] + } + } + }, + "pool": { + "title": "Pool", + "description": "Set the queue pool.", + "params_schema": { + "type": "array", + "description": "Array of tracks", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "uri": { "type": "string" } + }, + "required": ["id", "uri"] } } }, diff --git a/src/pages/orchestrator/queue-tracks/_applet.astro b/src/pages/orchestrator/queue-tracks/_applet.astro index 89c3455..bb0b4cf 100644 --- a/src/pages/orchestrator/queue-tracks/_applet.astro +++ b/src/pages/orchestrator/queue-tracks/_applet.astro @@ -40,12 +40,14 @@ { timeoutDuration: 60000 * 5 }, ); + // Available tracks const tracks = Object.values(groups).reduce((acc: Track[], value) => { if (value.available === false) return acc; return [...acc, ...value.tracks]; }, []); - engine.queue.sendAction("fill", tracks); + // Clear + engine.queue.sendAction("pool", tracks); }, ); }); diff --git a/src/scripts/applet/common.ts b/src/scripts/applet/common.ts index 3f197c9..c84f676 100644 --- a/src/scripts/applet/common.ts +++ b/src/scripts/applet/common.ts @@ -292,16 +292,17 @@ export function register(): BroadcastedApplet { export function reactive( applet: Applet | AppletScope, dataFn: (data: D) => T, - effectFn: (t: T, setter: (t: T) => void) => void, + effectFn: (t: T) => void, ) { - const [getter, setter] = signal(dataFn(applet.data)); - - effect(() => { - effectFn(getter(), setter); - }); + let value = dataFn(applet.data); + effectFn(value); applet.addEventListener("data", (event: AppletEvent) => { - setter(dataFn(event.data)); + const newData = dataFn(event.data); + if (newData !== value) { + value = newData; + effectFn(value); + } }); } diff --git a/src/scripts/common.ts b/src/scripts/common.ts index 3118112..48fef03 100644 --- a/src/scripts/common.ts +++ b/src/scripts/common.ts @@ -46,20 +46,24 @@ export function comparable(value: unknown) { return xxh32(JSON.stringify(value)); } -export function endpoint>(port: MessagePort) { - const e = createEndpoint(port); - if ("start" in port) port.start(); - else - console.warn("Missing `start` function in port, probably using a regular worker:", port as any); +export function endpoint>(ini: MessageEndpoint) { + const e = createEndpoint(ini); + if ("start" in ini && typeof ini.start === "function") ini.start(); return e; } export function expose>(actions: T): T { - (self as unknown as SharedWorkerGlobalScope).onconnect = (event: MessageEvent) => { - const port = event.ports[0]; - createEndpoint(port).expose(actions); - port.start(); - }; + if (globalThis.SharedWorkerGlobalScope && self instanceof SharedWorkerGlobalScope) { + self.onconnect = (event: MessageEvent) => { + const port = event.ports[0]; + createEndpoint(port).expose(actions); + port.start(); + }; + + (self as any).connected = true; + } else { + createEndpoint(self).expose(actions); + } return actions; } diff --git a/src/scripts/engine/queue/worker.ts b/src/scripts/engine/queue/worker.ts index 2bfd2bb..fec90ad 100644 --- a/src/scripts/engine/queue/worker.ts +++ b/src/scripts/engine/queue/worker.ts @@ -2,12 +2,22 @@ import type { Track } from "@applets/core/types.js"; import type { State } from "./types"; import { expose } from "@scripts/common.ts"; +//////////////////////////////////////////// +// STATE +//////////////////////////////////////////// + +const QUEUE_SIZE = 25; + +const internal: { pool: Track[] } = { + pool: [], +}; + //////////////////////////////////////////// // ACTIONS //////////////////////////////////////////// const actions = expose({ add, - fill, + pool, shift, unshift, }); @@ -20,9 +30,15 @@ function add(state: State, items: Track[]): State { return { ...state, future: [...state.future, ...items] }; } -// TODO: Shuffle, limit track amount, etc. -function fill(state: State, availableItems: Track[]): State { - state = add(state, availableItems); +function pool(state: State, tracks: Track[]): State { + internal.pool = tracks; + + // TODO: If the pool changes, only remove non-existing tracks + // instead of resetting the whole future queue. + // + // What about past queue items? + + state = fill({ ...state, future: [] }); // Automatically insert track if there isn't any if (!state.now) return shift(state); @@ -34,7 +50,7 @@ function shift(state: State): State { const future = state.future.slice(1); const past = state.now ? [...state.past, state.now] : state.past; - return { past, now, future }; + return fill({ past, now, future }); } function unshift(state: State): State { @@ -47,3 +63,18 @@ function unshift(state: State): State { return { past, now, future }; } + +// 🛠️ + +// TODO: Shuffle, limit track amount, etc. +function fill(state: State): State { + return state.future.length < QUEUE_SIZE + ? add( + state, + internal.pool.slice( + state.past.length, + state.past.length + (QUEUE_SIZE - state.future.length), + ), + ) + : state; +} diff --git a/src/scripts/processor/artwork/worker.ts b/src/scripts/processor/artwork/worker.ts index 7f9ce4c..20c77e9 100644 --- a/src/scripts/processor/artwork/worker.ts +++ b/src/scripts/processor/artwork/worker.ts @@ -1,20 +1,14 @@ import type { IPicture } from "music-metadata"; import * as IDB from "idb-keyval"; -import type { Actions as MetadataActions } from "../metadata/worker"; import type { Artwork, ArtworkRequest } from "./types"; import { expose } from "@scripts/common"; import { IDB_ARTWORK_PREFIX } from "./constants"; -import { createEndpoint } from "@remote-ui/rpc"; +import { musicMetadataTags } from "../metadata/common"; // State let queue: ArtworkRequest[] = []; -// Metadata worker -const metadataWorker = createEndpoint( - new Worker(new URL("../metadata/worker", import.meta.url), { type: "module" }), -); - //////////////////////////////////////////// // ACTIONS //////////////////////////////////////////// @@ -28,6 +22,7 @@ export type Actions = typeof actions; // Actions function artwork(request: ArtworkRequest) { + console.log("INSERT REQ", request); return processRequest(request); } @@ -49,19 +44,30 @@ async function lastFm(req: ArtworkRequest): Promise { `https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`, ) .then((r) => r.json()) - .then((r) => lastFmCover(r.results.albummatches.album)); + .then((r) => lastFmCover(r.results.albummatches.album)) + .catch((err) => { + console.error(err); + return []; + }); } -function lastFmCover(remainingMatches: any[]): Promise { +async function lastFmCover(remainingMatches: any[]): Promise { const album = remainingMatches[0]; const url = album ? album.image[album.image.length - 1]["#text"] : null; return url && url !== "" - ? fetch(url) + ? await fetch(url) .then((r) => r.blob()) - .then(async (b) => [{ bytes: await b.bytes(), mime: b.type }]) - .catch((_) => lastFmCover(remainingMatches.slice(1))) - : album && lastFmCover(remainingMatches.slice(1)); + .then(async (b) => [ + { bytes: await b.arrayBuffer().then((buf) => new Uint8Array(buf)), mime: b.type }, + ]) + .catch((err) => { + console.error(err); + return lastFmCover(remainingMatches.slice(1)); + }) + : album + ? lastFmCover(remainingMatches.slice(1)) + : []; } async function musicBrainz(req: ArtworkRequest): Promise { @@ -79,7 +85,11 @@ async function musicBrainz(req: ArtworkRequest): Promise { return await fetch(`https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`) .then((r) => r.json()) - .then((r) => musicBrainzCover(r.releases)); + .then((r) => musicBrainzCover(r.releases)) + .catch((err) => { + console.error(err); + return []; + }); } async function musicBrainzCover(remainingReleases: any[]): Promise { @@ -89,26 +99,31 @@ async function musicBrainzCover(remainingReleases: any[]): Promise { return await fetch(`https://coverartarchive.org/release/${release.id}/front-500`) .then((r) => r.blob()) .then(async (b) => { - if (b && b.type.startsWith("image/")) { - return [{ bytes: await b.bytes(), mime: b.type }]; + if (b.type.startsWith("image/")) { + return [{ bytes: await b.arrayBuffer().then((buf) => new Uint8Array(buf)), mime: b.type }]; } else { return musicBrainzCover(remainingReleases.slice(1)); } }) - .catch(() => musicBrainzCover(remainingReleases.slice(1))); + .catch((err) => { + console.error(err); + return musicBrainzCover(remainingReleases.slice(1)); + }); } async function processRequest(req: ArtworkRequest): Promise { // Check if already processed // TODO: Retry if none was found? const cache = await IDB.get(`${IDB_ARTWORK_PREFIX}/${req.cacheId}`); - if (cache) return cache; + if (cache && Array.isArray(cache) && cache.length) return cache; // 🚀 let art: Artwork[] = []; // Get metadata + possible artwork from file metadata - const meta = await metadataWorker.call.supply({ ...req, includeArtwork: true }); + console.log("ART REQ", req); + const meta = await musicMetadataTags({ ...req, includeArtwork: true }); + console.log("ART META", meta); if (!req.tags) req.tags = meta.tags; // Add artwork from metadata diff --git a/src/scripts/processor/metadata/common.ts b/src/scripts/processor/metadata/common.ts new file mode 100644 index 0000000..f45a320 --- /dev/null +++ b/src/scripts/processor/metadata/common.ts @@ -0,0 +1,66 @@ +import { parseBlob, parseFromTokenizer, parseWebStream } from "music-metadata"; +import { contentType } from "@std/media-types"; +import * as URI from "uri-js"; +import * as HTTP_TOKENIZER from "@tokenizer/http"; +import * as RANGE_TOKENIZER from "@tokenizer/range"; + +import type { TrackStats, TrackTags } from "@applets/core/types"; +import type { Extraction, Urls } from "./types"; + +// 🛠️ + +export async function musicMetadataTags({ + includeArtwork, + mimeType, + stream, + urls, +}: { + includeArtwork?: boolean; + mimeType?: string; + stream?: ReadableStream; + urls?: Urls; +}): Promise { + const uri = urls ? URI.parse(urls.get) : undefined; + const pathParts = uri?.path?.split("/"); + const filename = pathParts?.[pathParts.length - 1]; + + let meta; + + console.log(urls?.get, stream, includeArtwork); + + if (urls?.get.startsWith("blob:")) { + const blob = await fetch(urls.get).then((r) => r.blob()); + meta = await parseBlob(blob, { skipCovers: !includeArtwork }); + } else if (urls) { + const httpClient = new HTTP_TOKENIZER.HttpClient(urls.head, { resolveUrl: false }); + httpClient.resolvedUrl = urls.get; + + const tokenizer = await RANGE_TOKENIZER.tokenizer(httpClient); + + meta = await parseFromTokenizer(tokenizer, { skipCovers: !includeArtwork }); + } else if (stream) { + meta = await parseWebStream(stream, { mimeType }, { skipCovers: !includeArtwork }); + } else { + throw new Error("Missing args, need either some urls or a stream."); + } + + const stats: TrackStats = { + duration: meta.format.duration, + }; + + const tags: TrackTags = { + album: meta.common.album, + artist: meta.common.artist, + disc: { no: meta.common.disk.no || 1, of: meta.common.disk.of ?? undefined }, + genre: Array.isArray(meta.common.genre) ? meta.common.genre[0] : meta.common.genre, + title: meta.common.title || filename || urls?.head || "Unknown", + track: { no: meta.common.track.no || 1, of: meta.common.track.of ?? undefined }, + year: meta.common.year, + }; + + return { + artwork: includeArtwork ? meta.common.picture : undefined, + stats, + tags, + }; +} diff --git a/src/scripts/processor/metadata/worker.ts b/src/scripts/processor/metadata/worker.ts index 8591430..35132d3 100644 --- a/src/scripts/processor/metadata/worker.ts +++ b/src/scripts/processor/metadata/worker.ts @@ -1,12 +1,6 @@ -import { parseFromTokenizer, parseWebStream } from "music-metadata"; -import { contentType } from "@std/media-types"; -import * as URI from "uri-js"; -import * as HTTP_TOKENIZER from "@tokenizer/http"; -import * as RANGE_TOKENIZER from "@tokenizer/range"; - -import type { TrackStats, TrackTags } from "@applets/core/types"; import type { Extraction, Urls } from "./types.d.ts"; import { expose } from "@scripts/common"; +import { musicMetadataTags } from "./common.ts"; //////////////////////////////////////////// // ACTIONS @@ -37,71 +31,3 @@ async function supply(args: { // Fin return response; } - -//////////////////////////////////////////// -// 🛠️ -//////////////////////////////////////////// -async function musicMetadataTags({ - includeArtwork, - mimeType, - stream, - urls, -}: { - includeArtwork?: boolean; - mimeType?: string; - stream?: ReadableStream; - urls?: Urls; -}): Promise { - const uri = urls ? URI.parse(urls.get) : undefined; - const pathParts = uri?.path?.split("/"); - const filename = pathParts?.[pathParts.length - 1]; - - let meta; - - if (urls?.get.startsWith("blob:")) { - const mimeFallback = filename?.includes(".") - ? contentType(filename.split(".").reverse()[0]) - : undefined; - - const resp = await fetch(urls.get); - const stream = resp.body; - - if (!stream) return {}; - meta = await parseWebStream( - stream, - { mimeType: mimeType || mimeFallback }, - { skipCovers: !includeArtwork }, - ); - } else if (urls) { - const httpClient = new HTTP_TOKENIZER.HttpClient(urls.head, { resolveUrl: false }); - httpClient.resolvedUrl = urls.get; - - const tokenizer = await RANGE_TOKENIZER.tokenizer(httpClient); - - meta = await parseFromTokenizer(tokenizer, { skipCovers: !includeArtwork }); - } else if (stream) { - meta = await parseWebStream(stream, { mimeType }, { skipCovers: !includeArtwork }); - } else { - throw new Error("Missing args, need either some urls or a stream."); - } - - const stats: TrackStats = { - duration: meta.format.duration, - }; - - const tags: TrackTags = { - album: meta.common.album, - artist: meta.common.artist, - disc: { no: meta.common.disk.no || 1, of: meta.common.disk.of ?? undefined }, - genre: Array.isArray(meta.common.genre) ? meta.common.genre[0] : meta.common.genre, - title: meta.common.title || filename || urls?.head || "Unknown", - track: { no: meta.common.track.no || 1, of: meta.common.track.of ?? undefined }, - year: meta.common.year, - }; - - return { - artwork: includeArtwork ? meta.common.picture : undefined, - stats, - tags, - }; -} -- 2.51.2