From 019be42b23f3853259b43cd64abf6bf87d64556f Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Thu, 13 Aug 2026 08:22:40 +0100 Subject: [PATCH] feat(tourmaline): add "You might like" recommendations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces Last.fm similar-artist data (ArtistInfo.similar) that was already being fetched during enrichment but thrown away — no new API calls. Ranks candidates by how many of the listener's top 25 artists recommend them, filters out anything already in the listener's own library. Also makes the profile page reveal progressively instead of hiding everything behind a spinner until enrichment fully completes: content now shows as soon as the first pass computes (still-'Unknown'-heavy genres/mood included), with a small non-blocking banner surfacing enrichment progress. Recommendations specifically show skeleton rows for pending slots that fill in with real artists as enrichment progresses — the artist data the feature depends on is genuinely growing in the background, so the loading state now reflects that instead of an opaque wait. Co-Authored-By: Claude Sonnet 5 --- .../src/lib/analysis/recommendations.ts | 64 +++++++++++++++++++ .../tourmaline/src/lib/client/load-profile.ts | 2 + .../src/lib/components/Skeleton.svelte | 19 ++++++ packages/tourmaline/src/lib/types.ts | 2 + .../src/routes/profile/[did]/+page.svelte | 46 +++++++------ .../profile/[did]/Recommendations.svelte | 60 +++++++++++++++++ .../src/routes/profile/[did]/TasteTab.svelte | 7 +- 7 files changed, 181 insertions(+), 19 deletions(-) create mode 100644 packages/tourmaline/src/lib/analysis/recommendations.ts create mode 100644 packages/tourmaline/src/lib/components/Skeleton.svelte create mode 100644 packages/tourmaline/src/routes/profile/[did]/Recommendations.svelte diff --git a/packages/tourmaline/src/lib/analysis/recommendations.ts b/packages/tourmaline/src/lib/analysis/recommendations.ts new file mode 100644 index 0000000..dafb365 --- /dev/null +++ b/packages/tourmaline/src/lib/analysis/recommendations.ts @@ -0,0 +1,64 @@ +/** + * "You might like" recommendations, built entirely from data already fetched + * during artist enrichment (Last.fm similar-artist data via ArtistInfo.similar) + * — no extra API calls. + */ +import type { ArtistInfo } from "$lib/types"; + +export interface Recommendation { + name: string; + mbId?: string; + /** The listener's own top artists that surfaced this recommendation, most-played first. */ + recommendedBy: string[]; + /** Number of the listener's top artists that recommend this one. */ + score: number; +} + +/** How many of the listener's top artists to draw similar-artist candidates from. */ +const MAX_SOURCE_ARTISTS = 25; +const MAX_RECOMMENDATIONS = 20; + +/** + * Recommend artists the listener doesn't already listen to, ranked by how + * many of their top artists point to each candidate as similar. + */ +export function buildRecommendations( + topArtists: Array<{ name: string; count: number }>, + allArtists: string[], + artistInfos: Map, +): Recommendation[] { + const known = new Set(allArtists.map((a) => a.toLowerCase())); + const candidates = new Map(); + + const sourceArtists = topArtists.slice(0, MAX_SOURCE_ARTISTS); + + for (const { name: sourceName } of sourceArtists) { + const info = artistInfos.get(sourceName); + if (!info?.similar?.length) continue; + + for (const sim of info.similar) { + if (!sim.name) continue; + const key = sim.name.toLowerCase(); + if (known.has(key)) continue; // already in the listener's library + + const existing = candidates.get(key); + if (existing) { + existing.score++; + if (!existing.recommendedBy.includes(sourceName)) { + existing.recommendedBy.push(sourceName); + } + } else { + candidates.set(key, { + name: sim.name, + mbId: sim.mbId, + recommendedBy: [sourceName], + score: 1, + }); + } + } + } + + return [...candidates.values()] + .sort((a, b) => b.score - a.score) + .slice(0, MAX_RECOMMENDATIONS); +} diff --git a/packages/tourmaline/src/lib/client/load-profile.ts b/packages/tourmaline/src/lib/client/load-profile.ts index de0b77c..81453b3 100644 --- a/packages/tourmaline/src/lib/client/load-profile.ts +++ b/packages/tourmaline/src/lib/client/load-profile.ts @@ -32,6 +32,7 @@ import { buildStoryRecap } from "$lib/analysis/story-recap"; import { buildPersonality } from "$lib/analysis/personality"; import { filterScrobbles, presetRange } from "$lib/analysis/date-range"; import { topZScorePerMonth } from "$lib/analysis/zscore"; +import { buildRecommendations } from "$lib/analysis/recommendations"; export type RangeKey = "all" | "7d" | "30d" | "90d" | "365d"; export const RANGES: RangeKey[] = ["all", "7d", "30d", "90d", "365d"]; @@ -157,6 +158,7 @@ export function computeProfile( trackMilestones: data.trackMilestones, albumMilestones: data.albumMilestones, longestNotListenedGap: data.longestNotListenedGap, + recommendations: buildRecommendations(data.topArtists, data.allArtists, artistInfos), }; const sessions = deriveSessions(filtered); diff --git a/packages/tourmaline/src/lib/components/Skeleton.svelte b/packages/tourmaline/src/lib/components/Skeleton.svelte new file mode 100644 index 0000000..14b3a6d --- /dev/null +++ b/packages/tourmaline/src/lib/components/Skeleton.svelte @@ -0,0 +1,19 @@ + + + diff --git a/packages/tourmaline/src/lib/types.ts b/packages/tourmaline/src/lib/types.ts index ff6856d..dd4cf32 100644 --- a/packages/tourmaline/src/lib/types.ts +++ b/packages/tourmaline/src/lib/types.ts @@ -1,4 +1,5 @@ import type { ListeningPhase } from "$lib/analysis/phases"; +import type { Recommendation } from "$lib/analysis/recommendations"; export interface TealScrobble { trackName: string; @@ -135,6 +136,7 @@ export interface ListenerProfile { trackMilestones: Milestone[]; albumMilestones: Milestone[]; longestNotListenedGap: Gap | null; + recommendations: Recommendation[]; } export interface UnusualMonth { diff --git a/packages/tourmaline/src/routes/profile/[did]/+page.svelte b/packages/tourmaline/src/routes/profile/[did]/+page.svelte index 0344b10..9add04b 100644 --- a/packages/tourmaline/src/routes/profile/[did]/+page.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/+page.svelte @@ -38,6 +38,7 @@ import CatalogueTab from './CatalogueTab.svelte'; import PunchcardHeatmap from './PunchcardHeatmap.svelte'; import EddingtonChart from './EddingtonChart.svelte'; + import Recommendations from './Recommendations.svelte'; function noiseAvatar(canvas: HTMLCanvasElement, seed: string) { renderNoiseAvatar(canvas, seed, { displaySize: 32, gridSize: 5 }); @@ -158,8 +159,8 @@ - - {#if phase !== 'complete' && phase !== 'error'} + + {#if phase === 'fetching' || phase === 'idle'}
@@ -167,29 +168,31 @@
-

- {phase === 'fetching' ? 'Fetching scrobbles' : phase === 'computing' ? 'Computing profile' : 'Enriching artist data…'} -

-

- {phase === 'fetching' ? `${loaded.toLocaleString()} loaded` : 'This may take a moment…'} -

+

Fetching scrobbles

+

{loaded.toLocaleString()} loaded

- {#if phase === 'enriching' && enrichProgress.total > 0} -
- {:else} -
- {/if} -
+
+
{/if} + + {#if (phase === 'computing' || phase === 'enriching') && profile} +
+ +

+ {phase === 'computing' + ? 'Computing profile…' + : `Enriching artist data — ${enrichProgress.current.toLocaleString()}/${enrichProgress.total.toLocaleString()}. Genres, mood, and recommendations will keep filling in.`} +

+
+ {/if} + {#if phase === 'error'}
@@ -207,7 +210,7 @@ {/if} - {#if phase === 'complete' && profile && profile.totalScrobbles > 0} + {#if phase !== 'fetching' && phase !== 'idle' && phase !== 'error' && profile && profile.totalScrobbles > 0}
@@ -360,6 +363,13 @@
{/if} +
+ +
+ {:else if activeTab === 'habits'} {#if profile.scrobblesByHour.some((n) => n > 0)} diff --git a/packages/tourmaline/src/routes/profile/[did]/Recommendations.svelte b/packages/tourmaline/src/routes/profile/[did]/Recommendations.svelte new file mode 100644 index 0000000..916a8d9 --- /dev/null +++ b/packages/tourmaline/src/routes/profile/[did]/Recommendations.svelte @@ -0,0 +1,60 @@ + + +{#if shown.length > 0 || loading} +
+
+ +

You might like

+
+

+ Artists similar to ones you already listen to, that don't show up in your own scrobbles yet. +

+ +
    + {#each shown as rec (rec.name)} +
  • +
    +

    {rec.name}

    +

    + because you listen to {rec.recommendedBy.slice(0, 2).join(', ')}{rec.recommendedBy.length > 2 ? ` +${rec.recommendedBy.length - 2} more` : ''} +

    +
    + + {rec.score} + +
  • + {/each} + + {#each Array(skeletonCount) as _, i (i)} +
  • +
    + +
    + +
    +
    + +
  • + {/each} +
+
+{/if} diff --git a/packages/tourmaline/src/routes/profile/[did]/TasteTab.svelte b/packages/tourmaline/src/routes/profile/[did]/TasteTab.svelte index 317e32c..1c5457e 100644 --- a/packages/tourmaline/src/routes/profile/[did]/TasteTab.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/TasteTab.svelte @@ -6,9 +6,10 @@ import ListeningPhases from './ListeningPhases.svelte'; import RemarkableDays from './RemarkableDays.svelte'; import Milestones from './Milestones.svelte'; + import Recommendations from './Recommendations.svelte'; import type { ListenerProfile } from '$lib/types'; - let { profile }: { profile: ListenerProfile } = $props(); + let { profile, loading = false }: { profile: ListenerProfile; loading?: boolean } = $props();
@@ -62,3 +63,7 @@ />
{/if} + +
+ +
-- 2.51.2