import type { Artist, Track } from './sources'; export type Knobs = { /** * Where the draw sits on the artist-spread span, not which artists get * picked — pickArtists() samples uniformly regardless of this value. 0 * draws fewer artists with more tracks each (tighter, marginally more * precise); 100 draws close to one track per artist, matching the * reference playlists' texture and the shipped default. Feeds * artistsNeeded(), which sets how many artists pickArtists() draws. */ adventurousness: number; /** * Where in each artist's own catalogue to draw from: 0 = their biggest hit, * 100 = the deepest cut available. Measured per artist, so it produces deep * cuts by well-known acts rather than hits by obscure ones. */ devotion: number; /** How hard to penalise artists whose audience is larger than the roster median. */ popularityPenalty: number; maxPerArtist: number; /** * 'multi' is a hard gate, but few artists clear it: Deezer only returns ~13-20 * neighbours against ListenBrainz's ~100, so the overlap is small. 'boost' * keeps everyone and just ranks agreeing artists higher. */ agreement: 'any' | 'boost' | 'multi'; }; export type Filters = { yearFrom: number; yearTo: number; durationMin: number; durationMax: number; popularityMin: number; // percentile within the candidate pool popularityMax: number; loudnessMin: number; // 0-100, mapped from ReplayGain loudnessMax: number; bpmMin: number; bpmMax: number; allowUnknownBpm: boolean; explicit: 'allow' | 'exclude' | 'only'; seedShare: number; // percent of the playlist allowed to be the seed artist targetCount: number; }; /** * Artist spread the playlist aims for. The 16 reference playlists carry 57-78 * distinct artists across 85-99 tracks — about 1.37 tracks per artist, with 79% * of artists holding exactly one track. Deriving the draw from the per-artist * cap instead produced 36 artists at 2.73 tracks each, a different kind of * playlist entirely: measured recall rose from 11.8% to 19.7% on switching to * this target, at no cost in precision and no additional requests. * * `adventurousness` picks where on that span the draw lands: 0 targets * TIGHT_TRACKS_PER_ARTIST (fewer artists, more tracks each), 100 targets * SPREAD_TRACKS_PER_ARTIST — the shipped default before this knob did * anything, and still the default position. */ const TIGHT_TRACKS_PER_ARTIST = 3; const SPREAD_TRACKS_PER_ARTIST = 1.2; const spreadTarget = (adventurousness: number) => TIGHT_TRACKS_PER_ARTIST - (adventurousness / 100) * (TIGHT_TRACKS_PER_ARTIST - SPREAD_TRACKS_PER_ARTIST); /** * How many artists must be drawn to fill the playlist. The per-artist cap stays * a hard ceiling but only binds when it is below the spread target; the buffer * covers artists whose tracks get filtered away. */ export const artistsNeeded = (targetCount: number, maxPerArtist: number, adventurousness: number) => Math.ceil((targetCount / Math.min(Math.max(1, maxPerArtist), spreadTarget(adventurousness))) * 1.2); /** Seeded RNG so a given shuffle token reproduces the same playlist. */ function rng(seed: number) { return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** * ReplayGain is a correction value, so a louder (more compressed) master gets a * more negative number. Flip it into a 0-100 scale where 100 reads as loudest. */ export const loudnessOf = (gain?: number) => { if (gain === undefined) return undefined; const scaled = (-gain / 20) * 100; // gain 0 -> 0, gain -20 -> 100 return Math.max(0, Math.min(100, scaled)); }; /** * Draws `count` artists from the fused ranking, uniformly at random. A * rank-weighted sample and a deterministic top-N sampler were both measured * statistically indistinguishable from a uniform draw at a 90-artist pool — * rank order inside the roster carries no usable signal, so there is nothing * for a smarter (or adventurousness-biased) sampler to exploit. `count` is * what actually shapes the artist set; see `artistsNeeded()`. */ export function pickArtists( similar: Artist[], knobs: Knobs, count: number, shuffle: number, ): Artist[] { let eligible = similar.filter((a) => knobs.agreement === 'multi' ? a.sources.length >= 2 : true, ); if (knobs.agreement === 'boost') { eligible = [...eligible].sort( (a, b) => b.score * (1 + 0.5 * (b.sources.length - 1)) - a.score * (1 + 0.5 * (a.sources.length - 1)), ); } if (!eligible.length) return []; const rand = rng(shuffle); const picked: Artist[] = []; const seen = new Set(); for (let guard = 0; picked.length < count && guard < count * 40; guard++) { const idx = Math.floor(eligible.length * rand()); const artist = eligible[Math.min(idx, eligible.length - 1)]; if (seen.has(artist.name)) continue; seen.add(artist.name); picked.push(artist); } return picked; } const percentiles = (values: number[]) => { const sorted = [...values].sort((a, b) => a - b); return (v: number) => { let lo = 0; let hi = sorted.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (sorted[mid] < v) lo = mid + 1; else hi = mid; } return sorted.length ? lo / sorted.length : 0; }; }; export type Selection = { tracks: Track[]; dropped: Record }; export function selectTracks( candidates: Track[], /** * One name, or several for a multi-seed generate. `seedShare` caps what * fraction of the playlist may come from this whole set combined, not from * each seed individually — a single running `seedUsed` counter below is * what makes that combined rather than per-seed. */ seedNames: string | string[], knobs: Knobs, filters: Filters, shuffle: number, /** * bpm, gain, and year only exist after enrichment. Applying those filters to * unenriched tracks would drop the whole pool, so the first pass skips them. */ detailsReady = true, ): Selection { const dropped: Record = {}; const drop = (reason: string) => { dropped[reason] = (dropped[reason] ?? 0) + 1; return false; }; // A Last.fm-only track (src/sources.ts lastFmTopTracks()) carries rank 0 // and, when Last.fm logged none, duration 0 — sentinels for "no data", // read the same way selectTracks() already reads a missing year or bpm: a // track with nothing to filter on passes that filter rather than failing // it. Deezer's own rank and duration are never 0 in practice, so a // Deezer-covered generate filters exactly as before. const rankPct = percentiles(candidates.filter((t) => t.rank > 0).map((t) => t.rank)); const passing = candidates.filter((t) => { if ( t.duration > 0 && (t.duration < filters.durationMin || t.duration > filters.durationMax) ) return drop('duration'); if (t.rank > 0) { const pop = rankPct(t.rank) * 100; if (pop < filters.popularityMin || pop > filters.popularityMax) return drop('popularity'); } if (detailsReady) { if (t.year !== undefined && (t.year < filters.yearFrom || t.year > filters.yearTo)) return drop('year'); const loud = loudnessOf(t.gain); if (loud !== undefined && (loud < filters.loudnessMin || loud > filters.loudnessMax)) return drop('loudness'); if (t.bpm === undefined) { if (!filters.allowUnknownBpm) return drop('bpm unknown'); } else if (t.bpm < filters.bpmMin || t.bpm > filters.bpmMax) { return drop('bpm'); } } if (filters.explicit === 'exclude' && t.explicit) return drop('explicit'); if (filters.explicit === 'only' && !t.explicit) return drop('not explicit'); return true; }); // Devotion is a preference, not a gate: score each track by how close its // position in its own artist's catalogue sits to where the knob is set. The // jitter is deliberately small — at 0.35 it swamped the signal entirely and // selection was effectively random within the pool. const target = knobs.devotion / 100; const rand = rng(shuffle + 977); const scored = passing .map((t) => { const affinity = 1 - Math.abs(t.depth - target); return { track: t, weight: affinity + rand() * 0.08 }; }) .sort((a, b) => b.weight - a.weight); const seedCap = Math.round((filters.seedShare / 100) * filters.targetCount); const seedSet = new Set( (Array.isArray(seedNames) ? seedNames : [seedNames]).map((s) => s.toLowerCase()), ); const perArtist = new Map(); const out: Track[] = []; let seedUsed = 0; for (const { track } of scored) { if (out.length >= filters.targetCount) break; const isSeed = seedSet.has(track.artist.toLowerCase()); if (isSeed && seedUsed >= seedCap) { drop('seed share'); continue; } const used = perArtist.get(track.artist) ?? 0; if (used >= knobs.maxPerArtist) { drop('artist cap'); continue; } perArtist.set(track.artist, used + 1); if (isSeed) seedUsed++; out.push(track); } return { tracks: out, dropped }; } export const toCsv = (tracks: Track[]) => ['artist,title,album,year,duration,bpm,rank'] .concat( tracks.map((t) => [t.artist, t.title, t.album, t.year ?? '', t.duration, t.bpm ?? '', t.rank] .map((v) => `"${String(v).replace(/"/g, '""')}"`) .join(','), ), ) .join('\n'); /** * YouTube's search HTML and youtubei endpoint send no CORS headers, so results * can't be fetched from the browser — this is a plain search-page navigation * instead, which needs no key and can't be rate-limited. */ export const youtubeSearchUrl = (track: Track) => `https://www.youtube.com/results?search_query=${encodeURIComponent(`${track.artist} ${track.title}`)}`; /** Same reasoning as youtubeSearchUrl: SoundCloud's api-v2 sends no CORS headers either. */ export const soundcloudSearchUrl = (track: Track) => `https://soundcloud.com/search/sounds?q=${encodeURIComponent(`${track.artist} ${track.title}`)}`; /** * Plain "Artist - Title" per line — this is the interchange format the * playlist-transfer services (Soundiiz, TuneMyMusic, etc.) ingest, not a debug * dump. */ export const toText = (tracks: Track[]) => tracks.map((t) => `${t.artist} - ${t.title}`).join('\n'); export type M3u = { m3u: string; skipped: number }; /** * Extended M3U pointing at Deezer's preview clips. Deezer previews are ~30 * seconds, so this file auditions the playlist rather than playing it in * full. Tracks with no preview are skipped outright — an #EXTINF with no * following URL is a malformed playlist entry — and the skip count is * returned so the caller can tell the listener what got left out. */ export function toM3u(tracks: Track[]): M3u { let skipped = 0; const lines = ['#EXTM3U']; for (const t of tracks) { if (!t.preview) { skipped++; continue; } lines.push(`#EXTINF:${t.duration},${t.artist} - ${t.title}`); lines.push(t.preview); } return { m3u: lines.join('\n'), skipped }; } /** * XML entity escaping. `&` must run first, or the entities this introduces * for `<`, `>`, `"`, `'` would themselves get escaped on a later pass. */ const escapeXml = (s: string) => s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); /** * Resolved YouTube video ids by track id — the shape usePlaylistPlayer()'s * resolveAll() hands back. Optional everywhere: an export without one is still * a valid playlist, just one whose locations stop at Deezer. */ export type VideoIds = ReadonlyMap; /** * Locations in preference order: the whole track on YouTube where it resolved, * then Deezer's 30-second preview clip, then the Deezer track page. A player * takes the first location it can handle, so the full track goes first. */ const locationsOf = (t: Track, videoIds?: VideoIds) => { const videoId = videoIds?.get(t.id); return [ videoId ? `https://www.youtube.com/watch?v=${videoId}` : null, t.preview, t.link, ].filter((v): v is string => !!v); }; /** Whether a track would carry any location at all in an XSPF/JSPF export. */ export const hasLocation = (t: Track, videoIds?: VideoIds) => locationsOf(t, videoIds).length > 0; /** * XSPF 1.0 playlist. Unlike toM3u(), no track is dropped — a track with * neither preview nor link is still emitted, just with an empty locationless * entry, since XSPF allows a track with no . */ export function toXspf(tracks: Track[], playlistTitle: string, videoIds?: VideoIds): string { const trackXml = tracks .map((t) => { const parts: string[] = []; for (const loc of locationsOf(t, videoIds)) parts.push(` ${escapeXml(loc)}`); if (t.link) parts.push(` ${escapeXml(t.link)}`); parts.push(` ${escapeXml(t.title)}`); parts.push(` ${escapeXml(t.artist)}`); if (t.album) parts.push(` ${escapeXml(t.album)}`); // Track.duration is in SECONDS (a Deezer field); XSPF wants milliseconds. parts.push(` ${Math.round(t.duration * 1000)}`); return ` \n${parts.join('\n')}\n `; }) .join('\n'); return [ '', '', ` ${escapeXml(playlistTitle)}`, ' ', trackXml, ' ', '', ].join('\n'); } /** * JSPF: the JSON form of the same model as toXspf(). Unlike XSPF's * ``/``, JSPF's `identifier` and `location` are arrays * of strings; empty fields are omitted rather than emitted as null. */ export function toJspf(tracks: Track[], playlistTitle: string, videoIds?: VideoIds): string { const track = tracks.map((t) => { const entry: Record = { creator: t.artist, title: t.title, }; if (t.album) entry.album = t.album; // Track.duration is in SECONDS (a Deezer field); JSPF wants milliseconds. entry.duration = Math.round(t.duration * 1000); if (t.link) entry.identifier = [t.link]; const location = locationsOf(t, videoIds); if (location.length) entry.location = location; return entry; }); return JSON.stringify({ playlist: { title: playlistTitle, track } }, null, 2); }