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 {
    - +