Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/ewanc26/tourmaline. AT Protocol scrobble analyser — ingests Teal.fm scrobbles and builds a listener profile tourmaline.croft.click
analytics archived atproto monorepo-moved sveltekit
Something went wrong. Try again.
TypeScript 64%
Svelte 36%
CSS <1%
HTML <1%
JavaScript <1%
README.md
Tourmaline #
This repository is archived. Tourmaline has moved to the pkgs monorepo.
AT Protocol scrobble analyser. Ingests Teal.fm scrobbles from any handle or DID, cross-references with free music APIs, and builds a listener profile.
How it works #
- Enter a handle (e.g.
ewancroft.uk) or DID (did:plc:...ordid:web:...) - Resolves identity via Slingshot (handles → DIDs), then fetches the DID document for the PDS URL
- Fetches
fm.teal.alpha.feed.playrecords from the user's PDS - Aggregates play counts, timelines, and listening patterns
- Enriches artist data with MusicBrainz (genres), Last.fm (tags, similar artists), and Deezer (art, genres)
- Builds a listener profile: genre map, mood profile, diversity score, obscurity index, era preference, timeline heatmap
Setup #
pnpm install
Copy .env.example to .env and add your Last.fm API key (optional):
cp .env.example .env
Get a Last.fm API key at https://www.last.fm/api/account/create.
Development #
pnpm dev
Project structure #
src/
├── lib/
│ ├── atproto/
│ │ └── resolve.ts # Handle/DID resolution via Slingshot + scrobble fetching
│ ├── enrich/
│ │ ├── cache.ts # SQLite cache layer
│ │ ├── musicbrainz.ts # Genre + MBID enrichment
│ │ ├── lastfm.ts # Tag + similar artist enrichment
│ │ └── deezer.ts # Image + genre fallback
│ ├── analysis/
│ │ ├── aggregator.ts # Raw scrobble aggregation
│ │ ├── genres.ts # Genre classification
│ │ ├── timeline.ts # Time-of-day analysis
│ │ ├── era.ts # Release decade distribution
│ │ ├── diversity.ts # Shannon entropy + Gini coefficient
│ │ ├── obscurity.ts # Mainstream vs niche scoring
│ │ └── mood.ts # Mood extraction from tags
│ └── types.ts
├── routes/
│ ├── +page.svelte # Handle/DID input form
│ └── profile/[did]/
│ ├── +page.server.ts # Fetch + analyse + render
│ ├── +page.svelte # Profile display
│ ├── GenreChart.svelte
│ ├── TimelineHeatmap.svelte
│ ├── MoodRadar.svelte
│ └── EraBarChart.svelte
└── app.html
APIs used #
| API | Purpose | Auth | Rate limit |
|---|---|---|---|
| Slingshot | Handle → DID resolution | None | None |
| MusicBrainz | Genres, MBIDs, release dates | User-Agent | 1 req/sec |
| Last.fm | Tags, similar artists, listener counts | API key | Undocumented |
| Deezer | Artist images, genre fallback | None | Undocumented |
All responses cached in .cache/tourmaline.db (SQLite). Cache TTL: 30 days.
Listener profile #
- Genre map — weighted by play count, from MusicBrainz + Last.fm tags
- Mood profile — radar chart from tag keywords (Energetic, Melancholic, Chill, etc.)
- Diversity score — Shannon entropy normalised to 0–100
- Obscurity index — log-scaled Last.fm listener counts, 0 (mainstream) to 100 (deep cuts)
- Era preference — decade distribution from MusicBrainz release dates
- Timeline heatmap — hour × day listening patterns from scrobble timestamps
License: AGPL-3.0-only