From 634bf202cfc6ad9f50264df73c8210b56ef1c203 Mon Sep 17 00:00:00 2001
From: Steven Vandevelde
Date: Sun, 29 Mar 2026 19:03:50 +0200
Subject: [PATCH] feat: make artwork extendable
---
_config.ts | 1 +
src/_data/facets.json | 7 +
src/build.vto | 2 +-
src/common/facets/constants.js | 1 +
src/common/foundation.js | 43 ++-
.../artwork/audio-metadata/element.js | 61 ++++
.../artwork/audio-metadata/worker.js | 59 ++++
.../artwork => artwork/last.fm}/element.js | 19 +-
src/components/artwork/last.fm/worker.js | 59 ++++
src/components/artwork/musicbrainz/element.js | 38 +++
src/components/artwork/musicbrainz/worker.js | 132 ++++++++
src/components/artwork/types.d.ts | 9 +
.../configurator/artwork/element.js | 53 ++++
.../configurator/artwork/types.d.ts | 3 +
src/components/configurator/artwork/worker.js | 39 +++
src/components/input/https/worker.js | 6 +
.../orchestrator/artwork/element.js | 62 ++++
.../orchestrator/artwork/types.d.ts | 4 +
src/components/orchestrator/artwork/worker.js | 105 ++++++
.../orchestrator/media-session/element.js | 40 ++-
src/components/processor/artwork/constants.js | 2 -
src/components/processor/artwork/types.d.ts | 26 --
src/components/processor/artwork/worker.js | 300 ------------------
src/elements.vto | 29 +-
src/facets/data/artwork-bundle/index.html | 1 +
.../data/artwork-bundle/index.inline.js | 59 ++++
src/site.webmanifest | 4 +-
src/themes/blur/artwork-controller/element.js | 96 +++---
.../artwork-controller/facet/index.inline.js | 4 +-
src/themes/winamp/facet/index.html | 5 +
.../components/artwork/audio-metadata/test.ts | 41 +++
tests/components/configurator/artwork/test.ts | 52 +++
tests/components/orchestrator/artwork/test.ts | 87 +++++
tests/components/processor/artwork/test.ts | 147 ---------
34 files changed, 1023 insertions(+), 573 deletions(-)
create mode 100644 src/components/artwork/audio-metadata/element.js
create mode 100644 src/components/artwork/audio-metadata/worker.js
rename src/components/{processor/artwork => artwork/last.fm}/element.js (54%)
create mode 100644 src/components/artwork/last.fm/worker.js
create mode 100644 src/components/artwork/musicbrainz/element.js
create mode 100644 src/components/artwork/musicbrainz/worker.js
create mode 100644 src/components/artwork/types.d.ts
create mode 100644 src/components/configurator/artwork/element.js
create mode 100644 src/components/configurator/artwork/types.d.ts
create mode 100644 src/components/configurator/artwork/worker.js
create mode 100644 src/components/orchestrator/artwork/element.js
create mode 100644 src/components/orchestrator/artwork/types.d.ts
create mode 100644 src/components/orchestrator/artwork/worker.js
delete mode 100644 src/components/processor/artwork/constants.js
delete mode 100644 src/components/processor/artwork/types.d.ts
delete mode 100644 src/components/processor/artwork/worker.js
create mode 100644 src/facets/data/artwork-bundle/index.html
create mode 100644 src/facets/data/artwork-bundle/index.inline.js
create mode 100644 tests/components/artwork/audio-metadata/test.ts
create mode 100644 tests/components/configurator/artwork/test.ts
create mode 100644 tests/components/orchestrator/artwork/test.ts
delete mode 100644 tests/components/processor/artwork/test.ts
diff --git a/_config.ts b/_config.ts
index 86e82532..02dc9422 100644
--- a/_config.ts
+++ b/_config.ts
@@ -275,6 +275,7 @@ for (
site.add([".html"]);
site.add([".json"]);
+site.add([".webmanifest"]);
site.use(brotli());
site.use(sourceMaps());
diff --git a/src/_data/facets.json b/src/_data/facets.json
index 4ad2c44b..6db90399 100644
--- a/src/_data/facets.json
+++ b/src/_data/facets.json
@@ -78,6 +78,13 @@
"category": "Data",
"desc": "Export all data as a JSON snapshot, or restore from a previously exported file."
},
+ {
+ "url": "facets/data/artwork-bundle/index.html",
+ "title": "Default Artwork Bundle",
+ "kind": "prelude",
+ "category": "Data",
+ "desc": "The default setup for track artwork retrieval. Adds support for: embedded audio metadata, Last.fm, and MusicBrainz."
+ },
{
"url": "facets/data/input-bundle/index.html",
"title": "Default Input Bundle",
diff --git a/src/build.vto b/src/build.vto
index f114ad70..9c7521f1 100644
--- a/src/build.vto
+++ b/src/build.vto
@@ -88,7 +88,7 @@ examples:
{{ echo -}}await foundation.orchestrator.scrobbleAudio(){{- /echo }}
{{ echo -}}await foundation.orchestrator.sources(){{- /echo }}
-{{ echo -}}await foundation.processor.artwork(){{- /echo }}
+{{ echo -}}await foundation.orchestrator.artwork(){{- /echo }}
{{ echo -}}await foundation.processor.metadata(){{- /echo }}
{{ echo -}}await foundation.processor.search(){{- /echo -}}
diff --git a/src/common/facets/constants.js b/src/common/facets/constants.js
index c977d7b6..1568d3bb 100644
--- a/src/common/facets/constants.js
+++ b/src/common/facets/constants.js
@@ -6,6 +6,7 @@ export const STARTING_SET_URIS = [
"themes/blur/artwork-controller/facet/index.html",
// PRELUDES
+ "facets/data/artwork-bundle/index.html",
"facets/data/input-bundle/index.html",
"facets/data/output-bundle/index.html",
"facets/data/process-tracks/prelude/index.html",
diff --git a/src/common/foundation.js b/src/common/foundation.js
index 65c35c0e..6de32b33 100644
--- a/src/common/foundation.js
+++ b/src/common/foundation.js
@@ -14,6 +14,9 @@ export const GROUP = url.searchParams.get("group") ?? "facets";
*/
const signals = {
configurator: {
+ artwork: signal(
+ /** @type {import("~/components/configurator/artwork/element.js").CLASS | null} */ (null),
+ ),
input: signal(
/** @type {import("~/components/configurator/input/element.js").CLASS | null} */ (null),
),
@@ -38,6 +41,9 @@ const signals = {
},
orchestrator: {
+ artwork: signal(
+ /** @type {import("~/components/orchestrator/artwork/element.js").CLASS | null} */ (null),
+ ),
autoQueue: signal(
/** @type {import("~/components/orchestrator/auto-queue/element.js").CLASS | null} */ (null),
),
@@ -71,9 +77,6 @@ const signals = {
},
processor: {
- artwork: signal(
- /** @type {import("~/components/processor/artwork/element.js").CLASS | null} */ (null),
- ),
metadata: signal(
/** @type {import("~/components/processor/metadata/element.js").CLASS | null} */ (null),
),
@@ -91,6 +94,7 @@ export const config = {
// Elements
configurator: {
+ artwork: configuratorArtwork,
input,
scrobbles,
},
@@ -103,6 +107,7 @@ export const config = {
},
orchestrator: {
+ artwork,
autoQueue,
favourites,
mediaSession,
@@ -116,7 +121,6 @@ export const config = {
},
processor: {
- artwork,
metadata,
search,
},
@@ -126,6 +130,7 @@ export const config = {
*/
signals: {
configurator: {
+ artwork: signals.configurator.artwork.get,
input: signals.configurator.input.get,
scrobbles: signals.configurator.scrobbles.get,
},
@@ -138,6 +143,7 @@ export const config = {
},
orchestrator: {
+ artwork: signals.orchestrator.artwork.get,
autoQueue: signals.orchestrator.autoQueue.get,
favourites: signals.orchestrator.favourites.get,
mediaSession: signals.orchestrator.mediaSession.get,
@@ -151,7 +157,6 @@ export const config = {
},
processor: {
- artwork: signals.processor.artwork.get,
metadata: signals.processor.metadata.get,
search: signals.processor.search.get,
},
@@ -190,6 +195,18 @@ export default config;
// Configurators
+async function configuratorArtwork() {
+ const { default: ArtworkConfigurator } = await import(
+ "~/components/configurator/artwork/element.js"
+ );
+
+ const ac = new ArtworkConfigurator();
+ ac.setAttribute("group", GROUP);
+ ac.setAttribute("id", "artwork");
+
+ return findExistingOrAdd(ac, signals.configurator.artwork);
+}
+
async function input() {
const { default: InputConfigurator } = await import(
"~/components/configurator/input/element.js"
@@ -269,16 +286,18 @@ async function scope() {
return findExistingOrAdd(s, signals.engine.scope);
}
-// Processors
+// Orchestrators (cont.)
async function artwork() {
- const { default: ArtworkProcessor } = await import(
- "~/components/processor/artwork/element.js"
- );
+ const [{ default: ArtworkOrchestrator }, ac] = await Promise.all([
+ import("~/components/orchestrator/artwork/element.js"),
+ configuratorArtwork(),
+ ]);
- const a = new ArtworkProcessor();
+ const a = new ArtworkOrchestrator();
a.setAttribute("group", GROUP);
+ a.setAttribute("artwork-selector", ac.selector);
- return findExistingOrAdd(a, signals.processor.artwork);
+ return findExistingOrAdd(a, signals.orchestrator.artwork);
}
async function metadata() {
@@ -347,7 +366,7 @@ async function mediaSession() {
const mso = new MediaSessionOrchestrator();
mso.setAttribute("group", GROUP);
mso.setAttribute("audio-engine-selector", a.selector);
- mso.setAttribute("artwork-processor-selector", aw.selector);
+ mso.setAttribute("artwork-selector", aw.selector);
mso.setAttribute("output-selector", o.selector);
mso.setAttribute("queue-engine-selector", q.selector);
diff --git a/src/components/artwork/audio-metadata/element.js b/src/components/artwork/audio-metadata/element.js
new file mode 100644
index 00000000..2e914446
--- /dev/null
+++ b/src/components/artwork/audio-metadata/element.js
@@ -0,0 +1,61 @@
+import { DiffuseElement, query } from "~/common/element.js";
+
+/**
+ * @import {ProxiedActions} from "~/common/worker.d.ts"
+ * @import {InputElement} from "~/components/input/types.d.ts"
+ * @import {Actions} from "~/components/artwork/types.d.ts"
+ */
+
+////////////////////////////////////////////
+// ELEMENT
+////////////////////////////////////////////
+
+/**
+ * @implements {ProxiedActions}
+ */
+class AudioMetadataArtwork extends DiffuseElement {
+ static NAME = "diffuse/artwork/audio-metadata";
+ static WORKER_URL = "components/artwork/audio-metadata/worker.js";
+
+ constructor() {
+ super();
+
+ /** @type {ProxiedActions} */
+ const p = this.workerProxy();
+
+ this.get = p.get;
+ }
+
+ // LIFECYCLE
+
+ /** @override */
+ async connectedCallback() {
+ super.connectedCallback();
+
+ /** @type {InputElement} */
+ this.input = query(this, "input-selector");
+
+ await customElements.whenDefined(this.input.localName);
+ }
+
+ // WORKERS
+
+ /**
+ * @override
+ */
+ dependencies() {
+ if (!this.input) throw new Error("Input element not defined yet");
+ return { input: this.input };
+ }
+}
+
+export default AudioMetadataArtwork;
+
+////////////////////////////////////////////
+// REGISTER
+////////////////////////////////////////////
+
+export const CLASS = AudioMetadataArtwork;
+export const NAME = "da-audio-metadata";
+
+customElements.define(NAME, AudioMetadataArtwork);
diff --git a/src/components/artwork/audio-metadata/worker.js b/src/components/artwork/audio-metadata/worker.js
new file mode 100644
index 00000000..7432a81f
--- /dev/null
+++ b/src/components/artwork/audio-metadata/worker.js
@@ -0,0 +1,59 @@
+import { musicMetadataTags } from "~/components/processor/metadata/common.js";
+import { ostiary, rpc, workerProxy } from "~/common/worker.js";
+
+/**
+ * @import {Extraction} from "~/components/processor/metadata/types.d.ts"
+ * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts"
+ * @import {InputActions} from "~/components/input/types.d.ts"
+ * @import {Actions} from "~/components/artwork/types.d.ts"
+ */
+
+////////////////////////////////////////////
+// ACTIONS
+////////////////////////////////////////////
+
+/**
+ * @type {ActionsWithTunnel['get']}
+ */
+export async function get({ data: track, ports }) {
+ /** @type {ProxiedActions} */
+ const input = workerProxy(() => {
+ ports.input.start();
+ return ports.input;
+ });
+
+ const resGet = await input.resolve({ method: "GET", uri: track.uri });
+ if (!resGet) return null;
+
+ const resHead = "stream" in resGet
+ ? undefined
+ : await input.resolve({ method: "HEAD", uri: track.uri });
+
+ const meta = await musicMetadataTags({
+ includeArtwork: true,
+ stream: "stream" in resGet ? resGet.stream : undefined,
+ mimeType: "stream" in resGet ? resGet.mimeType : undefined,
+ urls: "url" in resGet
+ ? {
+ get: resGet.url,
+ head: resHead && "url" in resHead ? resHead.url : resGet.url,
+ }
+ : undefined,
+ }).catch(/** @param {Error} err */ (err) => {
+ console.error("music-metadata error", err);
+ return /** @type {Extraction} */ ({});
+ });
+
+ const pictures = meta.artwork ?? [];
+ if (!pictures.length) return null;
+
+ return pictures[0].data;
+}
+
+////////////////////////////////////////////
+// ⚡️
+////////////////////////////////////////////
+
+ostiary((context) => {
+ rpc(context, { get });
+});
diff --git a/src/components/processor/artwork/element.js b/src/components/artwork/last.fm/element.js
similarity index 54%
rename from src/components/processor/artwork/element.js
rename to src/components/artwork/last.fm/element.js
index 74788a65..94d7325d 100644
--- a/src/components/processor/artwork/element.js
+++ b/src/components/artwork/last.fm/element.js
@@ -2,7 +2,7 @@ import { DiffuseElement } from "~/common/element.js";
/**
* @import {ProxiedActions} from "~/common/worker.d.ts"
- * @import {Actions} from "./types.d.ts"
+ * @import {Actions} from "~/components/artwork/types.d.ts"
*/
////////////////////////////////////////////
@@ -12,9 +12,9 @@ import { DiffuseElement } from "~/common/element.js";
/**
* @implements {ProxiedActions}
*/
-class ArtworkProcessor extends DiffuseElement {
- static NAME = "diffuse/processor/artwork";
- static WORKER_URL = "components/processor/artwork/worker.js";
+class LastFmArtwork extends DiffuseElement {
+ static NAME = "diffuse/artwork/last.fm";
+ static WORKER_URL = "components/artwork/last.fm/worker.js";
constructor() {
super();
@@ -22,18 +22,17 @@ class ArtworkProcessor extends DiffuseElement {
/** @type {ProxiedActions} */
const p = this.workerProxy();
- this.artwork = p.artwork;
- this.supply = p.supply;
+ this.get = p.get;
}
}
-export default ArtworkProcessor;
+export default LastFmArtwork;
////////////////////////////////////////////
// REGISTER
////////////////////////////////////////////
-export const CLASS = ArtworkProcessor;
-export const NAME = "dp-artwork";
+export const CLASS = LastFmArtwork;
+export const NAME = "da-lastfm";
-customElements.define(NAME, ArtworkProcessor);
+customElements.define(NAME, LastFmArtwork);
diff --git a/src/components/artwork/last.fm/worker.js b/src/components/artwork/last.fm/worker.js
new file mode 100644
index 00000000..66a319cd
--- /dev/null
+++ b/src/components/artwork/last.fm/worker.js
@@ -0,0 +1,59 @@
+import { ostiary, rpc } from "~/common/worker.js";
+
+/**
+ * @import {Actions} from "~/components/artwork/types.d.ts"
+ */
+
+////////////////////////////////////////////
+// ACTIONS
+////////////////////////////////////////////
+
+/**
+ * @type {Actions['get']}
+ */
+export async function get(track) {
+ if (!navigator.onLine) return null;
+
+ const query = track.tags?.artist;
+ if (!query) return null;
+
+ return await fetch(
+ `https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`,
+ )
+ .then((r) => r.json())
+ .then((r) => findCover(r.results.albummatches.album))
+ .catch((err) => {
+ console.error(err);
+ return null;
+ });
+}
+
+////////////////////////////////////////////
+// ⚡️
+////////////////////////////////////////////
+
+ostiary((context) => {
+ rpc(context, { get });
+});
+
+////////////////////////////////////////////
+// 🛠️
+////////////////////////////////////////////
+
+/**
+ * @param {any[]} remainingMatches
+ * @returns {Promise}
+ */
+async function findCover(remainingMatches) {
+ const album = remainingMatches[0];
+ const url = album ? album.image[album.image.length - 1]["#text"] : null;
+
+ return url && url !== ""
+ ? await fetch(url)
+ .then((r) => r.blob())
+ .then(async (b) => new Uint8Array(await b.arrayBuffer()))
+ .catch(() => findCover(remainingMatches.slice(1)))
+ : album
+ ? findCover(remainingMatches.slice(1))
+ : null;
+}
diff --git a/src/components/artwork/musicbrainz/element.js b/src/components/artwork/musicbrainz/element.js
new file mode 100644
index 00000000..299a47b1
--- /dev/null
+++ b/src/components/artwork/musicbrainz/element.js
@@ -0,0 +1,38 @@
+import { DiffuseElement } from "~/common/element.js";
+
+/**
+ * @import {ProxiedActions} from "~/common/worker.d.ts"
+ * @import {Actions} from "~/components/artwork/types.d.ts"
+ */
+
+////////////////////////////////////////////
+// ELEMENT
+////////////////////////////////////////////
+
+/**
+ * @implements {ProxiedActions}
+ */
+class MusicBrainzArtwork extends DiffuseElement {
+ static NAME = "diffuse/artwork/musicbrainz";
+ static WORKER_URL = "components/artwork/musicbrainz/worker.js";
+
+ constructor() {
+ super();
+
+ /** @type {ProxiedActions} */
+ const p = this.workerProxy();
+
+ this.get = p.get;
+ }
+}
+
+export default MusicBrainzArtwork;
+
+////////////////////////////////////////////
+// REGISTER
+////////////////////////////////////////////
+
+export const CLASS = MusicBrainzArtwork;
+export const NAME = "da-musicbrainz";
+
+customElements.define(NAME, MusicBrainzArtwork);
diff --git a/src/components/artwork/musicbrainz/worker.js b/src/components/artwork/musicbrainz/worker.js
new file mode 100644
index 00000000..b6173c18
--- /dev/null
+++ b/src/components/artwork/musicbrainz/worker.js
@@ -0,0 +1,132 @@
+import { ostiary, rpc } from "~/common/worker.js";
+
+/**
+ * @import {Track} from "~/definitions/types.d.ts"
+ * @import {Actions} from "~/components/artwork/types.d.ts"
+ */
+
+////////////////////////////////////////////
+// ACTIONS
+////////////////////////////////////////////
+
+/**
+ * @type {Actions['get']}
+ */
+export async function get(track) {
+ const artist = track.tags?.artist;
+ const album = track.tags?.album;
+
+ if (!navigator.onLine) return null;
+ if (!album && !artist) return null;
+
+ const variousArtists = artist?.toUpperCase() === "VA";
+
+ return search(track, variousArtists);
+}
+
+////////////////////////////////////////////
+// ⚡️
+////////////////////////////////////////////
+
+ostiary((context) => {
+ rpc(context, { get });
+});
+
+////////////////////////////////////////////
+// 🛠️
+////////////////////////////////////////////
+
+/**
+ * @param {string} str
+ */
+function escapeLucene(str) {
+ return [].map
+ .call(str, (char) => {
+ if (
+ char === "+" ||
+ char === "-" ||
+ char === "&" ||
+ char === "|" ||
+ char === "!" ||
+ char === "(" ||
+ char === ")" ||
+ char === "{" ||
+ char === "}" ||
+ char === "[" ||
+ char === "]" ||
+ char === "^" ||
+ char === '"' ||
+ char === "~" ||
+ char === "*" ||
+ char === "?" ||
+ char === ":" ||
+ char === "\\" ||
+ char === "/"
+ ) {
+ return "\\" + char;
+ } else return char;
+ })
+ .join("");
+}
+
+/**
+ * @param {Track} track
+ * @param {boolean} variousArtists
+ * @returns {Promise}
+ */
+async function search(track, variousArtists) {
+ const artist = track.tags?.artist;
+ const album = track.tags?.album;
+
+ const query = `release:"${escapeLucene(album || "")}"` +
+ (variousArtists
+ ? ``
+ : ` AND artistname:"${escapeLucene(artist || "")}"`);
+ const encodedQuery = encodeURIComponent(query);
+
+ return await fetch(
+ `https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`,
+ )
+ .then((r) => r.json())
+ .then((r) => {
+ if (r.releases.length === 0 && !variousArtists) {
+ return search(track, true);
+ } else {
+ return findCover(r.releases, track, variousArtists);
+ }
+ })
+ .catch(() => null);
+}
+
+/**
+ * @param {any[]} remainingReleases
+ * @param {Track} track
+ * @param {boolean} variousArtists
+ * @returns {Promise}
+ */
+async function findCover(remainingReleases, track, variousArtists) {
+ const release = remainingReleases[0];
+ if (!release) return null;
+
+ const credit = release?.["artist-credit"]?.[0]?.name;
+ if (
+ variousArtists && credit !== "Various Artists" &&
+ credit !== track.tags?.artist
+ ) return null;
+
+ return await fetch(
+ `https://coverartarchive.org/release/${release.id}/front-1200`,
+ )
+ .then((r) => r.blob())
+ .then(async (b) => {
+ if (b.type.startsWith("image/")) {
+ return new Uint8Array(await b.arrayBuffer());
+ } else {
+ return findCover(remainingReleases.slice(1), track, variousArtists);
+ }
+ })
+ .catch((err) => {
+ console.error(err);
+ return findCover(remainingReleases.slice(1), track, variousArtists);
+ });
+}
diff --git a/src/components/artwork/types.d.ts b/src/components/artwork/types.d.ts
new file mode 100644
index 00000000..7425ec4c
--- /dev/null
+++ b/src/components/artwork/types.d.ts
@@ -0,0 +1,9 @@
+import type { Track } from "~/definitions/types.d.ts";
+import type { DiffuseElement } from "~/common/element.js";
+import type { ProxiedActions } from "~/common/worker.d.ts";
+
+export type Actions = {
+ get(track: Track): Promise;
+};
+
+export type ArtworkElement = DiffuseElement & ProxiedActions;
diff --git a/src/components/configurator/artwork/element.js b/src/components/configurator/artwork/element.js
new file mode 100644
index 00000000..26943da9
--- /dev/null
+++ b/src/components/configurator/artwork/element.js
@@ -0,0 +1,53 @@
+import { DiffuseElement } from "~/common/element.js";
+
+/**
+ * @import {ProxiedActions} from "~/common/worker.d.ts"
+ * @import {ArtworkElement} from "~/components/artwork/types.d.ts"
+ * @import {Actions} from "./types.d.ts"
+ */
+
+////////////////////////////////////////////
+// ELEMENT
+////////////////////////////////////////////
+
+/**
+ * @implements {ProxiedActions}
+ */
+class ArtworkConfigurator extends DiffuseElement {
+ static NAME = "diffuse/configurator/artwork";
+ static WORKER_URL = "components/configurator/artwork/worker.js";
+
+ constructor() {
+ super();
+
+ /** @type {ProxiedActions} */
+ const proxy = this.workerProxy();
+
+ this.get = proxy.get;
+ }
+
+ // WORKERS
+
+ /**
+ * @override
+ */
+ dependencies() {
+ return Object.fromEntries(
+ Array.from(this.children).map((element) => {
+ const artwork = /** @type {ArtworkElement} */ (element);
+ return [artwork.localName, artwork];
+ }),
+ );
+ }
+}
+
+export default ArtworkConfigurator;
+
+////////////////////////////////////////////
+// REGISTER
+////////////////////////////////////////////
+
+export const CLASS = ArtworkConfigurator;
+export const NAME = "dc-artwork";
+
+customElements.define(NAME, ArtworkConfigurator);
diff --git a/src/components/configurator/artwork/types.d.ts b/src/components/configurator/artwork/types.d.ts
new file mode 100644
index 00000000..6c9e9703
--- /dev/null
+++ b/src/components/configurator/artwork/types.d.ts
@@ -0,0 +1,3 @@
+import type { Actions } from "~/components/artwork/types.d.ts";
+
+export type { Actions };
diff --git a/src/components/configurator/artwork/worker.js b/src/components/configurator/artwork/worker.js
new file mode 100644
index 00000000..b30faa8f
--- /dev/null
+++ b/src/components/configurator/artwork/worker.js
@@ -0,0 +1,39 @@
+import { ostiary, rpc, workerProxy } from "~/common/worker.js";
+
+/**
+ * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts"
+ * @import {Actions} from "~/components/artwork/types.d.ts"
+ * @import {Actions as ConfiguratorActions} from "./types.d.ts"
+ */
+
+////////////////////////////////////////////
+// ACTIONS
+////////////////////////////////////////////
+
+/**
+ * @type {ActionsWithTunnel['get']}
+ */
+export async function get({ data, ports }) {
+ const track = data;
+
+ for (const port of Object.values(ports)) {
+ /** @type {ProxiedActions} */
+ const artwork = workerProxy(() => {
+ port.start();
+ return port;
+ });
+
+ const bytes = await artwork.get(track);
+ if (bytes !== null) return bytes;
+ }
+
+ return null;
+}
+
+////////////////////////////////////////////
+// ⚡️
+////////////////////////////////////////////
+
+ostiary((context) => {
+ rpc(context, { get });
+});
diff --git a/src/components/input/https/worker.js b/src/components/input/https/worker.js
index 9f93b8d0..b5c6f883 100644
--- a/src/components/input/https/worker.js
+++ b/src/components/input/https/worker.js
@@ -105,6 +105,12 @@ export async function list(cachedTracks = []) {
* @type {Actions['resolve']}
*/
export async function resolve({ method, uri }) {
+ if (uri.startsWith("blob:")) {
+ const expiresInSeconds = 60 * 60 * 24 * 365;
+ const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds;
+ return { url: uri, expiresAt: expiresAtSeconds };
+ }
+
const parsed = parseURI(uri);
if (!parsed) return undefined;
diff --git a/src/components/orchestrator/artwork/element.js b/src/components/orchestrator/artwork/element.js
new file mode 100644
index 00000000..45d123b6
--- /dev/null
+++ b/src/components/orchestrator/artwork/element.js
@@ -0,0 +1,62 @@
+import { DiffuseElement, query } from "~/common/element.js";
+
+/**
+ * @import {ProxiedActions} from "~/common/worker.d.ts"
+ * @import {Actions} from "~/components/artwork/types.d.ts"
+ */
+
+////////////////////////////////////////////
+// ELEMENT
+////////////////////////////////////////////
+
+class ArtworkOrchestrator extends DiffuseElement {
+ static NAME = "diffuse/orchestrator/artwork";
+ static WORKER_URL = "components/orchestrator/artwork/worker.js";
+
+ constructor() {
+ super();
+
+ /** @type {ProxiedActions} */
+ const p = this.workerProxy();
+
+ this.get = p.get;
+ }
+
+ // LIFECYCLE
+
+ /** @override */
+ async connectedCallback() {
+ super.connectedCallback();
+
+ /** @type {import("~/components/configurator/artwork/element.js").CLASS} */
+ this.artworkConfigurator = query(this, "artwork-selector");
+
+ await customElements.whenDefined(this.artworkConfigurator.localName);
+ }
+
+ // WORKERS
+
+ /**
+ * @override
+ */
+ dependencies() {
+ if (!this.artworkConfigurator) {
+ throw new Error("Artwork configurator element not defined yet");
+ }
+
+ return {
+ artwork: this.artworkConfigurator,
+ };
+ }
+}
+
+export default ArtworkOrchestrator;
+
+////////////////////////////////////////////
+// REGISTER
+////////////////////////////////////////////
+
+export const CLASS = ArtworkOrchestrator;
+export const NAME = "do-artwork";
+
+customElements.define(NAME, ArtworkOrchestrator);
diff --git a/src/components/orchestrator/artwork/types.d.ts b/src/components/orchestrator/artwork/types.d.ts
new file mode 100644
index 00000000..e64589df
--- /dev/null
+++ b/src/components/orchestrator/artwork/types.d.ts
@@ -0,0 +1,4 @@
+export type Artwork = {
+ bytes: Uint8Array;
+ mime: string;
+};
diff --git a/src/components/orchestrator/artwork/worker.js b/src/components/orchestrator/artwork/worker.js
new file mode 100644
index 00000000..30888b63
--- /dev/null
+++ b/src/components/orchestrator/artwork/worker.js
@@ -0,0 +1,105 @@
+import * as IDB from "idb-keyval";
+
+import { create as createCid } from "~/common/cid.js";
+import { ostiary, rpc, workerProxy } from "~/common/worker.js";
+
+/**
+ * @import {Track} from "~/definitions/types.d.ts"
+ * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts"
+ * @import {Actions} from "~/components/artwork/types.d.ts"
+ * @import {Artwork} from "./types.d.ts"
+ */
+
+// multicodec raw bytes
+const RAW = 0x55;
+
+const IDB_PREFIX = "~/components/orchestrator/artwork";
+const IDB_ARTWORK_PREFIX = `${IDB_PREFIX}/cache`;
+
+////////////////////////////////////////////
+// ACTIONS
+////////////////////////////////////////////
+
+/**
+ * @type {ActionsWithTunnel['get']}
+ */
+export async function get({ data: track, ports }) {
+ return processRequest(track, ports);
+}
+
+////////////////////////////////////////////
+// ⚡️
+////////////////////////////////////////////
+
+ostiary((context) => {
+ rpc(context, { get });
+});
+
+////////////////////////////////////////////
+// 🛠️
+////////////////////////////////////////////
+
+/**
+ * @param {Track} track
+ * @param {Record} ports
+ * @returns {Promise}
+ */
+async function processRequest(track, ports) {
+ // Check if already processed
+
+ /** @type {string[] | undefined} */
+ const cachedCids = await IDB.get(
+ `${IDB_ARTWORK_PREFIX}/track/${track.id}`,
+ );
+
+ if (cachedCids?.length) {
+ /** @type {Artwork[]} */
+ const art = await Promise.all(
+ cachedCids.map((cid) => IDB.get(`${IDB_ARTWORK_PREFIX}/image/${cid}`)),
+ );
+
+ const found = art.filter(Boolean);
+ if (found.length) return found[0].bytes;
+ }
+
+ // 🚀
+
+ /** @type {ProxiedActions} */
+ const configurator = workerProxy(() => {
+ ports.artwork.start();
+ return ports.artwork;
+ });
+
+ const bytes = await configurator.get(track);
+
+ if (bytes === null) {
+ await IDB.set(`${IDB_ARTWORK_PREFIX}/track/${track.id}`, []);
+ return null;
+ }
+
+ const mime = detectMime(bytes);
+
+ /** @type {Artwork} */
+ const art = { bytes, mime };
+
+ // Save artwork to IDB — store by content CID, map track to that CID
+ const cid = await createCid(RAW, bytes);
+ const key = `${IDB_ARTWORK_PREFIX}/image/${cid}`;
+ if (!await IDB.get(key)) await IDB.set(key, art);
+
+ await IDB.set(`${IDB_ARTWORK_PREFIX}/track/${track.id}`, [cid]);
+
+ return bytes;
+}
+
+/**
+ * @param {Uint8Array} bytes
+ * @returns {string}
+ */
+function detectMime(bytes) {
+ if (bytes[0] === 0xFF && bytes[1] === 0xD8) return "image/jpeg";
+ if (bytes[0] === 0x89 && bytes[1] === 0x50) return "image/png";
+ if (bytes[0] === 0x47 && bytes[1] === 0x49) return "image/gif";
+ if (bytes[0] === 0x52 && bytes[1] === 0x49) return "image/webp";
+ return "image/jpeg";
+}
diff --git a/src/components/orchestrator/media-session/element.js b/src/components/orchestrator/media-session/element.js
index 37e588bf..935d0a39 100644
--- a/src/components/orchestrator/media-session/element.js
+++ b/src/components/orchestrator/media-session/element.js
@@ -6,8 +6,7 @@ import {
/**
* @import {OutputElement} from "~/components/output/types.d.ts"
- * @import {Artwork} from "~/components/processor/artwork/types.d.ts"
- * @import ArtworkProcessor from "~/components/processor/artwork/element.js"
+ * @import ArtworkOrchestrator from "~/components/orchestrator/artwork/element.js"
*/
////////////////////////////////////////////
@@ -47,8 +46,8 @@ class MediaSessionOrchestrator extends BroadcastableDiffuseElement {
/** @type {OutputElement | null} */
this.output = queryOptional(this, "output-selector");
- /** @type {ArtworkProcessor | null} */
- this.artwork = queryOptional(this, "artwork-processor-selector");
+ /** @type {ArtworkOrchestrator | null} */
+ this.artwork = queryOptional(this, "artwork-selector");
// Wait until defined
await customElements.whenDefined(this.audio.localName);
@@ -92,22 +91,19 @@ class MediaSessionOrchestrator extends BroadcastableDiffuseElement {
// Optionally fetch and attach artwork
if (this.artwork) {
- const artworkProcessor = this.artwork;
+ const artworkOrchestrator = this.artwork;
- /** @type {Artwork[]} */
- let artworkItems;
+ /** @type {Uint8Array | null} */
+ let bytes = null;
try {
- artworkItems = await artworkProcessor.artwork({
- cacheId: track.id,
- tags,
- });
+ bytes = await artworkOrchestrator.get(track);
} catch {
- artworkItems = [];
+ bytes = null;
}
- if (artworkItems?.length && navigator.mediaSession.metadata) {
- const { bytes, mime } = artworkItems[0];
+ if (bytes && navigator.mediaSession.metadata) {
+ const mime = detectMime(bytes);
const blob = new Blob([/** @type {ArrayBuffer} */ (bytes.buffer)], {
type: mime,
});
@@ -203,6 +199,22 @@ class MediaSessionOrchestrator extends BroadcastableDiffuseElement {
export default MediaSessionOrchestrator;
+////////////////////////////////////////////
+// 🛠️
+////////////////////////////////////////////
+
+/**
+ * @param {Uint8Array} bytes
+ * @returns {string}
+ */
+function detectMime(bytes) {
+ if (bytes[0] === 0xFF && bytes[1] === 0xD8) return "image/jpeg";
+ if (bytes[0] === 0x89 && bytes[1] === 0x50) return "image/png";
+ if (bytes[0] === 0x47 && bytes[1] === 0x49) return "image/gif";
+ if (bytes[0] === 0x52 && bytes[1] === 0x49) return "image/webp";
+ return "image/jpeg";
+}
+
////////////////////////////////////////////
// REGISTER
////////////////////////////////////////////
diff --git a/src/components/processor/artwork/constants.js b/src/components/processor/artwork/constants.js
deleted file mode 100644
index 0eb555d7..00000000
--- a/src/components/processor/artwork/constants.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export const IDB_PREFIX = "~/components/processor/artwork";
-export const IDB_ARTWORK_PREFIX = `${IDB_PREFIX}/cache`;
diff --git a/src/components/processor/artwork/types.d.ts b/src/components/processor/artwork/types.d.ts
deleted file mode 100644
index 37940643..00000000
--- a/src/components/processor/artwork/types.d.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { TrackTags } from "~/definitions/types.d.ts";
-
-export type Actions = {
- artwork(request: ArtworkRequest): Promise;
- supply(items: ArtworkRequest[]): void;
-};
-
-export type Artwork = {
- bytes: Uint8Array;
- mime: string;
-};
-
-export type ArtworkRequest = {
- cacheId: string;
- mimeType?: string;
- stream?: ReadableStream;
- tags?: Tags;
- urls?: Urls;
- variousArtists?: boolean;
-};
-
-// export type State = {
-// artwork: Record;
-// };
-
-export type Urls = { get: string; head: string };
diff --git a/src/components/processor/artwork/worker.js b/src/components/processor/artwork/worker.js
deleted file mode 100644
index 917903a2..00000000
--- a/src/components/processor/artwork/worker.js
+++ /dev/null
@@ -1,300 +0,0 @@
-import * as IDB from "idb-keyval";
-
-import { IDB_ARTWORK_PREFIX } from "./constants.js";
-import { create as createCid } from "~/common/cid.js";
-import { musicMetadataTags } from "../metadata/common.js";
-import { ostiary, rpc } from "~/common/worker.js";
-
-// multicodec raw bytes
-const RAW = 0x55;
-
-/**
- * @import {IPicture} from "music-metadata"
- * @import {Actions, Artwork, ArtworkRequest} from "./types.d.ts"
- * @import {Extraction} from "../metadata/types.d.ts"
- */
-
-/**
- * @type {ArtworkRequest[]}
- */
-let queue = [];
-
-////////////////////////////////////////////
-// ACTIONS
-////////////////////////////////////////////
-
-/**
- * @type {Actions['artwork']}
- */
-export async function artwork(request) {
- const art = await processRequest(request);
- return art;
-}
-
-/**
- * @type {Actions['supply']}
- */
-export function supply(items) {
- const exe = !queue[0];
- queue = [...queue, ...items];
- if (exe) shiftQueue();
-}
-
-////////////////////////////////////////////
-// ⚡️
-////////////////////////////////////////////
-
-ostiary((context) => {
- rpc(context, {
- artwork,
- supply,
- });
-});
-
-////////////////////////////////////////////
-// 🛠️
-////////////////////////////////////////////
-
-/**
- * @param {string} str
- */
-function escapeLucene(str) {
- return [].map
- .call(str, (char) => {
- if (
- char === "+" ||
- char === "-" ||
- char === "&" ||
- char === "|" ||
- char === "!" ||
- char === "(" ||
- char === ")" ||
- char === "{" ||
- char === "}" ||
- char === "[" ||
- char === "]" ||
- char === "^" ||
- char === '"' ||
- char === "~" ||
- char === "*" ||
- char === "?" ||
- char === ":" ||
- char === "\\" ||
- char === "/"
- ) {
- return "\\" + char;
- } else return char;
- })
- .join("");
-}
-
-/**
- * @param {ArtworkRequest} req
- * @returns {Promise}
- */
-async function lastFm(req) {
- if (!navigator.onLine) return [];
-
- const query = req.tags?.artist;
-
- return await fetch(
- `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))
- .catch((err) => {
- console.error(err);
- return [];
- });
-}
-
-/**
- * @param {any[]} remainingMatches
- * @returns {Promise}
- */
-async function lastFmCover(remainingMatches) {
- const album = remainingMatches[0];
- const url = album ? album.image[album.image.length - 1]["#text"] : null;
-
- return url && url !== ""
- ? await fetch(url)
- .then((r) => r.blob())
- .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))
- : [];
-}
-
-/**
- * @param {ArtworkRequest} req
- * @returns {Promise}
- */
-async function musicBrainz(req) {
- const artist = req.tags?.artist;
- const album = req.tags?.album;
-
- if (!navigator.onLine) return [];
- if (!album && !artist) return [];
-
- const query = `release:"${escapeLucene(album || "")}"` +
- (req.variousArtists
- ? ``
- : ` AND artistname:"${escapeLucene(artist || "")}"`);
- const encodedQuery = encodeURIComponent(query);
-
- return await fetch(
- `https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`,
- )
- .then((r) => r.json())
- .then((r) => {
- if (r.releases.length === 0 && !req.variousArtists) {
- return musicBrainz({ ...req, variousArtists: true });
- } else {
- return musicBrainzCover(r.releases, req);
- }
- })
- .catch((err) => {
- // console.error(err);
- return [];
- });
-}
-
-/**
- * @param {any[]} remainingReleases
- * @param {ArtworkRequest} req
- * @returns {Promise}
- */
-async function musicBrainzCover(remainingReleases, req) {
- const release = remainingReleases[0];
- if (!release) return [];
-
- const credit = release?.["artist-credit"]?.[0]?.name;
- if (
- req.variousArtists && credit !== "Various Artists" &&
- credit !== req.tags?.artist
- ) return [];
-
- return await fetch(
- `https://coverartarchive.org/release/${release.id}/front-1200`,
- )
- .then((r) => r.blob())
- .then(async (b) => {
- if (b.type.startsWith("image/")) {
- return [{
- bytes: await b.arrayBuffer().then((buf) => new Uint8Array(buf)),
- mime: b.type,
- }];
- } else {
- return musicBrainzCover(remainingReleases.slice(1), req);
- }
- })
- .catch((err) => {
- console.error(err);
- return musicBrainzCover(remainingReleases.slice(1), req);
- });
-}
-
-/**
- * @param {ArtworkRequest} req
- * @returns {Promise}
- */
-async function processRequest(req) {
- // Check if already processed
-
- /** @type {string[] | undefined} */
- const cachedCids = await IDB.get(
- `${IDB_ARTWORK_PREFIX}/track/${req.cacheId}`,
- );
-
- if (cachedCids?.length) {
- /** @type {Artwork[]} */
- const art = await Promise.all(
- cachedCids.map((cid) => IDB.get(`${IDB_ARTWORK_PREFIX}/image/${cid}`)),
- );
-
- const found = art.filter(Boolean);
- if (found.length) return found;
- }
-
- // Request override
- if (req.tags?.artist?.toUpperCase() === "VA") {
- req.variousArtists = true;
- }
-
- // 🚀
-
- /** @type {Artwork[]} */
- let art = [];
-
- // Get metadata + possible artwork from file metadata
- const meta = await musicMetadataTags({ ...req, includeArtwork: true }).catch(
- /** @param {Error} err */ (err) => {
- console.error("music-metadata error", err);
- /** @type {Extraction} */
- const extraction = {};
- return extraction;
- },
- );
-
- if (!req.tags && meta.tags) req.tags = meta.tags;
-
- // Add artwork from metadata
- const fromMeta = meta.artwork?.map(
- /**
- * @param {IPicture} a
- */
- (a) => {
- return { bytes: a.data, mime: a.format };
- },
- ) || [];
-
- art.push(...fromMeta);
-
- // Stop here if insufficient metadata is present
- if (!req.tags?.artist || !req.tags?.album) return art;
-
- // If no artwork, try finding it on other sources
- if (art.length === 0) {
- const fromMusicBrainz = await musicBrainz(req);
- art.push(...fromMusicBrainz);
- }
-
- if (art.length === 0) {
- const fromLastFm = await lastFm(req);
- art.push(...fromLastFm);
- }
-
- // Save artwork to IDB — store each image by its content CID,
- // then map the track to those CIDs
- const cids = await Promise.all(
- art.map(async (a) => {
- const cid = await createCid(RAW, a.bytes);
- const key = `${IDB_ARTWORK_PREFIX}/image/${cid}`;
- if (await IDB.get(key)) return cid;
- await IDB.set(key, a);
- return cid;
- }),
- );
-
- await IDB.set(`${IDB_ARTWORK_PREFIX}/track/${req.cacheId}`, cids);
-
- // Fin
- return art;
-}
-
-async function shiftQueue() {
- const next = queue.shift();
- if (!next) return;
-
- await processRequest(next);
- await shiftQueue();
-}
diff --git a/src/elements.vto b/src/elements.vto
index aca0c58f..7042fee0 100644
--- a/src/elements.vto
+++ b/src/elements.vto
@@ -14,7 +14,21 @@ scripts:
# ELEMENTS
+artwork:
+ - url: "components/artwork/audio-metadata/element.js"
+ title: "Audio Metadata"
+ desc: "Extracts embedded artwork from audio files using the music-metadata library."
+ - url: "components/artwork/last.fm/element.js"
+ title: "Last.fm"
+ desc: "Fetches cover art from the Last.fm API using track artist and album tags."
+ - url: "components/artwork/musicbrainz/element.js"
+ title: "MusicBrainz"
+ desc: "Fetches cover art from MusicBrainz and the Cover Art Archive using track artist and album tags."
+
configurators:
+ - url: "components/configurator/artwork/element.js"
+ title: "Artwork"
+ desc: "Takes artwork components as children and tries each in sequence, returning the first non-null result."
- url: "components/configurator/input/element.js"
title: "Input"
desc: "Allows for multiple inputs to be used at once."
@@ -106,6 +120,9 @@ orchestrators:
- url: "components/orchestrator/sources/element.js"
title: "Sources"
desc: "Monitor tracks from the given output to form a list of sources based on the input's sources return value."
+ - url: "components/orchestrator/artwork/element.js"
+ title: "Artwork"
+ desc: "Fetches cover art for a given set of tracks, stored locally in indexedDB. Uses the artwork configurator to try each configured source in sequence."
- url: "components/orchestrator/scoped-tracks/element.js"
title: "Scoped Tracks"
desc: "Supplies the tracks from the given output to the given search processor whenever the tracks collection changes. Additionally it can perform a search and other ways to reduce the scope of tracks based on the given scope engine. Provides a `tracks` signal similar to `output.tracks.collection`"
@@ -124,9 +141,6 @@ output:
Store your user data on the storage associated with your ATProtocol identity. Data is lexicon shaped by default so this element takes in that data directly without any transformations.
processors:
- - url: "components/processor/artwork/element.js"
- title: "Artwork"
- desc: "Fetches cover art for a given set of tracks, stored locally in indexedDB. Checks the audio metadata first, then MusicBrainz and uses Last.fm as the fallback."
- url: "components/processor/metadata/element.js"
title: "Metadata"
desc: "Fetch audio metadata for a given set of tracks, adding to the `Track` object."
@@ -219,6 +233,7 @@ definitions:
Diffuse was built using these web components, consume these using the build tool, the Javascript package, or the linked Javascript files down below.
+ - Artwork
- Configurators
- Engines
- Input
@@ -237,6 +252,14 @@ definitions:
+ {{ await comp.element({
+ title: "Artwork",
+ items: artwork,
+ content: `
+ Artwork sources for tracks. Each implements a
get(track) action and returns artwork bytes or null. Use an artwork configurator to combine multiple sources.
+ `
+ }) }}
+
{{ await comp.element({
title: "Configurators",
items: configurators,
diff --git a/src/facets/data/artwork-bundle/index.html b/src/facets/data/artwork-bundle/index.html
new file mode 100644
index 00000000..a8d6d449
--- /dev/null
+++ b/src/facets/data/artwork-bundle/index.html
@@ -0,0 +1 @@
+
diff --git a/src/facets/data/artwork-bundle/index.inline.js b/src/facets/data/artwork-bundle/index.inline.js
new file mode 100644
index 00000000..aedc2f57
--- /dev/null
+++ b/src/facets/data/artwork-bundle/index.inline.js
@@ -0,0 +1,59 @@
+import foundation from "~/common/foundation.js";
+import { effect } from "~/common/signal.js";
+
+import { NAME as AUDIO_METADATA_NAME } from "~/components/artwork/audio-metadata/element.js";
+import { NAME as LAST_FM_NAME } from "~/components/artwork/last.fm/element.js";
+import { NAME as MUSICBRAINZ_NAME } from "~/components/artwork/musicbrainz/element.js";
+
+/**
+ * @import ArtworkConfigurator from "~/components/configurator/artwork/element.js"
+ */
+
+/**
+ * Setup DOM elements when needed.
+ */
+effect(() => {
+ const artwork = foundation.signals.configurator.artwork();
+ const input = foundation.signals.configurator.input();
+ if (!artwork || !input) return;
+
+ audioMetadata(artwork, input);
+ lastFm(artwork);
+ musicBrainz(artwork);
+});
+
+////////////////////////////////////////////
+// AUDIO METADATA
+////////////////////////////////////////////
+
+/**
+ * @param {ArtworkConfigurator} artwork
+ * @param {import("~/components/configurator/input/element.js").default} input
+ */
+export function audioMetadata(artwork, input) {
+ const el = document.createElement(AUDIO_METADATA_NAME);
+ el.setAttribute("input-selector", input.selector);
+ artwork.append(el);
+}
+
+////////////////////////////////////////////
+// LAST.FM
+////////////////////////////////////////////
+
+/**
+ * @param {ArtworkConfigurator} artwork
+ */
+export function lastFm(artwork) {
+ artwork.append(document.createElement(LAST_FM_NAME));
+}
+
+////////////////////////////////////////////
+// MUSICBRAINZ
+////////////////////////////////////////////
+
+/**
+ * @param {ArtworkConfigurator} artwork
+ */
+export function musicBrainz(artwork) {
+ artwork.append(document.createElement(MUSICBRAINZ_NAME));
+}
diff --git a/src/site.webmanifest b/src/site.webmanifest
index b1f17d96..d6ee50bf 100644
--- a/src/site.webmanifest
+++ b/src/site.webmanifest
@@ -3,12 +3,12 @@
"short_name": "Diffuse",
"icons": [
{
- "src": "favicons/android-chrome-192x192.png",
+ "src": "android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
- "src": "favicons/android-chrome-512x512.png",
+ "src": "android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
diff --git a/src/themes/blur/artwork-controller/element.js b/src/themes/blur/artwork-controller/element.js
index f0cb2c5e..c2cf7277 100644
--- a/src/themes/blur/artwork-controller/element.js
+++ b/src/themes/blur/artwork-controller/element.js
@@ -18,10 +18,9 @@ import { computed, signal, untracked } from "~/common/signal.js";
*
* @import {InputElement} from "~/components/input/types.d.ts"
* @import {OutputElement} from "~/components/output/types.d.ts"
- * @import {Artwork} from "~/components/processor/artwork/types.d.ts"
* @import AudioEngine from "~/components/engine/audio/element.js"
* @import QueueEngine from "~/components/engine/queue/element.js"
- * @import ArtworkProcessor from "~/components/processor/artwork/element.js"
+ * @import ArtworkOrchestrator from "~/components/orchestrator/artwork/element.js"
* @import FavouritesOrchestrator from "~/components/orchestrator/favourites/element.js"
*/
@@ -39,7 +38,7 @@ class ArtworkController extends DiffuseElement {
// SIGNALS
#artwork = signal(
- /** @type {{ current: (Artwork & { hash: string; index: number; loaded: boolean; url: string }) | null; previous: (Artwork & { hash: string; index: number; loaded: boolean; url: string }) | null }} */ ({
+ /** @type {{ current: ({ bytes: Uint8Array; mime: string; hash: string; index: number; loaded: boolean; url: string }) | null; previous: ({ bytes: Uint8Array; mime: string; hash: string; index: number; loaded: boolean; url: string }) | null }} */ ({
current: null,
previous: null,
}),
@@ -53,7 +52,7 @@ class ArtworkController extends DiffuseElement {
// SIGNALS - DEPENDENCIES
- $artwork = signal(/** @type {ArtworkProcessor | undefined} */ (undefined));
+ $artwork = signal(/** @type {ArtworkOrchestrator | undefined} */ (undefined));
$audio = signal(/** @type {AudioEngine | undefined} */ (undefined));
$favourites = signal(
/** @type {FavouritesOrchestrator | undefined} */ (undefined),
@@ -89,8 +88,8 @@ class ArtworkController extends DiffuseElement {
connectedCallback() {
super.connectedCallback();
- /** @type {ArtworkProcessor} */
- const artwork = query(this, "artwork-processor-selector");
+ /** @type {ArtworkOrchestrator} */
+ const artwork = query(this, "artwork-selector");
/** @type {AudioEngine} */
const audio = query(this, "audio-engine-selector");
@@ -176,72 +175,42 @@ class ArtworkController extends DiffuseElement {
if (currArtwork.current) {
this.#artwork.value = { current: null, previous: currArtwork.current };
}
+
return;
}
- const cacheId = track.id;
-
- const resGet = await this.$input.value?.resolve({
- method: "GET",
- uri: track.uri,
- });
-
- const resHead = await this.$input.value?.resolve({
- method: "HEAD",
- uri: track.uri,
- });
-
- if (!resGet) return;
-
- const request = "stream" in resGet
- ? {
- cacheId,
- stream: resGet.stream,
- tags: track.tags,
- }
- : {
- cacheId,
- tags: track.tags,
- urls: {
- get: resGet.url,
- head: resHead && "url" in resHead ? resHead.url : resGet.url,
- },
- };
-
if (this.$queue.value?.now()?.id !== track?.id) {
return;
}
- const allArt = await this.$artwork.value?.artwork(request) ?? [];
+ const bytes = await this.$artwork.value?.get(track) ?? null;
// Check if queue item has changed while fetching the artwork
const currTrack = this.currentTrack();
- const currCacheId = currTrack ? currTrack.id : undefined;
-
- if (cacheId === currCacheId) {
- const art = allArt[0];
+ if (track.id === currTrack?.id) {
this.#artwork.set({
previous: currArtwork.current
? { ...currArtwork.current, loaded: false }
: null,
- current: art
- ? {
- ...art,
- hash: xxh32r(art.bytes).toString(),
- index: (currArtwork.current?.index ?? 0) + 1,
- loaded: false,
- url: URL.createObjectURL(
- new Blob(
- [/** @type {ArrayBuffer} */ (art.bytes.buffer)],
- { type: art.mime },
+ current: bytes
+ ? (() => {
+ const mime = detectMime(bytes);
+ return {
+ bytes,
+ mime,
+ hash: xxh32r(bytes).toString(),
+ index: (currArtwork.current?.index ?? 0) + 1,
+ loaded: false,
+ url: URL.createObjectURL(
+ new Blob([/** @type {ArrayBuffer} */ (bytes.buffer)], { type: mime }),
),
- ),
- }
+ };
+ })()
: null,
});
- if (!art) {
+ if (!bytes) {
this.#artworkColor.value = undefined;
this.#artworkLightMode.value = false;
}
@@ -480,7 +449,10 @@ class ArtworkController extends DiffuseElement {
-
+