- music landing: diverse per-genre album sliders (online counts / offline wide-probe fallback) and home-screen library shortcuts - add ArtistLinks component and shared navigation/genreDiversity utils - player/playback-mode refinements across Rust and frontend
96 lines
3.6 KiB
TypeScript
96 lines
3.6 KiB
TypeScript
// Selecting a *diverse* set of genres for the music landing page.
|
||
//
|
||
// The naive approach ("keep the genres with the most albums") tends to surface
|
||
// a cluster of near-synonyms — "Rock", "Hard Rock", "Classic Rock", "Pop Rock"
|
||
// — because big umbrella genres and their sub-genres are all populous. The
|
||
// result reads as one genre repeated, not a tour of the library.
|
||
//
|
||
// Instead we pick greedily for *spread*: start from the most populous genre,
|
||
// then repeatedly add whichever remaining genre is least similar to everything
|
||
// already chosen (a max-min / farthest-point selection). Similarity is word-
|
||
// token overlap (Jaccard), so "Hard Rock" stays close to "Rock" but far from
|
||
// "Jazz" or "Hip Hop". Count still acts as a gentle tie-breaker so we don't
|
||
// promote a one-album novelty genre over a healthy distinct one.
|
||
|
||
/** Anything with a name and a relative weight (album count) we can rank by. */
|
||
export interface DiversityCandidate {
|
||
name: string;
|
||
}
|
||
|
||
/**
|
||
* Sample up to `count` items at an even stride across `items`. Genre lists come
|
||
* back alphabetical, so taking the first N would only ever surface A-genres;
|
||
* striding spreads the sample A→Z. Always includes the first item.
|
||
*/
|
||
export function sampleAcross<T>(items: T[], count: number): T[] {
|
||
if (items.length <= count) return items.slice();
|
||
const stride = Math.max(1, Math.floor(items.length / count));
|
||
return items.filter((_, i) => i % stride === 0).slice(0, count);
|
||
}
|
||
|
||
/** Split a genre name into a set of lowercased word tokens. */
|
||
function tokenize(name: string): Set<string> {
|
||
return new Set(
|
||
name
|
||
.toLowerCase()
|
||
.split(/[^a-z0-9]+/)
|
||
.filter(Boolean)
|
||
);
|
||
}
|
||
|
||
/** Jaccard similarity of two token sets: |A∩B| / |A∪B|, in [0, 1]. */
|
||
function jaccard(a: Set<string>, b: Set<string>): number {
|
||
if (a.size === 0 && b.size === 0) return 1;
|
||
let intersection = 0;
|
||
for (const t of a) if (b.has(t)) intersection++;
|
||
const union = a.size + b.size - intersection;
|
||
return union === 0 ? 0 : intersection / union;
|
||
}
|
||
|
||
/**
|
||
* Pick up to `limit` genres that are textually distinct from one another,
|
||
* preferring more populous genres. Input order is treated as the count ranking
|
||
* (most albums first); ties in distance fall back to that order.
|
||
*/
|
||
export function selectDiverseGenres<T extends DiversityCandidate>(
|
||
candidates: T[],
|
||
limit: number
|
||
): T[] {
|
||
if (candidates.length <= limit) return candidates.slice();
|
||
|
||
const tokens = candidates.map(c => tokenize(c.name));
|
||
const chosen: number[] = [];
|
||
const remaining = new Set(candidates.map((_, i) => i));
|
||
|
||
// Seed with the most populous genre (candidates[0]).
|
||
chosen.push(0);
|
||
remaining.delete(0);
|
||
|
||
while (chosen.length < limit && remaining.size > 0) {
|
||
let best = -1;
|
||
// The best candidate is the one *least* similar to its most-similar chosen
|
||
// member. We track that max-similarity and minimise it: a genre is only
|
||
// "diverse" if it resembles *nothing* already chosen, so looking at the
|
||
// single nearest neighbour (as plain farthest-point does) isn't enough —
|
||
// "Hard Rock" must stay close to "Rock" even after unrelated "Jazz" is in.
|
||
let bestMaxSim = Infinity;
|
||
for (const i of remaining) {
|
||
let maxSim = 0;
|
||
for (const c of chosen) {
|
||
const sim = jaccard(tokens[i], tokens[c]);
|
||
if (sim > maxSim) maxSim = sim;
|
||
}
|
||
// Lower max-similarity wins; on a tie keep the earlier (more populous)
|
||
// one, guaranteed because `remaining` iterates in insertion order.
|
||
if (maxSim < bestMaxSim) {
|
||
bestMaxSim = maxSim;
|
||
best = i;
|
||
}
|
||
}
|
||
chosen.push(best);
|
||
remaining.delete(best);
|
||
}
|
||
|
||
return chosen.map(i => candidates[i]);
|
||
}
|