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} + +
+ +