From cb9d6bc4b7363405efab98a8bfc8883d12bc88ef Mon Sep 17 00:00:00 2001 From: Steven Vandevelde Date: Sat, 5 Sep 2026 21:42:27 +0200 Subject: [PATCH] feat: deduplicate tracks feature --- src/_data/facets.json | 7 ++ .../deduplicate-tracks/prelude/index.html | 1 + .../prelude/index.inline.js | 96 +++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 src/facets/browsing/deduplicate-tracks/prelude/index.html create mode 100644 src/facets/browsing/deduplicate-tracks/prelude/index.inline.js diff --git a/src/_data/facets.json b/src/_data/facets.json index 60cd9129..5d859898 100644 --- a/src/_data/facets.json +++ b/src/_data/facets.json @@ -147,6 +147,13 @@ "tags": ["base"], "desc": "The default setup for track artwork retrieval. Adds support for: embedded audio metadata, Last.fm, and MusicBrainz." }, + { + "url": "facets/browsing/deduplicate-tracks/prelude/index.html", + "title": "Deduplicate Tracks", + "kind": "prelude", + "category": "Browsing", + "desc": "Hide duplicate tracks (same artist and same title) from the presented tracks. Your saved data is not affected." + }, { "url": "facets/data/input-bundle/index.html", "title": "Default Input Bundle", diff --git a/src/facets/browsing/deduplicate-tracks/prelude/index.html b/src/facets/browsing/deduplicate-tracks/prelude/index.html new file mode 100644 index 00000000..859ee040 --- /dev/null +++ b/src/facets/browsing/deduplicate-tracks/prelude/index.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/facets/browsing/deduplicate-tracks/prelude/index.inline.js b/src/facets/browsing/deduplicate-tracks/prelude/index.inline.js new file mode 100644 index 00000000..9a0cd294 --- /dev/null +++ b/src/facets/browsing/deduplicate-tracks/prelude/index.inline.js @@ -0,0 +1,96 @@ +import foundation from "~/common/foundation.js"; +import { computed, effect } from "~/common/signal.js"; + +/** + * @import {Track} from "~/definitions/types.d.ts" + */ + +// Marker used to avoid wrapping the same orchestrator instance more than once. +const PATCHED = Symbol("diffuse.deduplicate-tracks.patched"); + +/** + * Returns a normalised key for a track's artist + title, or `null` when the + * track cannot be deduplicated (it's missing either tag). + * + * @param {Track} track + * @returns {string | null} + */ +function trackKey(track) { + const artist = track.tags?.artist?.trim().toLowerCase(); + const title = track.tags?.title?.trim().toLowerCase(); + if (!artist || !title) return null; + return `${artist}\u0000${title}`; +} + +/** + * Removes duplicate tracks (same artist + same title) from a list, keeping the + * first occurrence of each pair. Tracks without an artist or title always pass + * through untouched. A shared `seen` set can be passed so that deduplication + * is consistent across multiple lists (e.g. grouped tracks). + * + * @param {Track[]} tracks + * @param {Set} [seen] + * @returns {Track[]} + */ +function deduplicate(tracks, seen = new Set()) { + /** @type {Track[]} */ + const result = []; + + for (const track of tracks) { + const key = trackKey(track); + if (key === null) { + result.push(track); + continue; + } + + if (seen.has(key)) continue; + + seen.add(key); + result.push(track); + } + + return result; +} + +/** + * The `do-scoped-tracks` orchestrator is the single source that all theme + * browsers read their "presented tracks" from, via its `tracks()` and + * `groups()` signal readers. This feature facet replaces those two signals + * with derived computed signals that deduplicate at runtime — it never + * modifies the underlying data or any component source. Only the tracks shown + * in the UI are affected; the saved `tracks` collection stays intact. + */ +effect(() => { + const element = foundation.signals.orchestrator.scopedTracks(); + if (!element) return; + + // The orchestrator is typed by its public readers; the runtime patch works + // on the element instance, so keep a loosely-typed handle for it. + const target = /** @type {any} */ (element); + if (target[PATCHED]) return; + + Object.defineProperty(target, PATCHED, { value: true }); + + // `tracks` and `groups` are signals on the orchestrator, so they're re-wrapped + // as derived computed signals rather than plain functions. Reading the + // original reader inside the computed keeps the dependency graph intact there. + if (typeof target.tracks === "function") { + const originalTracks = target.tracks; + target.tracks = computed(() => deduplicate(originalTracks())); + } + + if (typeof target.groups === "function") { + const originalGroups = target.groups; + target.groups = computed(() => { + const groups = originalGroups(); + if (!groups) return groups; + // A single `seen` set across all groups keeps the whole presented set + // consistent: a (artist, title) pair is shown at most once, no matter + // which group(s) it would otherwise appear in. + const seen = new Set(); + return groups.map((/** @type {{ tracks: Track[] }} */ group) => { + return { ...group, tracks: deduplicate(group.tracks, seen) }; + }); + }); + } +}); \ No newline at end of file -- 2.51.2