A music player that connects to your cloud/distributed storage.
5
fork

Configure Feed

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

feat: make metadata lookup extendable

+498 -257
+9 -2
src/_data/facets.json
··· 16 16 }, 17 17 { 18 18 "url": "themes/blur/artwork-controller/facet/index.html", 19 - "title": "Blur / Artwork controller", 19 + "title": "Blur / Artwork Controller", 20 20 "category": "Playback", 21 21 "featured": true, 22 22 "desc": "Audio playback controller with an artwork display. Play audio from the queue, add tracks to your favourites, control the queue and volume." 23 23 }, 24 24 { 25 25 "url": "facets/data/cache-tracks/index.html", 26 - "title": "Cache tracks", 26 + "title": "Cache Tracks", 27 27 "kind": "prelude", 28 28 "category": "Data", 29 29 "featured": true, ··· 91 91 "kind": "prelude", 92 92 "category": "Data", 93 93 "desc": "The default setup for audio input sources. Adds support for: HTTPS, Icecast, the local filesystem, OpenSubsonic, and S3-compatible storage." 94 + }, 95 + { 96 + "url": "facets/data/metadata-bundle/index.html", 97 + "title": "Default Metadata Bundle", 98 + "kind": "prelude", 99 + "category": "Data", 100 + "desc": "The default setup for track metadata lookups. Reads tags and audio stats from audio files." 94 101 }, 95 102 { 96 103 "url": "facets/data/output-bundle/index.html",
+2 -1
src/build.vto
··· 70 70 {{- echo -}} 71 71 import foundation from "common/foundation.js" 72 72 {{ /echo }} 73 + {{ echo -}}await foundation.configurator.artwork(){{- /echo }} 73 74 {{ echo -}}await foundation.configurator.input(){{- /echo }} 75 + {{ echo -}}await foundation.configurator.metadata(){{- /echo }} 74 76 {{ echo -}}await foundation.configurator.scrobbles(){{- /echo }} 75 77 76 78 {{ echo -}}await foundation.engine.audio(){{- /echo }} ··· 89 91 {{ echo -}}await foundation.orchestrator.sources(){{- /echo }} 90 92 91 93 {{ echo -}}await foundation.orchestrator.artwork(){{- /echo }} 92 - {{ echo -}}await foundation.processor.metadata(){{- /echo }} 93 94 {{ echo -}}await foundation.processor.search(){{- /echo -}} 94 95 </code> 95 96 </div>
+1
src/common/facets/constants.js
··· 6 6 "themes/blur/artwork-controller/facet/index.html", 7 7 8 8 // PRELUDES 9 + "facets/data/metadata-bundle/index.html", 9 10 "facets/data/artwork-bundle/index.html", 10 11 "facets/data/input-bundle/index.html", 11 12 "facets/data/output-bundle/index.html",
+19 -17
src/common/foundation.js
··· 17 17 artwork: signal( 18 18 /** @type {import("~/components/configurator/artwork/element.js").CLASS | null} */ (null), 19 19 ), 20 + metadata: signal( 21 + /** @type {import("~/components/configurator/metadata/element.js").CLASS | null} */ (null), 22 + ), 20 23 input: signal( 21 24 /** @type {import("~/components/configurator/input/element.js").CLASS | null} */ (null), 22 25 ), ··· 77 80 }, 78 81 79 82 processor: { 80 - metadata: signal( 81 - /** @type {import("~/components/processor/metadata/element.js").CLASS | null} */ (null), 82 - ), 83 83 search: signal( 84 84 /** @type {import("~/components/processor/search/element.js").CLASS | null} */ (null), 85 85 ), ··· 95 95 // Elements 96 96 configurator: { 97 97 artwork: configuratorArtwork, 98 + metadata: configuratorMetadata, 98 99 input, 99 100 scrobbles, 100 101 }, ··· 121 122 }, 122 123 123 124 processor: { 124 - metadata, 125 125 search, 126 126 }, 127 127 ··· 131 131 signals: { 132 132 configurator: { 133 133 artwork: signals.configurator.artwork.get, 134 + metadata: signals.configurator.metadata.get, 134 135 input: signals.configurator.input.get, 135 136 scrobbles: signals.configurator.scrobbles.get, 136 137 }, ··· 157 158 }, 158 159 159 160 processor: { 160 - metadata: signals.processor.metadata.get, 161 161 search: signals.processor.search.get, 162 162 }, 163 163 }, ··· 207 207 return findExistingOrAdd(ac, signals.configurator.artwork); 208 208 } 209 209 210 + async function configuratorMetadata() { 211 + const { default: MetadataConfigurator } = await import( 212 + "~/components/configurator/metadata/element.js" 213 + ); 214 + 215 + const mc = new MetadataConfigurator(); 216 + mc.setAttribute("group", GROUP); 217 + mc.setAttribute("id", "metadata"); 218 + 219 + return findExistingOrAdd(mc, signals.configurator.metadata); 220 + } 221 + 210 222 async function input() { 211 223 const { default: InputConfigurator } = await import( 212 224 "~/components/configurator/input/element.js" ··· 300 312 return findExistingOrAdd(a, signals.orchestrator.artwork); 301 313 } 302 314 303 - async function metadata() { 304 - const { default: MetadataProcessor } = await import( 305 - "~/components/processor/metadata/element.js" 306 - ); 307 - 308 - const m = new MetadataProcessor(); 309 - m.setAttribute("group", GROUP); 310 - 311 - return findExistingOrAdd(m, signals.processor.metadata); 312 - } 313 315 314 316 async function search() { 315 317 const { default: SearchProcessor } = await import( ··· 394 396 import("~/components/orchestrator/process-tracks/element.js"), 395 397 input(), 396 398 output(), 397 - metadata(), 399 + configuratorMetadata(), 398 400 ]); 399 401 400 402 const opt = new ProcessTracksOrchestrator(); 401 403 opt.setAttribute("group", GROUP); 402 404 opt.setAttribute("input-selector", i.selector); 403 405 opt.setAttribute("output-selector", o.selector); 404 - opt.setAttribute("metadata-processor-selector", m.selector); 406 + opt.setAttribute("metadata-selector", m.selector); 405 407 406 408 if (!opts.disableWhenReady) { 407 409 opt.toggleAttribute("process-when-ready");
+2 -2
src/components/artwork/audio-metadata/worker.js
··· 1 - import { musicMetadataTags } from "~/components/processor/metadata/common.js"; 1 + import { musicMetadataTags } from "~/components/metadata/common.js"; 2 2 import { ostiary, rpc, workerProxy } from "~/common/worker.js"; 3 3 4 4 /** 5 - * @import {Extraction} from "~/components/processor/metadata/types.d.ts" 5 + * @import {Extraction} from "~/components/metadata/audio-file/types.d.ts" 6 6 * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts" 7 7 * @import {InputActions} from "~/components/input/types.d.ts" 8 8 * @import {Actions} from "~/components/artwork/types.d.ts"
+53
src/components/configurator/metadata/element.js
··· 1 + import { DiffuseElement } from "~/common/element.js"; 2 + 3 + /** 4 + * @import {ProxiedActions} from "~/common/worker.d.ts" 5 + * @import {MetadataElement} from "~/components/metadata/types.d.ts" 6 + * @import {Actions} from "./types.d.ts" 7 + */ 8 + 9 + //////////////////////////////////////////// 10 + // ELEMENT 11 + //////////////////////////////////////////// 12 + 13 + /** 14 + * @implements {ProxiedActions<Actions>} 15 + */ 16 + class MetadataConfigurator extends DiffuseElement { 17 + static NAME = "diffuse/configurator/metadata"; 18 + static WORKER_URL = "components/configurator/metadata/worker.js"; 19 + 20 + constructor() { 21 + super(); 22 + 23 + /** @type {ProxiedActions<Actions>} */ 24 + const proxy = this.workerProxy(); 25 + 26 + this.patch = proxy.patch; 27 + } 28 + 29 + // WORKERS 30 + 31 + /** 32 + * @override 33 + */ 34 + dependencies() { 35 + return Object.fromEntries( 36 + Array.from(this.children).map((element) => { 37 + const metadata = /** @type {MetadataElement} */ (element); 38 + return [metadata.localName, metadata]; 39 + }), 40 + ); 41 + } 42 + } 43 + 44 + export default MetadataConfigurator; 45 + 46 + //////////////////////////////////////////// 47 + // REGISTER 48 + //////////////////////////////////////////// 49 + 50 + export const CLASS = MetadataConfigurator; 51 + export const NAME = "dc-metadata"; 52 + 53 + customElements.define(NAME, MetadataConfigurator);
+1
src/components/configurator/metadata/types.d.ts
··· 1 + export type { Actions } from "~/components/metadata/types.d.ts";
+38
src/components/configurator/metadata/worker.js
··· 1 + import { ostiary, rpc, workerProxy } from "~/common/worker.js"; 2 + 3 + /** 4 + * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts" 5 + * @import {Actions} from "~/components/metadata/types.d.ts" 6 + * @import {Actions as ConfiguratorActions} from "./types.d.ts" 7 + */ 8 + 9 + //////////////////////////////////////////// 10 + // ACTIONS 11 + //////////////////////////////////////////// 12 + 13 + /** 14 + * @type {ActionsWithTunnel<ConfiguratorActions>['patch']} 15 + */ 16 + export async function patch({ data: track, ports }) { 17 + let result = track; 18 + 19 + for (const port of Object.values(ports)) { 20 + /** @type {ProxiedActions<Actions>} */ 21 + const metadata = workerProxy(() => { 22 + port.start(); 23 + return port; 24 + }); 25 + 26 + result = await metadata.patch(result); 27 + } 28 + 29 + return result; 30 + } 31 + 32 + //////////////////////////////////////////// 33 + // ⚡️ 34 + //////////////////////////////////////////// 35 + 36 + ostiary((context) => { 37 + rpc(context, { patch }); 38 + });
+61
src/components/metadata/audio-file/element.js
··· 1 + import { DiffuseElement, query } from "~/common/element.js"; 2 + 3 + /** 4 + * @import {ProxiedActions} from "~/common/worker.d.ts" 5 + * @import {InputElement} from "~/components/input/types.d.ts" 6 + * @import {Actions} from "~/components/metadata/types.d.ts" 7 + */ 8 + 9 + //////////////////////////////////////////// 10 + // ELEMENT 11 + //////////////////////////////////////////// 12 + 13 + /** 14 + * @implements {ProxiedActions<Actions>} 15 + */ 16 + class AudioFileMetadata extends DiffuseElement { 17 + static NAME = "diffuse/metadata/audio-file"; 18 + static WORKER_URL = "components/metadata/audio-file/worker.js"; 19 + 20 + constructor() { 21 + super(); 22 + 23 + /** @type {ProxiedActions<Actions>} */ 24 + const p = this.workerProxy(); 25 + 26 + this.patch = p.patch; 27 + } 28 + 29 + // LIFECYCLE 30 + 31 + /** @override */ 32 + async connectedCallback() { 33 + super.connectedCallback(); 34 + 35 + /** @type {InputElement} */ 36 + this.input = query(this, "input-selector"); 37 + 38 + await customElements.whenDefined(this.input.localName); 39 + } 40 + 41 + // WORKERS 42 + 43 + /** 44 + * @override 45 + */ 46 + dependencies() { 47 + if (!this.input) throw new Error("Input element not defined yet"); 48 + return { input: this.input }; 49 + } 50 + } 51 + 52 + export default AudioFileMetadata; 53 + 54 + //////////////////////////////////////////// 55 + // REGISTER 56 + //////////////////////////////////////////// 57 + 58 + export const CLASS = AudioFileMetadata; 59 + export const NAME = "dm-audio-file"; 60 + 61 + customElements.define(NAME, AudioFileMetadata);
+10
src/components/metadata/audio-file/types.d.ts
··· 1 + import type { IPicture } from "music-metadata"; 2 + import type { TrackStats, TrackTags } from "~/definitions/types.d.ts"; 3 + 4 + export type Extraction = { 5 + artwork?: IPicture[]; 6 + stats?: TrackStats; 7 + tags?: TrackTags; 8 + }; 9 + 10 + export type Urls = { get: string; head: string };
+62
src/components/metadata/audio-file/worker.js
··· 1 + import { ostiary, rpc, workerProxy } from "~/common/worker.js"; 2 + import { musicMetadataTags } from "~/components/metadata/common.js"; 3 + 4 + /** 5 + * @import {Track} from "~/definitions/types.d.ts" 6 + * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts" 7 + * @import {InputActions} from "~/components/input/types.d.ts" 8 + * @import {Actions} from "~/components/metadata/types.d.ts" 9 + */ 10 + 11 + //////////////////////////////////////////// 12 + // ACTIONS 13 + //////////////////////////////////////////// 14 + 15 + /** 16 + * @type {ActionsWithTunnel<Actions>['patch']} 17 + */ 18 + export async function patch({ data: track, ports }) { 19 + /** @type {ProxiedActions<InputActions>} */ 20 + const input = workerProxy(() => { 21 + ports.input.start(); 22 + return ports.input; 23 + }); 24 + 25 + const resGet = await input.resolve({ method: "GET", uri: track.uri }); 26 + if (!resGet) return track; 27 + 28 + const resHead = "stream" in resGet 29 + ? undefined 30 + : await input.resolve({ method: "HEAD", uri: track.uri }); 31 + 32 + const { stats, tags } = await musicMetadataTags({ 33 + stream: "stream" in resGet ? resGet.stream : undefined, 34 + mimeType: "stream" in resGet ? resGet.mimeType : undefined, 35 + urls: "url" in resGet 36 + ? { 37 + get: resGet.url, 38 + head: resHead && "url" in resHead ? resHead.url : resGet.url, 39 + } 40 + : undefined, 41 + }).catch(/** @param {Error} err */ (err) => { 42 + console.warn("audio-file metadata error", err); 43 + return /** @type {import("./types.d.ts").Extraction} */ ({}); 44 + }); 45 + 46 + if (!tags && !stats) return track; 47 + 48 + return { 49 + ...track, 50 + stats, 51 + tags, 52 + updatedAt: new Date().toISOString(), 53 + }; 54 + } 55 + 56 + //////////////////////////////////////////// 57 + // ⚡️ 58 + //////////////////////////////////////////// 59 + 60 + ostiary((context) => { 61 + rpc(context, { patch }); 62 + });
+9
src/components/metadata/types.d.ts
··· 1 + import type { DiffuseElement } from "~/common/element.js"; 2 + import type { ProxiedActions } from "~/common/worker.d.ts"; 3 + import type { Track } from "~/definitions/types.d.ts"; 4 + 5 + export type Actions = { 6 + patch(track: Track): Promise<Track>; 7 + }; 8 + 9 + export type MetadataElement = DiffuseElement & ProxiedActions<Actions>;
+8 -7
src/components/orchestrator/process-tracks/element.js
··· 6 6 * @import {ProxiedActions} from "~/common/worker.d.ts" 7 7 * @import {InputElement} from "~/components/input/types.d.ts" 8 8 * @import {OutputElement} from "~/components/output/types.d.ts" 9 + * @import MetadataConfigurator from "~/components/configurator/metadata/element.js" 9 10 * 10 11 * @import {Actions, Progress} from "./types.d.ts" 11 12 */ ··· 98 99 /** @type {OutputElement} */ 99 100 const output = query(this, "output-selector"); 100 101 101 - /** @type {import("~/components/processor/metadata/element.js").CLASS} */ 102 - const metadataProcessor = query(this, "metadata-processor-selector"); 102 + /** @type {MetadataConfigurator} */ 103 + const metadataConfigurator = query(this, "metadata-selector"); 103 104 104 105 // Assign to self 105 106 this.input = input; 106 107 this.output = output; 107 - this.metadataProcessor = metadataProcessor; 108 + this.metadataConfigurator = metadataConfigurator; 108 109 109 110 // Worker link 110 111 const link = this.workerLink(); ··· 112 113 // Wait until defined 113 114 await customElements.whenDefined(input.localName); 114 115 await customElements.whenDefined(output.localName); 115 - await customElements.whenDefined(metadataProcessor.localName); 116 + await customElements.whenDefined(metadataConfigurator.localName); 116 117 117 118 // Sync progress with worker 118 119 listen("progress", this.#progress.set, link); ··· 156 157 */ 157 158 dependencies() { 158 159 if (!this.input) throw new Error("Input element not defined yet"); 159 - if (!this.metadataProcessor) { 160 - throw new Error("Metadata processor element not defined yet"); 160 + if (!this.metadataConfigurator) { 161 + throw new Error("Metadata configurator element not defined yet"); 161 162 } 162 163 163 164 return { 164 165 input: this.input, 165 - metadataProcessor: this.metadataProcessor, 166 + metadata: this.metadataConfigurator, 166 167 }; 167 168 } 168 169
+6 -35
src/components/orchestrator/process-tracks/worker.js
··· 7 7 * @import {Track} from "~/definitions/types.d.ts" 8 8 * @import {ActionsWithTunnel, ProxiedActions} from "~/common/worker.d.ts" 9 9 * @import {InputActions} from "~/components/input/types.d.ts" 10 - * @import {Actions as MetadataProcessorActions} from "~/components/processor/metadata/types.d.ts" 10 + * @import {Actions as MetadataActions} from "~/components/metadata/types.d.ts" 11 11 * 12 12 * @import {Actions} from "./types.d.ts" 13 13 */ ··· 37 37 /** @type {ProxiedActions<InputActions>} */ 38 38 const input = workerProxy(() => ports.input); 39 39 40 - /** @type {ProxiedActions<MetadataProcessorActions>} */ 41 - const metadataProcessor = workerProxy(() => ports.metadataProcessor); 40 + /** @type {ProxiedActions<MetadataActions>} */ 41 + const metadata = workerProxy(() => ports.metadata); 42 42 43 43 ports.input.start(); 44 - ports.metadataProcessor.start(); 44 + ports.metadata.start(); 45 45 46 46 // List 47 47 const tracks = await input.list(cachedTracks); ··· 66 66 return [...acc, track]; 67 67 } 68 68 69 - const resGet = await input.resolve({ 70 - method: "GET", 71 - uri: track.uri, 72 - }); 73 - 74 - if (!resGet) { 75 - processed++; 76 - $progress.value = { processed, total: tracks.length }; 77 - return [...acc, track]; 78 - } 79 - 80 - const resHead = "stream" in resGet ? undefined : await input.resolve({ 81 - method: "HEAD", 82 - uri: track.uri, 83 - }); 84 - 85 - const { stats, tags } = await metadataProcessor.supply({ 86 - stream: "stream" in resGet ? resGet.stream : undefined, 87 - urls: "url" in resGet 88 - ? { 89 - get: resGet.url, 90 - head: resHead && "url" in resHead ? resHead.url : resGet.url, 91 - } 92 - : undefined, 93 - }); 69 + const patched = await metadata.patch(track); 94 70 95 71 processed++; 96 72 $progress.value = { processed, total: tracks.length }; 97 73 98 - return [...acc, { 99 - ...track, 100 - stats, 101 - tags, 102 - updatedAt: new Date().toISOString(), 103 - }]; 74 + return [...acc, patched]; 104 75 }, 105 76 Promise.resolve([]), 106 77 );
+1 -1
src/components/processor/metadata/common.js src/components/metadata/common.js
··· 7 7 8 8 /** 9 9 * @import { TrackStats, TrackTags } from "~/definitions/types.d.ts"; 10 - * @import { Extraction, Urls } from "./types.d.ts"; 10 + * @import { Extraction, Urls } from "~/components/metadata/audio-file/types.d.ts"; 11 11 */ 12 12 13 13 // 🛠️
-39
src/components/processor/metadata/element.js
··· 1 - import { DiffuseElement } from "~/common/element.js"; 2 - 3 - /** 4 - * @import {ProxiedActions} from "~/common/worker.d.ts" 5 - * @import {Actions} from "./types.d.ts" 6 - */ 7 - 8 - //////////////////////////////////////////// 9 - // ELEMENT 10 - //////////////////////////////////////////// 11 - 12 - /** 13 - * @implements {ProxiedActions<Actions>} 14 - */ 15 - class MetadataProcessor extends DiffuseElement { 16 - static NAME = "diffuse/processor/metadata"; 17 - static WORKER_URL = "components/processor/metadata/worker.js"; 18 - 19 - constructor() { 20 - super(); 21 - 22 - /** @type {ProxiedActions<Actions>} */ 23 - const p = this.workerProxy(); 24 - 25 - // Worker proxy 26 - this.supply = p.supply; 27 - } 28 - } 29 - 30 - export default MetadataProcessor; 31 - 32 - //////////////////////////////////////////// 33 - // REGISTER 34 - //////////////////////////////////////////// 35 - 36 - export const CLASS = MetadataProcessor; 37 - export const NAME = "dp-metadata"; 38 - 39 - customElements.define(NAME, MetadataProcessor);
-21
src/components/processor/metadata/types.d.ts
··· 1 - import type { IPicture } from "music-metadata"; 2 - import type { TrackStats, TrackTags } from "~/definitions/types.d.ts"; 3 - 4 - export type Actions = { 5 - supply: ( 6 - args: { 7 - includeArtwork?: boolean; 8 - mimeType?: string; 9 - stream?: ReadableStream; 10 - urls?: Urls; 11 - }, 12 - ) => Promise<Extraction>; 13 - }; 14 - 15 - export type Extraction = { 16 - artwork?: IPicture[]; 17 - stats?: TrackStats; 18 - tags?: TrackTags; 19 - }; 20 - 21 - export type Urls = { get: string; head: string };
-38
src/components/processor/metadata/worker.js
··· 1 - import { ostiary, rpc } from "~/common/worker.js"; 2 - import { musicMetadataTags } from "./common.js"; 3 - 4 - /** 5 - * @import { Actions, Extraction } from "./types.d.ts"; 6 - */ 7 - 8 - //////////////////////////////////////////// 9 - // ACTIONS 10 - //////////////////////////////////////////// 11 - 12 - /** 13 - * @type {Actions['supply']} 14 - */ 15 - export async function supply(args) { 16 - // Construct records 17 - // TODO: Use other metadata lib as fallback: https://github.com/buzz/mediainfo.js 18 - return await musicMetadataTags(args).catch( 19 - /** 20 - * @param {Error} err 21 - * @returns {Extraction} 22 - */ 23 - (err) => { 24 - console.warn("Metadata processor error:", err, args); 25 - return {}; 26 - }, 27 - ); 28 - } 29 - 30 - //////////////////////////////////////////// 31 - // ⚡️ 32 - //////////////////////////////////////////// 33 - 34 - ostiary((context) => { 35 - rpc(context, { 36 - supply, 37 - }); 38 - });
+19 -5
src/elements.vto
··· 32 32 - url: "components/configurator/input/element.js" 33 33 title: "Input" 34 34 desc: "Allows for multiple inputs to be used at once." 35 + - url: "components/configurator/metadata/element.js" 36 + title: "Metadata" 37 + desc: "Takes metadata components as children and chains their patches in sequence." 35 38 - url: "components/configurator/output/element.js" 36 39 title: "Output" 37 40 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 143 desc: > 141 144 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. 142 145 146 + metadata: 147 + - url: "components/metadata/audio-file/element.js" 148 + title: "Audio File" 149 + desc: "Extracts tags and audio stats from audio files using the music-metadata library." 150 + 143 151 processors: 144 - - url: "components/processor/metadata/element.js" 145 - title: "Metadata" 146 - desc: "Fetch audio metadata for a given set of tracks, adding to the `Track` object." 147 152 - url: "components/processor/search/element.js" 148 153 title: "Search" 149 154 desc: "Provides a way to search through a collection of tracks, powered by orama.js" ··· 237 242 <li><a href="elements/#configurators">Configurators</a></li> 238 243 <li><a href="elements/#engines">Engines</a></li> 239 244 <li><a href="elements/#input">Input</a></li> 245 + <li><a href="elements/#metadata">Metadata</a></li> 240 246 <li><a href="elements/#orchestrators">Orchestrators</a></li> 241 247 <li><a href="elements/#output">Output</a></li> 242 248 <li><a href="elements/#processors">Processors</a></li> ··· 256 262 title: "Artwork", 257 263 items: artwork, 258 264 content: ` 259 - Artwork sources for tracks. Each implements a <code>get(track)</code> action and returns artwork bytes or null. Use an artwork configurator to combine multiple sources. 265 + Elements that provide artwork for tracks. 260 266 ` 261 267 }) }} 262 268 ··· 264 270 title: "Configurators", 265 271 items: configurators, 266 272 content: ` 267 - 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. 273 + 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. 268 274 ` 269 275 }) }} 270 276 ··· 281 287 items: input, 282 288 content: ` 283 289 Inputs are sources of audio tracks. Each track is an entry in the list of possible items to play. These can be files or streams, static or dynamic. 290 + ` 291 + }) }} 292 + 293 + {{ await comp.element({ 294 + title: "Metadata", 295 + items: metadata, 296 + content: ` 297 + Elements that provide metadata for tracks. 284 298 ` 285 299 }) }} 286 300
+1
src/facets/data/metadata-bundle/index.html
··· 1 + <script type="module" src="facets/data/metadata-bundle/index.inline.js"></script>
+33
src/facets/data/metadata-bundle/index.inline.js
··· 1 + import foundation from "~/common/foundation.js"; 2 + import { effect } from "~/common/signal.js"; 3 + 4 + import { NAME as AUDIO_FILE_NAME } from "~/components/metadata/audio-file/element.js"; 5 + 6 + /** 7 + * @import MetadataConfigurator from "~/components/configurator/metadata/element.js" 8 + */ 9 + 10 + /** 11 + * Setup DOM elements when needed. 12 + */ 13 + effect(() => { 14 + const metadata = foundation.signals.configurator.metadata(); 15 + const input = foundation.signals.configurator.input(); 16 + if (!metadata || !input) return; 17 + 18 + audioFile(metadata, input); 19 + }); 20 + 21 + //////////////////////////////////////////// 22 + // AUDIO FILE 23 + //////////////////////////////////////////// 24 + 25 + /** 26 + * @param {MetadataConfigurator} metadata 27 + * @param {import("~/components/configurator/input/element.js").default} input 28 + */ 29 + export function audioFile(metadata, input) { 30 + const el = document.createElement(AUDIO_FILE_NAME); 31 + el.setAttribute("input-selector", input.selector); 32 + metadata.append(el); 33 + }
+77
tests/components/configurator/metadata/test.ts
··· 1 + import { describe, it } from "@std/testing/bdd"; 2 + import { expect } from "@std/expect"; 3 + 4 + import { testWeb } from "@tests/common/index.ts"; 5 + 6 + describe("components/configurator/metadata", () => { 7 + it("returns track unchanged when there are no children", async () => { 8 + const result = await testWeb(async () => { 9 + const { CLASS } = await import( 10 + "~/components/configurator/metadata/element.js" 11 + ); 12 + 13 + const configurator = new CLASS(); 14 + document.body.append(configurator); 15 + await customElements.whenDefined(configurator.localName); 16 + 17 + const track = { 18 + $type: "sh.diffuse.output.track" as const, 19 + id: "metadata-configurator-test-no-children", 20 + uri: "local://test", 21 + }; 22 + 23 + const result = await configurator.patch(track); 24 + return { sameId: result.id === track.id, hasTags: !!result.tags }; 25 + }); 26 + 27 + expect(result.sameId).toBe(true); 28 + expect(result.hasTags).toBe(false); 29 + }); 30 + 31 + it("chains patches through children in sequence", async () => { 32 + const result = await testWeb(async () => { 33 + const HttpsInput = await import("~/components/input/https/element.js"); 34 + const AudioFile = await import( 35 + "~/components/metadata/audio-file/element.js" 36 + ); 37 + const { CLASS } = await import( 38 + "~/components/configurator/metadata/element.js" 39 + ); 40 + 41 + const input = new HttpsInput.CLASS(); 42 + input.id = "test-metadata-configurator-input"; 43 + document.body.append(input); 44 + 45 + const audioFile = new AudioFile.CLASS(); 46 + audioFile.setAttribute( 47 + "input-selector", 48 + "#test-metadata-configurator-input", 49 + ); 50 + 51 + const configurator = new CLASS(); 52 + configurator.append(audioFile); 53 + document.body.append(configurator); 54 + 55 + await customElements.whenDefined(input.localName); 56 + await customElements.whenDefined(configurator.localName); 57 + 58 + const blob = await fetch("/testing/sample/audio.mp3").then((r) => 59 + r.blob() 60 + ); 61 + const blobUri = URL.createObjectURL(blob); 62 + 63 + const track = { 64 + $type: "sh.diffuse.output.track" as const, 65 + id: "metadata-configurator-test-chain", 66 + uri: blobUri, 67 + }; 68 + 69 + const patched = await configurator.patch(track); 70 + URL.revokeObjectURL(blobUri); 71 + 72 + return { title: patched.tags?.title ?? null }; 73 + }); 74 + 75 + expect(result.title).toBe("Mr. Sandman"); 76 + }); 77 + });
+86
tests/components/metadata/audio-file/test.ts
··· 1 + import { describe, it } from "@std/testing/bdd"; 2 + import { expect } from "@std/expect"; 3 + 4 + import { testWeb } from "@tests/common/index.ts"; 5 + 6 + describe("components/metadata/audio-file", () => { 7 + it("returns track unchanged when URI is unresolvable", async () => { 8 + const result = await testWeb(async () => { 9 + const HttpsInput = await import("~/components/input/https/element.js"); 10 + const AudioFile = await import( 11 + "~/components/metadata/audio-file/element.js" 12 + ); 13 + 14 + const input = new HttpsInput.CLASS(); 15 + input.id = "test-metadata-https-input-1"; 16 + document.body.append(input); 17 + 18 + const audioFile = new AudioFile.CLASS(); 19 + audioFile.setAttribute("input-selector", "#test-metadata-https-input-1"); 20 + document.body.append(audioFile); 21 + 22 + await customElements.whenDefined(input.localName); 23 + await customElements.whenDefined(audioFile.localName); 24 + 25 + const track = { 26 + $type: "sh.diffuse.output.track" as const, 27 + id: "metadata-audio-file-test-unresolvable", 28 + uri: "local://no-such-file", 29 + }; 30 + 31 + const result = await audioFile.patch(track); 32 + return { hasTags: !!result.tags, hasStats: !!result.stats }; 33 + }); 34 + 35 + expect(result.hasTags).toBe(false); 36 + expect(result.hasStats).toBe(false); 37 + }); 38 + 39 + it("extracts tags and stats from sample audio file", async () => { 40 + const result = await testWeb(async () => { 41 + const HttpsInput = await import("~/components/input/https/element.js"); 42 + const AudioFile = await import( 43 + "~/components/metadata/audio-file/element.js" 44 + ); 45 + 46 + const input = new HttpsInput.CLASS(); 47 + input.id = "test-metadata-https-input-2"; 48 + document.body.append(input); 49 + 50 + const audioFile = new AudioFile.CLASS(); 51 + audioFile.setAttribute("input-selector", "#test-metadata-https-input-2"); 52 + document.body.append(audioFile); 53 + 54 + await customElements.whenDefined(input.localName); 55 + await customElements.whenDefined(audioFile.localName); 56 + 57 + const blob = await fetch("/testing/sample/audio.mp3").then((r) => 58 + r.blob() 59 + ); 60 + const blobUri = URL.createObjectURL(blob); 61 + 62 + const track = { 63 + $type: "sh.diffuse.output.track" as const, 64 + id: "metadata-audio-file-test-sample", 65 + uri: blobUri, 66 + }; 67 + 68 + const patched = await audioFile.patch(track); 69 + URL.revokeObjectURL(blobUri); 70 + 71 + return { tags: patched.tags ?? null, stats: patched.stats ?? null }; 72 + }); 73 + 74 + expect(result.tags).not.toBe(null); 75 + expect(result.tags?.title).toBe("Mr. Sandman"); 76 + expect(result.tags?.album).toBe("Mr. Sandman"); 77 + expect(result.tags?.year).toBe(1954); 78 + expect(result.tags?.track?.no).toBe(1); 79 + expect(result.tags?.artist).toContain("The Chordettes"); 80 + 81 + expect(result.stats).not.toBe(null); 82 + expect(result.stats?.bitrate).toBe(143320); 83 + expect(result.stats?.duration).toBeGreaterThan(150000); 84 + expect(result.stats?.duration).toBeLessThan(152000); 85 + }); 86 + });
-89
tests/components/processor/metadata/test.ts
··· 1 - import { describe, it } from "@std/testing/bdd"; 2 - import { expect } from "@std/expect"; 3 - 4 - import { testWeb } from "@tests/common/index.ts"; 5 - 6 - describe("components/processor/metadata", () => { 7 - it("returns empty extraction when no urls or stream are provided", async () => { 8 - const result = await testWeb(async () => { 9 - const mod = await import("~/components/processor/metadata/element.js"); 10 - const processor = new mod.CLASS(); 11 - 12 - document.body.append(processor); 13 - 14 - // No urls/stream — musicMetadataTags throws, worker catches and returns {} 15 - return processor.supply({}); 16 - }); 17 - 18 - expect(result).toEqual({}); 19 - }); 20 - 21 - it("returns empty extraction when the URL is unreachable", async () => { 22 - const result = await testWeb(async () => { 23 - const mod = await import("~/components/processor/metadata/element.js"); 24 - const processor = new mod.CLASS(); 25 - 26 - document.body.append(processor); 27 - 28 - return processor.supply({ 29 - urls: { 30 - get: "http://localhost/nonexistent-audio-file.mp3", 31 - head: "http://localhost/nonexistent-audio-file.mp3", 32 - }, 33 - }); 34 - }); 35 - 36 - expect(result).toEqual({}); 37 - }); 38 - 39 - it("extracts tags from sample audio file", async () => { 40 - const tags = await testWeb(async () => { 41 - const mod = await import("~/components/processor/metadata/element.js"); 42 - const processor = new mod.CLASS(); 43 - document.body.append(processor); 44 - 45 - const blob = await fetch("/testing/sample/audio.mp3").then((r) => 46 - r.blob() 47 - ); 48 - const blobUrl = URL.createObjectURL(blob); 49 - const extraction = await processor.supply({ 50 - urls: { get: blobUrl, head: blobUrl }, 51 - }); 52 - URL.revokeObjectURL(blobUrl); 53 - 54 - return extraction.tags ?? null; 55 - }); 56 - 57 - expect(tags).not.toBe(null); 58 - expect(tags?.title).toBe("Mr. Sandman"); 59 - expect(tags?.album).toBe("Mr. Sandman"); 60 - expect(tags?.year).toBe(1954); 61 - expect(tags?.track?.no).toBe(1); 62 - expect(tags?.artist).toContain("The Chordettes"); 63 - }); 64 - 65 - it("extracts stats from sample audio file", async () => { 66 - const stats = await testWeb(async () => { 67 - const mod = await import("~/components/processor/metadata/element.js"); 68 - const processor = new mod.CLASS(); 69 - document.body.append(processor); 70 - 71 - const blob = await fetch("/testing/sample/audio.mp3").then((r) => 72 - r.blob() 73 - ); 74 - const blobUrl = URL.createObjectURL(blob); 75 - const extraction = await processor.supply({ 76 - urls: { get: blobUrl, head: blobUrl }, 77 - }); 78 - URL.revokeObjectURL(blobUrl); 79 - 80 - return extraction.stats ?? null; 81 - }); 82 - 83 - expect(stats).not.toBe(null); 84 - expect(stats?.bitrate).toBe(143320); 85 - // Duration is stored in milliseconds; 151.21s ± 500ms 86 - expect(stats?.duration).toBeGreaterThan(150000); 87 - expect(stats?.duration).toBeLessThan(152000); 88 - }); 89 - });