diff --git a/src/_data/facets.json b/src/_data/facets.json index 6db90399..cdef4cf4 100644 --- a/src/_data/facets.json +++ b/src/_data/facets.json @@ -16,14 +16,14 @@ }, { "url": "themes/blur/artwork-controller/facet/index.html", - "title": "Blur / Artwork controller", + "title": "Blur / Artwork Controller", "category": "Playback", "featured": true, "desc": "Audio playback controller with an artwork display. Play audio from the queue, add tracks to your favourites, control the queue and volume." }, { "url": "facets/data/cache-tracks/index.html", - "title": "Cache tracks", + "title": "Cache Tracks", "kind": "prelude", "category": "Data", "featured": true, @@ -92,6 +92,13 @@ "category": "Data", "desc": "The default setup for audio input sources. Adds support for: HTTPS, Icecast, the local filesystem, OpenSubsonic, and S3-compatible storage." }, + { + "url": "facets/data/metadata-bundle/index.html", + "title": "Default Metadata Bundle", + "kind": "prelude", + "category": "Data", + "desc": "The default setup for track metadata lookups. Reads tags and audio stats from audio files." + }, { "url": "facets/data/output-bundle/index.html", "title": "Default Output Bundle", diff --git a/src/build.vto b/src/build.vto index 9c7521f1..6dfd8bee 100644 --- a/src/build.vto +++ b/src/build.vto @@ -70,7 +70,9 @@ examples: {{- echo -}} import foundation from "common/foundation.js" {{ /echo }} +{{ echo -}}await foundation.configurator.artwork(){{- /echo }} {{ echo -}}await foundation.configurator.input(){{- /echo }} +{{ echo -}}await foundation.configurator.metadata(){{- /echo }} {{ echo -}}await foundation.configurator.scrobbles(){{- /echo }} {{ echo -}}await foundation.engine.audio(){{- /echo }} @@ -89,7 +91,6 @@ examples: {{ echo -}}await foundation.orchestrator.sources(){{- /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 1568d3bb..57144ca0 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/metadata-bundle/index.html", "facets/data/artwork-bundle/index.html", "facets/data/input-bundle/index.html", "facets/data/output-bundle/index.html", diff --git a/src/common/foundation.js b/src/common/foundation.js index 6de32b33..4e4a0a76 100644 --- a/src/common/foundation.js +++ b/src/common/foundation.js @@ -17,6 +17,9 @@ const signals = { artwork: signal( /** @type {import("~/components/configurator/artwork/element.js").CLASS | null} */ (null), ), + metadata: signal( + /** @type {import("~/components/configurator/metadata/element.js").CLASS | null} */ (null), + ), input: signal( /** @type {import("~/components/configurator/input/element.js").CLASS | null} */ (null), ), @@ -77,9 +80,6 @@ const signals = { }, processor: { - metadata: signal( - /** @type {import("~/components/processor/metadata/element.js").CLASS | null} */ (null), - ), search: signal( /** @type {import("~/components/processor/search/element.js").CLASS | null} */ (null), ), @@ -95,6 +95,7 @@ export const config = { // Elements configurator: { artwork: configuratorArtwork, + metadata: configuratorMetadata, input, scrobbles, }, @@ -121,7 +122,6 @@ export const config = { }, processor: { - metadata, search, }, @@ -131,6 +131,7 @@ export const config = { signals: { configurator: { artwork: signals.configurator.artwork.get, + metadata: signals.configurator.metadata.get, input: signals.configurator.input.get, scrobbles: signals.configurator.scrobbles.get, }, @@ -157,7 +158,6 @@ export const config = { }, processor: { - metadata: signals.processor.metadata.get, search: signals.processor.search.get, }, }, @@ -207,6 +207,18 @@ async function configuratorArtwork() { return findExistingOrAdd(ac, signals.configurator.artwork); } +async function configuratorMetadata() { + const { default: MetadataConfigurator } = await import( + "~/components/configurator/metadata/element.js" + ); + + const mc = new MetadataConfigurator(); + mc.setAttribute("group", GROUP); + mc.setAttribute("id", "metadata"); + + return findExistingOrAdd(mc, signals.configurator.metadata); +} + async function input() { const { default: InputConfigurator } = await import( "~/components/configurator/input/element.js" @@ -300,16 +312,6 @@ async function artwork() { return findExistingOrAdd(a, signals.orchestrator.artwork); } -async function metadata() { - const { default: MetadataProcessor } = await import( - "~/components/processor/metadata/element.js" - ); - - const m = new MetadataProcessor(); - m.setAttribute("group", GROUP); - - return findExistingOrAdd(m, signals.processor.metadata); -} async function search() { const { default: SearchProcessor } = await import( @@ -394,14 +396,14 @@ async function processTracks(opts = { disableWhenReady: false }) { import("~/components/orchestrator/process-tracks/element.js"), input(), output(), - metadata(), + configuratorMetadata(), ]); const opt = new ProcessTracksOrchestrator(); opt.setAttribute("group", GROUP); opt.setAttribute("input-selector", i.selector); opt.setAttribute("output-selector", o.selector); - opt.setAttribute("metadata-processor-selector", m.selector); + opt.setAttribute("metadata-selector", m.selector); if (!opts.disableWhenReady) { opt.toggleAttribute("process-when-ready"); diff --git a/src/components/artwork/audio-metadata/worker.js b/src/components/artwork/audio-metadata/worker.js index 7432a81f..d1d0328c 100644 --- a/src/components/artwork/audio-metadata/worker.js +++ b/src/components/artwork/audio-metadata/worker.js @@ -1,8 +1,8 @@ -import { musicMetadataTags } from "~/components/processor/metadata/common.js"; +import { musicMetadataTags } from "~/components/metadata/common.js"; import { ostiary, rpc, workerProxy } from "~/common/worker.js"; /** - * @import {Extraction} from "~/components/processor/metadata/types.d.ts" + * @import {Extraction} from "~/components/metadata/audio-file/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" diff --git a/src/components/configurator/metadata/element.js b/src/components/configurator/metadata/element.js new file mode 100644 index 00000000..cc2d112a --- /dev/null +++ b/src/components/configurator/metadata/element.js @@ -0,0 +1,53 @@ +import { DiffuseElement } from "~/common/element.js"; + +/** + * @import {ProxiedActions} from "~/common/worker.d.ts" + * @import {MetadataElement} from "~/components/metadata/types.d.ts" + * @import {Actions} from "./types.d.ts" + */ + +//////////////////////////////////////////// +// ELEMENT +//////////////////////////////////////////// + +/** + * @implements {ProxiedActions} + */ +class MetadataConfigurator extends DiffuseElement { + static NAME = "diffuse/configurator/metadata"; + static WORKER_URL = "components/configurator/metadata/worker.js"; + + constructor() { + super(); + + /** @type {ProxiedActions} */ + const proxy = this.workerProxy(); + + this.patch = proxy.patch; + } + + // WORKERS + + /** + * @override + */ + dependencies() { + return Object.fromEntries( + Array.from(this.children).map((element) => { + const metadata = /** @type {MetadataElement} */ (element); + return [metadata.localName, metadata]; + }), + ); + } +} + +export default MetadataConfigurator; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = MetadataConfigurator; +export const NAME = "dc-metadata"; + +customElements.define(NAME, MetadataConfigurator); diff --git a/src/components/configurator/metadata/types.d.ts b/src/components/configurator/metadata/types.d.ts new file mode 100644 index 00000000..8f2495f2 --- /dev/null +++ b/src/components/configurator/metadata/types.d.ts @@ -0,0 +1 @@ +export type { Actions } from "~/components/metadata/types.d.ts"; diff --git a/src/components/configurator/metadata/worker.js b/src/components/configurator/metadata/worker.js new file mode 100644 index 00000000..bbdd0458 --- /dev/null +++ b/src/components/configurator/metadata/worker.js @@ -0,0 +1,38 @@ +import { ostiary, rpc, workerProxy } from "~/common/worker.js"; + +/** + * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts" + * @import {Actions} from "~/components/metadata/types.d.ts" + * @import {Actions as ConfiguratorActions} from "./types.d.ts" + */ + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +/** + * @type {ActionsWithTunnel['patch']} + */ +export async function patch({ data: track, ports }) { + let result = track; + + for (const port of Object.values(ports)) { + /** @type {ProxiedActions} */ + const metadata = workerProxy(() => { + port.start(); + return port; + }); + + result = await metadata.patch(result); + } + + return result; +} + +//////////////////////////////////////////// +// ⚡️ +//////////////////////////////////////////// + +ostiary((context) => { + rpc(context, { patch }); +}); diff --git a/src/components/metadata/audio-file/element.js b/src/components/metadata/audio-file/element.js new file mode 100644 index 00000000..823ec4b0 --- /dev/null +++ b/src/components/metadata/audio-file/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/metadata/types.d.ts" + */ + +//////////////////////////////////////////// +// ELEMENT +//////////////////////////////////////////// + +/** + * @implements {ProxiedActions} + */ +class AudioFileMetadata extends DiffuseElement { + static NAME = "diffuse/metadata/audio-file"; + static WORKER_URL = "components/metadata/audio-file/worker.js"; + + constructor() { + super(); + + /** @type {ProxiedActions} */ + const p = this.workerProxy(); + + this.patch = p.patch; + } + + // 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 AudioFileMetadata; + +//////////////////////////////////////////// +// REGISTER +//////////////////////////////////////////// + +export const CLASS = AudioFileMetadata; +export const NAME = "dm-audio-file"; + +customElements.define(NAME, AudioFileMetadata); diff --git a/src/components/processor/metadata/types.d.ts b/src/components/metadata/audio-file/types.d.ts similarity index 57% rename from src/components/processor/metadata/types.d.ts rename to src/components/metadata/audio-file/types.d.ts index f928a5c0..d15ec575 100644 --- a/src/components/processor/metadata/types.d.ts +++ b/src/components/metadata/audio-file/types.d.ts @@ -1,17 +1,6 @@ import type { IPicture } from "music-metadata"; import type { TrackStats, TrackTags } from "~/definitions/types.d.ts"; -export type Actions = { - supply: ( - args: { - includeArtwork?: boolean; - mimeType?: string; - stream?: ReadableStream; - urls?: Urls; - }, - ) => Promise; -}; - export type Extraction = { artwork?: IPicture[]; stats?: TrackStats; diff --git a/src/components/metadata/audio-file/worker.js b/src/components/metadata/audio-file/worker.js new file mode 100644 index 00000000..14d43265 --- /dev/null +++ b/src/components/metadata/audio-file/worker.js @@ -0,0 +1,62 @@ +import { ostiary, rpc, workerProxy } from "~/common/worker.js"; +import { musicMetadataTags } from "~/components/metadata/common.js"; + +/** + * @import {Track} from "~/definitions/types.d.ts" + * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts" + * @import {InputActions} from "~/components/input/types.d.ts" + * @import {Actions} from "~/components/metadata/types.d.ts" + */ + +//////////////////////////////////////////// +// ACTIONS +//////////////////////////////////////////// + +/** + * @type {ActionsWithTunnel['patch']} + */ +export async function patch({ 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 track; + + const resHead = "stream" in resGet + ? undefined + : await input.resolve({ method: "HEAD", uri: track.uri }); + + const { stats, tags } = await musicMetadataTags({ + 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.warn("audio-file metadata error", err); + return /** @type {import("./types.d.ts").Extraction} */ ({}); + }); + + if (!tags && !stats) return track; + + return { + ...track, + stats, + tags, + updatedAt: new Date().toISOString(), + }; +} + +//////////////////////////////////////////// +// ⚡️ +//////////////////////////////////////////// + +ostiary((context) => { + rpc(context, { patch }); +}); diff --git a/src/components/processor/metadata/common.js b/src/components/metadata/common.js similarity index 98% rename from src/components/processor/metadata/common.js rename to src/components/metadata/common.js index e65fd085..09a82c59 100644 --- a/src/components/processor/metadata/common.js +++ b/src/components/metadata/common.js @@ -7,7 +7,7 @@ import { removeUndefinedValuesFromRecord } from "~/common/utils.js"; /** * @import { TrackStats, TrackTags } from "~/definitions/types.d.ts"; - * @import { Extraction, Urls } from "./types.d.ts"; + * @import { Extraction, Urls } from "~/components/metadata/audio-file/types.d.ts"; */ // 🛠️ diff --git a/src/components/metadata/types.d.ts b/src/components/metadata/types.d.ts new file mode 100644 index 00000000..ed6d3b45 --- /dev/null +++ b/src/components/metadata/types.d.ts @@ -0,0 +1,9 @@ +import type { DiffuseElement } from "~/common/element.js"; +import type { ProxiedActions } from "~/common/worker.d.ts"; +import type { Track } from "~/definitions/types.d.ts"; + +export type Actions = { + patch(track: Track): Promise; +}; + +export type MetadataElement = DiffuseElement & ProxiedActions; diff --git a/src/components/orchestrator/process-tracks/element.js b/src/components/orchestrator/process-tracks/element.js index fedd9263..301eb245 100644 --- a/src/components/orchestrator/process-tracks/element.js +++ b/src/components/orchestrator/process-tracks/element.js @@ -6,6 +6,7 @@ import { listen } from "~/common/worker.js"; * @import {ProxiedActions} from "~/common/worker.d.ts" * @import {InputElement} from "~/components/input/types.d.ts" * @import {OutputElement} from "~/components/output/types.d.ts" + * @import MetadataConfigurator from "~/components/configurator/metadata/element.js" * * @import {Actions, Progress} from "./types.d.ts" */ @@ -98,13 +99,13 @@ class ProcessTracksOrchestrator extends BroadcastableDiffuseElement { /** @type {OutputElement} */ const output = query(this, "output-selector"); - /** @type {import("~/components/processor/metadata/element.js").CLASS} */ - const metadataProcessor = query(this, "metadata-processor-selector"); + /** @type {MetadataConfigurator} */ + const metadataConfigurator = query(this, "metadata-selector"); // Assign to self this.input = input; this.output = output; - this.metadataProcessor = metadataProcessor; + this.metadataConfigurator = metadataConfigurator; // Worker link const link = this.workerLink(); @@ -112,7 +113,7 @@ class ProcessTracksOrchestrator extends BroadcastableDiffuseElement { // Wait until defined await customElements.whenDefined(input.localName); await customElements.whenDefined(output.localName); - await customElements.whenDefined(metadataProcessor.localName); + await customElements.whenDefined(metadataConfigurator.localName); // Sync progress with worker listen("progress", this.#progress.set, link); @@ -156,13 +157,13 @@ class ProcessTracksOrchestrator extends BroadcastableDiffuseElement { */ dependencies() { if (!this.input) throw new Error("Input element not defined yet"); - if (!this.metadataProcessor) { - throw new Error("Metadata processor element not defined yet"); + if (!this.metadataConfigurator) { + throw new Error("Metadata configurator element not defined yet"); } return { input: this.input, - metadataProcessor: this.metadataProcessor, + metadata: this.metadataConfigurator, }; } diff --git a/src/components/orchestrator/process-tracks/worker.js b/src/components/orchestrator/process-tracks/worker.js index a9d25f2c..7b28bb65 100644 --- a/src/components/orchestrator/process-tracks/worker.js +++ b/src/components/orchestrator/process-tracks/worker.js @@ -7,7 +7,7 @@ import { announce, ostiary, rpc, workerProxy } from "~/common/worker.js"; * @import {Track} from "~/definitions/types.d.ts" * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts" * @import {InputActions} from "~/components/input/types.d.ts" - * @import {Actions as MetadataProcessorActions} from "~/components/processor/metadata/types.d.ts" + * @import {Actions as MetadataActions} from "~/components/metadata/types.d.ts" * * @import {Actions} from "./types.d.ts" */ @@ -37,11 +37,11 @@ export async function process({ data, ports }) { /** @type {ProxiedActions} */ const input = workerProxy(() => ports.input); - /** @type {ProxiedActions} */ - const metadataProcessor = workerProxy(() => ports.metadataProcessor); + /** @type {ProxiedActions} */ + const metadata = workerProxy(() => ports.metadata); ports.input.start(); - ports.metadataProcessor.start(); + ports.metadata.start(); // List const tracks = await input.list(cachedTracks); @@ -66,41 +66,12 @@ export async function process({ data, ports }) { return [...acc, track]; } - const resGet = await input.resolve({ - method: "GET", - uri: track.uri, - }); - - if (!resGet) { - processed++; - $progress.value = { processed, total: tracks.length }; - return [...acc, track]; - } - - const resHead = "stream" in resGet ? undefined : await input.resolve({ - method: "HEAD", - uri: track.uri, - }); - - const { stats, tags } = await metadataProcessor.supply({ - stream: "stream" in resGet ? resGet.stream : undefined, - urls: "url" in resGet - ? { - get: resGet.url, - head: resHead && "url" in resHead ? resHead.url : resGet.url, - } - : undefined, - }); + const patched = await metadata.patch(track); processed++; $progress.value = { processed, total: tracks.length }; - return [...acc, { - ...track, - stats, - tags, - updatedAt: new Date().toISOString(), - }]; + return [...acc, patched]; }, Promise.resolve([]), ); diff --git a/src/components/processor/metadata/element.js b/src/components/processor/metadata/element.js deleted file mode 100644 index 46218259..00000000 --- a/src/components/processor/metadata/element.js +++ /dev/null @@ -1,39 +0,0 @@ -import { DiffuseElement } from "~/common/element.js"; - -/** - * @import {ProxiedActions} from "~/common/worker.d.ts" - * @import {Actions} from "./types.d.ts" - */ - -//////////////////////////////////////////// -// ELEMENT -//////////////////////////////////////////// - -/** - * @implements {ProxiedActions} - */ -class MetadataProcessor extends DiffuseElement { - static NAME = "diffuse/processor/metadata"; - static WORKER_URL = "components/processor/metadata/worker.js"; - - constructor() { - super(); - - /** @type {ProxiedActions} */ - const p = this.workerProxy(); - - // Worker proxy - this.supply = p.supply; - } -} - -export default MetadataProcessor; - -//////////////////////////////////////////// -// REGISTER -//////////////////////////////////////////// - -export const CLASS = MetadataProcessor; -export const NAME = "dp-metadata"; - -customElements.define(NAME, MetadataProcessor); diff --git a/src/components/processor/metadata/worker.js b/src/components/processor/metadata/worker.js deleted file mode 100644 index 2382abbf..00000000 --- a/src/components/processor/metadata/worker.js +++ /dev/null @@ -1,38 +0,0 @@ -import { ostiary, rpc } from "~/common/worker.js"; -import { musicMetadataTags } from "./common.js"; - -/** - * @import { Actions, Extraction } from "./types.d.ts"; - */ - -//////////////////////////////////////////// -// ACTIONS -//////////////////////////////////////////// - -/** - * @type {Actions['supply']} - */ -export async function supply(args) { - // Construct records - // TODO: Use other metadata lib as fallback: https://github.com/buzz/mediainfo.js - return await musicMetadataTags(args).catch( - /** - * @param {Error} err - * @returns {Extraction} - */ - (err) => { - console.warn("Metadata processor error:", err, args); - return {}; - }, - ); -} - -//////////////////////////////////////////// -// ⚡️ -//////////////////////////////////////////// - -ostiary((context) => { - rpc(context, { - supply, - }); -}); diff --git a/src/elements.vto b/src/elements.vto index 7042fee0..0cf0baf3 100644 --- a/src/elements.vto +++ b/src/elements.vto @@ -32,6 +32,9 @@ configurators: - url: "components/configurator/input/element.js" title: "Input" desc: "Allows for multiple inputs to be used at once." + - url: "components/configurator/metadata/element.js" + title: "Metadata" + desc: "Takes metadata components as children and chains their patches in sequence." - url: "components/configurator/output/element.js" title: "Output" desc: "Enables the user to configure a specific output. If no default output is set, it creates a temporary session by storing everything in memory." @@ -140,10 +143,12 @@ output: desc: > 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. +metadata: + - url: "components/metadata/audio-file/element.js" + title: "Audio File" + desc: "Extracts tags and audio stats from audio files using the music-metadata library." + processors: - - url: "components/processor/metadata/element.js" - title: "Metadata" - desc: "Fetch audio metadata for a given set of tracks, adding to the `Track` object." - url: "components/processor/search/element.js" title: "Search" desc: "Provides a way to search through a collection of tracks, powered by orama.js" @@ -237,6 +242,7 @@ definitions:
  • Configurators
  • Engines
  • Input
  • +
  • Metadata
  • Orchestrators
  • Output
  • Processors
  • @@ -256,7 +262,7 @@ definitions: 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. + Elements that provide artwork for tracks. ` }) }} @@ -264,7 +270,7 @@ definitions: title: "Configurators", items: configurators, content: ` - Elements that serve as an intermediate in order to make a particular kind of element configurable. In other words, these allow for an element to be swapped out with another that takes the same set of the actions and data output. + Intermediates in order to make a particular kind of element configurable. In other words, these allow for an element to be swapped out with another that takes the same set of the actions and data output. ` }) }} @@ -284,6 +290,14 @@ definitions: ` }) }} + {{ await comp.element({ + title: "Metadata", + items: metadata, + content: ` + Elements that provide metadata for tracks. + ` + }) }} + {{ await comp.element({ title: "Orchestrators", items: orchestrators, diff --git a/src/facets/data/metadata-bundle/index.html b/src/facets/data/metadata-bundle/index.html new file mode 100644 index 00000000..9773fe9d --- /dev/null +++ b/src/facets/data/metadata-bundle/index.html @@ -0,0 +1 @@ + diff --git a/src/facets/data/metadata-bundle/index.inline.js b/src/facets/data/metadata-bundle/index.inline.js new file mode 100644 index 00000000..5c9192fe --- /dev/null +++ b/src/facets/data/metadata-bundle/index.inline.js @@ -0,0 +1,33 @@ +import foundation from "~/common/foundation.js"; +import { effect } from "~/common/signal.js"; + +import { NAME as AUDIO_FILE_NAME } from "~/components/metadata/audio-file/element.js"; + +/** + * @import MetadataConfigurator from "~/components/configurator/metadata/element.js" + */ + +/** + * Setup DOM elements when needed. + */ +effect(() => { + const metadata = foundation.signals.configurator.metadata(); + const input = foundation.signals.configurator.input(); + if (!metadata || !input) return; + + audioFile(metadata, input); +}); + +//////////////////////////////////////////// +// AUDIO FILE +//////////////////////////////////////////// + +/** + * @param {MetadataConfigurator} metadata + * @param {import("~/components/configurator/input/element.js").default} input + */ +export function audioFile(metadata, input) { + const el = document.createElement(AUDIO_FILE_NAME); + el.setAttribute("input-selector", input.selector); + metadata.append(el); +} diff --git a/tests/components/configurator/metadata/test.ts b/tests/components/configurator/metadata/test.ts new file mode 100644 index 00000000..69a499a8 --- /dev/null +++ b/tests/components/configurator/metadata/test.ts @@ -0,0 +1,77 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { testWeb } from "@tests/common/index.ts"; + +describe("components/configurator/metadata", () => { + it("returns track unchanged when there are no children", async () => { + const result = await testWeb(async () => { + const { CLASS } = await import( + "~/components/configurator/metadata/element.js" + ); + + const configurator = new CLASS(); + document.body.append(configurator); + await customElements.whenDefined(configurator.localName); + + const track = { + $type: "sh.diffuse.output.track" as const, + id: "metadata-configurator-test-no-children", + uri: "local://test", + }; + + const result = await configurator.patch(track); + return { sameId: result.id === track.id, hasTags: !!result.tags }; + }); + + expect(result.sameId).toBe(true); + expect(result.hasTags).toBe(false); + }); + + it("chains patches through children in sequence", async () => { + const result = await testWeb(async () => { + const HttpsInput = await import("~/components/input/https/element.js"); + const AudioFile = await import( + "~/components/metadata/audio-file/element.js" + ); + const { CLASS } = await import( + "~/components/configurator/metadata/element.js" + ); + + const input = new HttpsInput.CLASS(); + input.id = "test-metadata-configurator-input"; + document.body.append(input); + + const audioFile = new AudioFile.CLASS(); + audioFile.setAttribute( + "input-selector", + "#test-metadata-configurator-input", + ); + + const configurator = new CLASS(); + configurator.append(audioFile); + document.body.append(configurator); + + await customElements.whenDefined(input.localName); + await customElements.whenDefined(configurator.localName); + + const blob = await fetch("/testing/sample/audio.mp3").then((r) => + r.blob() + ); + const blobUri = URL.createObjectURL(blob); + + const track = { + $type: "sh.diffuse.output.track" as const, + id: "metadata-configurator-test-chain", + uri: blobUri, + }; + + const patched = await configurator.patch(track); + URL.revokeObjectURL(blobUri); + + return { title: patched.tags?.title ?? null }; + }); + + expect(result.title).toBe("Mr. Sandman"); + }); +}); diff --git a/tests/components/metadata/audio-file/test.ts b/tests/components/metadata/audio-file/test.ts new file mode 100644 index 00000000..10d76fe2 --- /dev/null +++ b/tests/components/metadata/audio-file/test.ts @@ -0,0 +1,86 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { testWeb } from "@tests/common/index.ts"; + +describe("components/metadata/audio-file", () => { + it("returns track unchanged when URI is unresolvable", async () => { + const result = await testWeb(async () => { + const HttpsInput = await import("~/components/input/https/element.js"); + const AudioFile = await import( + "~/components/metadata/audio-file/element.js" + ); + + const input = new HttpsInput.CLASS(); + input.id = "test-metadata-https-input-1"; + document.body.append(input); + + const audioFile = new AudioFile.CLASS(); + audioFile.setAttribute("input-selector", "#test-metadata-https-input-1"); + document.body.append(audioFile); + + await customElements.whenDefined(input.localName); + await customElements.whenDefined(audioFile.localName); + + const track = { + $type: "sh.diffuse.output.track" as const, + id: "metadata-audio-file-test-unresolvable", + uri: "local://no-such-file", + }; + + const result = await audioFile.patch(track); + return { hasTags: !!result.tags, hasStats: !!result.stats }; + }); + + expect(result.hasTags).toBe(false); + expect(result.hasStats).toBe(false); + }); + + it("extracts tags and stats from sample audio file", async () => { + const result = await testWeb(async () => { + const HttpsInput = await import("~/components/input/https/element.js"); + const AudioFile = await import( + "~/components/metadata/audio-file/element.js" + ); + + const input = new HttpsInput.CLASS(); + input.id = "test-metadata-https-input-2"; + document.body.append(input); + + const audioFile = new AudioFile.CLASS(); + audioFile.setAttribute("input-selector", "#test-metadata-https-input-2"); + document.body.append(audioFile); + + await customElements.whenDefined(input.localName); + await customElements.whenDefined(audioFile.localName); + + const blob = await fetch("/testing/sample/audio.mp3").then((r) => + r.blob() + ); + const blobUri = URL.createObjectURL(blob); + + const track = { + $type: "sh.diffuse.output.track" as const, + id: "metadata-audio-file-test-sample", + uri: blobUri, + }; + + const patched = await audioFile.patch(track); + URL.revokeObjectURL(blobUri); + + return { tags: patched.tags ?? null, stats: patched.stats ?? null }; + }); + + expect(result.tags).not.toBe(null); + expect(result.tags?.title).toBe("Mr. Sandman"); + expect(result.tags?.album).toBe("Mr. Sandman"); + expect(result.tags?.year).toBe(1954); + expect(result.tags?.track?.no).toBe(1); + expect(result.tags?.artist).toContain("The Chordettes"); + + expect(result.stats).not.toBe(null); + expect(result.stats?.bitrate).toBe(143320); + expect(result.stats?.duration).toBeGreaterThan(150000); + expect(result.stats?.duration).toBeLessThan(152000); + }); +}); diff --git a/tests/components/processor/metadata/test.ts b/tests/components/processor/metadata/test.ts deleted file mode 100644 index 43345090..00000000 --- a/tests/components/processor/metadata/test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; - -import { testWeb } from "@tests/common/index.ts"; - -describe("components/processor/metadata", () => { - it("returns empty extraction when no urls or stream are provided", async () => { - const result = await testWeb(async () => { - const mod = await import("~/components/processor/metadata/element.js"); - const processor = new mod.CLASS(); - - document.body.append(processor); - - // No urls/stream — musicMetadataTags throws, worker catches and returns {} - return processor.supply({}); - }); - - expect(result).toEqual({}); - }); - - it("returns empty extraction when the URL is unreachable", async () => { - const result = await testWeb(async () => { - const mod = await import("~/components/processor/metadata/element.js"); - const processor = new mod.CLASS(); - - document.body.append(processor); - - return processor.supply({ - urls: { - get: "http://localhost/nonexistent-audio-file.mp3", - head: "http://localhost/nonexistent-audio-file.mp3", - }, - }); - }); - - expect(result).toEqual({}); - }); - - it("extracts tags from sample audio file", async () => { - const tags = await testWeb(async () => { - const mod = await import("~/components/processor/metadata/element.js"); - const processor = new mod.CLASS(); - document.body.append(processor); - - const blob = await fetch("/testing/sample/audio.mp3").then((r) => - r.blob() - ); - const blobUrl = URL.createObjectURL(blob); - const extraction = await processor.supply({ - urls: { get: blobUrl, head: blobUrl }, - }); - URL.revokeObjectURL(blobUrl); - - return extraction.tags ?? null; - }); - - expect(tags).not.toBe(null); - expect(tags?.title).toBe("Mr. Sandman"); - expect(tags?.album).toBe("Mr. Sandman"); - expect(tags?.year).toBe(1954); - expect(tags?.track?.no).toBe(1); - expect(tags?.artist).toContain("The Chordettes"); - }); - - it("extracts stats from sample audio file", async () => { - const stats = await testWeb(async () => { - const mod = await import("~/components/processor/metadata/element.js"); - const processor = new mod.CLASS(); - document.body.append(processor); - - const blob = await fetch("/testing/sample/audio.mp3").then((r) => - r.blob() - ); - const blobUrl = URL.createObjectURL(blob); - const extraction = await processor.supply({ - urls: { get: blobUrl, head: blobUrl }, - }); - URL.revokeObjectURL(blobUrl); - - return extraction.stats ?? null; - }); - - expect(stats).not.toBe(null); - expect(stats?.bitrate).toBe(143320); - // Duration is stored in milliseconds; 151.21s ± 500ms - expect(stats?.duration).toBeGreaterThan(150000); - expect(stats?.duration).toBeLessThan(152000); - }); -});