From e28abced058549c72e09a0284aa1b0b981ba5d67 Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Mon, 12 Oct 2026 14:47:16 +0200 Subject: [PATCH] feat: support levels in path-collections component --- src/_data/elements.js | 2 +- src/common/foundation.js | 10 +++- .../orchestrator/path-collections/element.js | 52 ++++++++++++++++--- .../orchestrator/path-collections/test.ts | 49 +++++++++++++++++ 4 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/_data/elements.js b/src/_data/elements.js index df79b586..2af3b8c3 100644 --- a/src/_data/elements.js +++ b/src/_data/elements.js @@ -217,7 +217,7 @@ export default { url: "components/orchestrator/path-collections/element.js", title: "Path Collections", desc: - "Wraps an output element to generate ephemeral playlists based on the first path segment of each track's URI. Ephemeral items are excluded from storage.", + "Wraps an output element to generate ephemeral playlists based on a path part of each track's URI. The 1-based part can be set using the `path-part` attribute (defaults to the first). Ephemeral items are excluded from storage.", }, { url: "components/orchestrator/process-tracks/element.js", diff --git a/src/common/foundation.js b/src/common/foundation.js index 5c95dd2c..d191b8b3 100644 --- a/src/common/foundation.js +++ b/src/common/foundation.js @@ -548,7 +548,11 @@ async function spectrogramAudio() { return findExistingOrAdd(sao, signals.orchestrator.spectrogramAudio); } -async function pathCollections() { +/** + * @param {Object} [options] - Options + * @param {number} [options.pathPart] - 1-based path part of each track's URI to group playlists by. Defaults to 1. + */ +async function pathCollections(options) { const [{ CLASS: PathCollectionsOrchestrator }, o] = await Promise.all([ import("~/components/orchestrator/path-collections/element.js"), output(), @@ -558,6 +562,10 @@ async function pathCollections() { pco.setAttribute("group", GROUP); pco.setAttribute("output-selector", o.selector); + if (options?.pathPart) { + pco.setAttribute("path-part", String(options.pathPart)); + } + return findExistingOrAdd(pco, signals.orchestrator.pathCollections); } diff --git a/src/components/orchestrator/path-collections/element.js b/src/components/orchestrator/path-collections/element.js index 90b5686b..4ec8e3bd 100644 --- a/src/components/orchestrator/path-collections/element.js +++ b/src/components/orchestrator/path-collections/element.js @@ -1,4 +1,4 @@ -import { computed } from "~/common/signal.js"; +import { computed, signal } from "~/common/signal.js"; import { OutputTransformer } from "~/components/transformer/output/base.js"; import { defineElement } from "~/common/element.js"; @@ -13,6 +13,11 @@ import { defineElement } from "~/common/element.js"; class PathCollectionsOrchestrator extends OutputTransformer { static NAME = "diffuse/orchestrator/path-collections"; + static observedAttributes = ["path-part"]; + + /** 1-based path part of each track's URI to group playlists by. Defaults to 1. */ + #pathPart = signal(1); + constructor() { super(); @@ -20,7 +25,10 @@ class PathCollectionsOrchestrator extends OutputTransformer { const ephemeralItems = computed(() => { const col = base.tracks.collection(); - return createEphemeralItems(col.state === "loaded" ? col.data : []); + return createEphemeralItems( + col.state === "loaded" ? col.data : [], + this.#pathPart.get(), + ); }); this.facets = base.facets; @@ -43,13 +51,26 @@ class PathCollectionsOrchestrator extends OutputTransformer { this.ready = base.ready; } + + /** + * @override + * @param {string} name + * @param {string} oldValue + * @param {string} newValue + */ + attributeChangedCallback(name, oldValue, newValue) { + super.attributeChangedCallback(name, oldValue, newValue); + + if (name === "path-part") this.#pathPart.set(parsePathPart(newValue)); + } } /** * @param {Track[]} tracks + * @param {number} pathPart * @returns {PlaylistItem[]} */ -function createEphemeralItems(tracks) { +function createEphemeralItems(tracks, pathPart) { /** @type {PlaylistItem[]} */ const items = []; @@ -57,7 +78,7 @@ function createEphemeralItems(tracks) { const segmentsAdded = new Set(); tracks.forEach((track) => { - const segment = pathSegment(track.uri); + const segment = pathSegment(track.uri, pathPart); if (!segment) return; if (segmentsAdded.has(segment)) return; @@ -80,18 +101,35 @@ function createEphemeralItems(tracks) { /** * @param {string} uri + * @param {number} pathPart * @returns {string | undefined} */ -function pathSegment(uri) { +function pathSegment(uri, pathPart) { try { const url = new URL(uri); - const segment = url.pathname.split("/").filter(Boolean)[0]; - return segment || undefined; + const segment = url.pathname.split("/").filter(Boolean)[pathPart - 1]; + + if (!segment) return undefined; + + try { + return decodeURIComponent(segment); + } catch { + return segment; + } } catch { return undefined; } } +/** + * @param {string | null} value + * @returns {number} + */ +function parsePathPart(value) { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isNaN(parsed) || parsed < 1 ? 1 : parsed; +} + export default PathCollectionsOrchestrator; //////////////////////////////////////////// diff --git a/tests/components/orchestrator/path-collections/test.ts b/tests/components/orchestrator/path-collections/test.ts index 38082e6d..cef80330 100644 --- a/tests/components/orchestrator/path-collections/test.ts +++ b/tests/components/orchestrator/path-collections/test.ts @@ -116,6 +116,55 @@ describe("components/orchestrator/path-collections", () => { expect(result).toEqual(["music"]); }); + it("supports grouping by a configurable path part, percent-decoded", async () => { + const result = await testWeb(async () => { + const idbMod = await import( + "~/components/output/polymorphic/indexed-db/element.js" + ); + const mod = await import( + "~/components/orchestrator/path-collections/element.js" + ); + + const output = new idbMod.CLASS(); + output.id = "test-idb-pathpart"; + document.body.append(output); + + const t = new mod.CLASS(); + t.setAttribute("path-part", "2"); + t.setAttribute("output-selector", "#test-idb-pathpart"); + document.body.append(t); + + await t.tracks.save([ + { + $type: "sh.diffuse.output.track", + id: "t1", + uri: + "https://example.com/Primary/techno%20.%20minimal%20.%20acid/album/track.mp3", + }, + { + $type: "sh.diffuse.output.track", + id: "t2", + uri: "https://example.com/Primary/ambient/album/track.mp3", + }, + { + $type: "sh.diffuse.output.track", + id: "t3", + uri: "https://example.com/onlyroot.mp3", + }, + ]); + await output.playlistItems.save([]); + + const col = t.playlistItems.collection(); + if (col.state !== "loaded") return null; + return (col.data as Array<{ ephemeral?: boolean; playlist: string }>) + .filter((p) => p.ephemeral) + .map((p) => p.playlist) + .sort(); + }); + + expect(result).toEqual(["ambient", "techno . minimal . acid"]); + }); + it("ephemeral items do not include tracks with URI that has no path segment", async () => { const result = await testWeb(async () => { const idbMod = await import( -- 2.54.0