From 4a85418a2e77cbd6fd2a9de793c112d8f72fe7c8 Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Sun, 29 Mar 2026 22:50:38 +0200 Subject: [PATCH] feat: add `artwork` action to input components --- src/components/configurator/input/worker.js | 13 ++++++++ src/components/input/https/worker.js | 8 +++++ src/components/input/icecast/worker.js | 8 +++++ src/components/input/local/worker.js | 8 +++++ src/components/input/opensubsonic/worker.js | 8 +++++ src/components/input/s3/worker.js | 8 +++++ src/components/input/types.d.ts | 3 +- tests/components/configurator/input/test.ts | 33 +++++++++++++++++++++ tests/components/input/https/test.ts | 11 +++++++ tests/components/input/icecast/test.ts | 11 +++++++ tests/components/input/local/test.ts | 11 +++++++ tests/components/input/opensubsonic/test.ts | 13 ++++++++ tests/components/input/s3/test.ts | 13 ++++++++ 13 files changed, 147 insertions(+), 1 deletion(-) diff --git a/src/components/configurator/input/worker.js b/src/components/configurator/input/worker.js index 8cee4d3e..dd3a51ec 100644 --- a/src/components/configurator/input/worker.js +++ b/src/components/configurator/input/worker.js @@ -24,6 +24,18 @@ const blobUrls = new Map(); // INPUT ACTIONS //////////////////////////////////////////// +/** + * @type {ActionsWithTunnel['artwork']} + */ +export async function artwork({ data, ports }) { + const uri = data; + const scheme = uri.split(":", 1)[0]; + const input = grabInput(scheme, ports); + if (!input) return null; + + return await input.artwork(uri); +} + /** * @type {ActionsWithTunnel['consult']} */ @@ -217,6 +229,7 @@ export async function removeFromCache({ data }) { ostiary((context) => { rpc(context, { + artwork, consult, detach, groupConsult, diff --git a/src/components/input/https/worker.js b/src/components/input/https/worker.js index b5c6f883..ab36fce3 100644 --- a/src/components/input/https/worker.js +++ b/src/components/input/https/worker.js @@ -17,6 +17,13 @@ import { SCHEME } from "./constants.js"; // ACTIONS //////////////////////////////////////////// +/** + * @type {Actions['artwork']} + */ +export async function artwork(_uri) { + return null; +} + /** * @type {Actions['consult']} */ @@ -133,6 +140,7 @@ ostiary((context) => { // Setup RPC rpc(context, { + artwork, consult, detach, groupConsult, diff --git a/src/components/input/icecast/worker.js b/src/components/input/icecast/worker.js index 0a5a8387..7cf77257 100644 --- a/src/components/input/icecast/worker.js +++ b/src/components/input/icecast/worker.js @@ -18,6 +18,13 @@ import { SCHEME } from "./constants.js"; // ACTIONS //////////////////////////////////////////// +/** + * @type {Actions['artwork']} + */ +export async function artwork(_uri) { + return null; +} + /** * @type {Actions['consult']} */ @@ -142,6 +149,7 @@ export async function resolve({ uri }) { ostiary((context) => { rpc(context, { + artwork, consult, detach, groupConsult, diff --git a/src/components/input/local/worker.js b/src/components/input/local/worker.js index 4b7589c2..5696f4b8 100644 --- a/src/components/input/local/worker.js +++ b/src/components/input/local/worker.js @@ -23,6 +23,13 @@ import { SCHEME } from "./constants.js"; // ACTIONS //////////////////////////////////////////// +/** + * @type {Actions['artwork']} + */ +export async function artwork(_uri) { + return null; +} + /** * @type {Actions['consult']} */ @@ -231,6 +238,7 @@ export async function resolve({ uri }) { ostiary((context) => { rpc(context, { + artwork, consult, detach, groupConsult, diff --git a/src/components/input/opensubsonic/worker.js b/src/components/input/opensubsonic/worker.js index 88c1e3f8..7805f3dd 100644 --- a/src/components/input/opensubsonic/worker.js +++ b/src/components/input/opensubsonic/worker.js @@ -29,6 +29,13 @@ import { // ACTIONS //////////////////////////////////////////// +/** + * @type {Actions['artwork']} + */ +export async function artwork(_uri) { + return null; +} + /** * @type {Actions['consult']} */ @@ -293,6 +300,7 @@ ostiary((context) => { // Setup RPC rpc(context, { + artwork, consult, detach, groupConsult, diff --git a/src/components/input/s3/worker.js b/src/components/input/s3/worker.js index fe2b30e4..1bffb9ec 100644 --- a/src/components/input/s3/worker.js +++ b/src/components/input/s3/worker.js @@ -26,6 +26,13 @@ import { SCHEME } from "./constants.js"; // ACTIONS //////////////////////////////////////////// +/** + * @type {Actions['artwork']} + */ +export async function artwork(_uri) { + return null; +} + /** * @type {Actions['consult']} */ @@ -241,6 +248,7 @@ ostiary((context) => { // Setup RPC rpc(context, { + artwork, consult, detach, groupConsult, diff --git a/src/components/input/types.d.ts b/src/components/input/types.d.ts index 1dfa53cc..b0c9468d 100644 --- a/src/components/input/types.d.ts +++ b/src/components/input/types.d.ts @@ -20,7 +20,8 @@ export type ConsultGrouping = export type GroupConsult = Record; export type InputActions = { - consult(fileUriOrScheme: string): Promise; + artwork(uri: string): Promise; + consult(uriOrScheme: string): Promise; detach(args: { fileUriOrScheme: string; tracks: Track[] }): Promise; groupConsult(uris: string[]): Promise; list(tracks: Track[]): Promise; diff --git a/tests/components/configurator/input/test.ts b/tests/components/configurator/input/test.ts index d8b34aec..4b92942a 100644 --- a/tests/components/configurator/input/test.ts +++ b/tests/components/configurator/input/test.ts @@ -82,6 +82,39 @@ describe("components/configurator/input", () => { expect(result["ipfs"]?.uris).toEqual(["ipfs://QmA", "ipfs://QmB"]); }); + it("artwork with an unsupported scheme returns null", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/configurator/input/element.js" + ); + const configurator = new mod.CLASS(); + document.body.append(configurator); + return configurator.artwork("ipfs://QmSomeCid"); + }); + + expect(result).toBe(null); + }); + + it("artwork delegates to the appropriate input and returns null", async () => { + const result = await testWeb(async () => { + const mod = await import( + "~/components/configurator/input/element.js" + ); + const HttpsInput = await import( + "~/components/input/https/element.js" + ); + + const configurator = new mod.CLASS(); + const httpsInput = new HttpsInput.CLASS(); + configurator.appendChild(httpsInput); + document.body.append(configurator); + + return configurator.artwork("https://example.com/audio.mp3"); + }); + + expect(result).toBe(null); + }); + it("inputs maps child input elements by their SCHEME", async () => { const result = await testWeb(async () => { const mod = await import( diff --git a/tests/components/input/https/test.ts b/tests/components/input/https/test.ts index 59524df4..9280264a 100644 --- a/tests/components/input/https/test.ts +++ b/tests/components/input/https/test.ts @@ -134,6 +134,17 @@ describe("components/input/https", () => { expect(remaining[1].id).toBe("4"); }); + it("artwork returns null", async () => { + const result = await testWeb(async () => { + const HttpsInput = await import("~/components/input/https/element.js"); + const input = new HttpsInput.CLASS(); + document.body.append(input); + return await input.artwork("https://example.com/audio.mp3"); + }); + + expect(result).toBe(null); + }); + it("has correct SCHEME property", async () => { const scheme = await testWeb(async () => { const HttpsInput = await import("~/components/input/https/element.js"); diff --git a/tests/components/input/icecast/test.ts b/tests/components/input/icecast/test.ts index 9a517182..292799fe 100644 --- a/tests/components/input/icecast/test.ts +++ b/tests/components/input/icecast/test.ts @@ -146,6 +146,17 @@ describe("components/input/icecast", () => { expect(remaining.length).toBe(2); }); + it("artwork returns null", async () => { + const result = await testWeb(async () => { + const mod = await import("~/components/input/icecast/element.js"); + const input = new mod.CLASS(); + document.body.append(input); + return await input.artwork("icecast://radio.example.com/stream.mp3"); + }); + + expect(result).toBe(null); + }); + it("sources returns an entry with icecast:// URI for each track", async () => { const sources = await testWeb(async () => { const mod = await import("~/components/input/icecast/element.js"); diff --git a/tests/components/input/local/test.ts b/tests/components/input/local/test.ts index af496ba6..d33a180e 100644 --- a/tests/components/input/local/test.ts +++ b/tests/components/input/local/test.ts @@ -125,6 +125,17 @@ describe("components/input/local", () => { expect(result).toBe(null); }); + it("artwork returns null", async () => { + const result = await testWeb(async () => { + const mod = await import("~/components/input/local/element.js"); + const input = new mod.CLASS(); + document.body.append(input); + return await input.artwork("local://tid-aaa/track.mp3"); + }); + + expect(result).toBe(null); + }); + it("groupConsult returns empty object for unknown TIDs", async () => { const result = await testWeb(async () => { const mod = await import("~/components/input/local/element.js"); diff --git a/tests/components/input/opensubsonic/test.ts b/tests/components/input/opensubsonic/test.ts index a80d95e5..5a1e08b7 100644 --- a/tests/components/input/opensubsonic/test.ts +++ b/tests/components/input/opensubsonic/test.ts @@ -89,6 +89,19 @@ describe("components/input/opensubsonic", () => { expect(remaining.length).toBe(1); }); + it("artwork returns null", async () => { + const result = await testWeb(async () => { + const mod = await import("~/components/input/opensubsonic/element.js"); + const input = new mod.CLASS(); + document.body.append(input); + return await input.artwork( + "opensubsonic://user:pass@subsonic.example.com?songId=123&tls=t", + ); + }); + + expect(result).toBe(null); + }); + it("detach with specific server URI removes only matching tracks", async () => { const remaining = await testWeb(async () => { const mod = await import("~/components/input/opensubsonic/element.js"); diff --git a/tests/components/input/s3/test.ts b/tests/components/input/s3/test.ts index 6aaa6e6d..b65c7078 100644 --- a/tests/components/input/s3/test.ts +++ b/tests/components/input/s3/test.ts @@ -120,6 +120,19 @@ describe("components/input/s3", () => { expect(remaining[0].id).toBe("2"); }); + it("artwork returns null", async () => { + const result = await testWeb(async () => { + const mod = await import("~/components/input/s3/element.js"); + const input = new mod.CLASS(); + document.body.append(input); + return await input.artwork( + "s3://key:secret@s3.amazonaws.com/music/track1.mp3?bucketName=my-bucket®ion=us-east-1", + ); + }); + + expect(result).toBe(null); + }); + it("sources returns labels with bucket name and host", async () => { const sources = await testWeb(async () => { const mod = await import("~/components/input/s3/element.js"); -- 2.51.2