Add recent listens, profiles, taste profiles, and recent playlists to the status page. Two-column responsive grid layout with progress bar for embeddings and relative timestamps throughout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
haunt-fm
Personal music recommendation engine that captures listening history from Music Assistant, discovers similar music via Last.fm, computes audio embeddings with CLAP, and generates playlists mixing known favorites with new discoveries — played back on house speakers via Apple Music.
How It Works
You play music on any speaker
→ HA automation logs the listen event
→ Last.fm discovers similar tracks (~50 per listened track)
→ iTunes Search API finds 30-second audio previews
→ CLAP model computes 512-dim audio embeddings
→ pgvector stores and indexes embeddings (HNSW cosine similarity)
→ Taste profile = weighted average of listened-track embeddings
→ Recommendations = closest unheard tracks by cosine similarity
→ Playlist mixes known favorites + new discoveries
→ Music Assistant plays it on speakers via Apple Music
Deployment
Runs on the NAS as two Docker containers:
| Container | Image | Port | Purpose |
|---|---|---|---|
haunt-fm |
Custom build | 8321 → 8000 | FastAPI app + embedding worker |
haunt-fm-db |
pgvector/pgvector:pg17 |
internal | PostgreSQL + pgvector |
# Deploy / rebuild
cd /volume1/homes/antialias/projects/haunt-fm
git pull && docker-compose up -d --build haunt-fm
# Run migrations
docker exec haunt-fm alembic upgrade head
Access:
- Status page: https://recommend.haunt.house
- Health check: http://192.168.86.51:8321/health
- API status: http://192.168.86.51:8321/api/status
- Source: https://git.dev.abaci.one/antialias/haunt-fm
API Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /health |
Health check (DB connectivity) |
| GET | /api/status |
Full pipeline status JSON |
| GET | / |
HTML status dashboard |
| POST | /api/history/webhook |
Log a listen event (from HA automation) |
| GET | /api/history/recent?limit=20&profile=name |
Recent listen events (optional profile filter) |
| POST | /api/admin/discover |
Expand listening history via Last.fm |
| POST | /api/admin/build-taste-profile |
Rebuild taste profile from embeddings |
| GET | /api/recommendations?limit=50&vibe=chill+ambient |
Get ranked recommendations (optional vibe) |
| POST | /api/playlists/generate |
Generate and optionally play a playlist |
| GET | /api/profiles |
List all named profiles with stats |
| POST | /api/profiles |
Create a named profile |
| GET | /api/profiles/{name} |
Get profile details + stats |
| DELETE | /api/profiles/{name} |
Delete profile (reassigns events to default) |
| PUT | /api/profiles/{name}/speakers |
Set speaker→profile mappings |
| GET | /api/profiles/{name}/speakers |
List speaker mappings |
Usage
Generate and play a playlist
curl -X POST http://192.168.86.51:8321/api/playlists/generate \
-H "Content-Type: application/json" \
-d '{
"total_tracks": 20,
"known_pct": 30,
"speaker_entity": "media_player.living_room_speaker_2",
"auto_play": true
}'
Generate a vibe-based playlist
curl -X POST http://192.168.86.51:8321/api/playlists/generate \
-H "Content-Type: application/json" \
-d '{
"total_tracks": 15,
"vibe": "chill ambient lo-fi",
"speaker_entity": "media_player.living_room_speaker_2",
"auto_play": true
}'
Parameters:
total_tracks— number of tracks in the playlist (default 20)known_pct— percentage of known-liked tracks vs new discoveries (default 30)speaker_entity— Music Assistant entity ID (must be a_2suffix entity)auto_play—trueto immediately play on the speakervibe— text description of the desired mood/vibe (e.g. "chill lo-fi beats", "upbeat party music"). Uses CLAP text embeddings to match tracks in the same vector space as audio.alpha— blend factor between taste profile and vibe (default 0.5).1.0= pure taste profile,0.0= pure vibe match,0.5= equal blend. Ignored when no vibe is provided.profile— named taste profile to use (default: "default"). Each profile has its own listening history and taste embedding.
Speaker entities
The speaker_entity must be a Music Assistant entity (the _2 suffix ones) for text search to resolve through Apple Music. Raw Cast entities cannot resolve search queries.
| Speaker | Entity ID |
|---|---|
| Living Room speaker | media_player.living_room_speaker_2 |
| Dining Room speaker | media_player.dining_room_speaker_2 |
| basement mini | media_player.basement_mini_2 |
| Kitchen stereo | media_player.kitchen_stereo_2 |
| Study speaker | media_player.study_speaker_2 |
| Butler's Pantry speaker | media_player.butlers_pantry_speaker_2 |
| Master bathroom speaker | media_player.master_bathroom_speaker_2 |
| Kids Room speaker | media_player.kids_room_speaker_2 |
| Guest bedroom speaker 2 | media_player.guest_bedroom_speaker_2_2 |
| Garage Wifi | media_player.garage_wifi_2 |
| Whole House | media_player.whole_house_2 |
| downstairs | media_player.downstairs_2 |
| upstairs | media_player.upstairs_2 |
Named profiles
Named profiles let each household member get personalized recommendations without polluting each other's taste.
# Create a profile
curl -X POST http://192.168.86.51:8321/api/profiles \
-H "Content-Type: application/json" \
-d '{"name":"antialias","display_name":"Me"}'
# Map speakers to auto-attribute listens
curl -X PUT http://192.168.86.51:8321/api/profiles/antialias/speakers \
-H "Content-Type: application/json" \
-d '{"speakers":["Study speaker","Master bathroom speaker"]}'
# Log a listen event with explicit profile
curl -X POST http://192.168.86.51:8321/api/history/webhook \
-H "Content-Type: application/json" \
-d '{"title":"Song","artist":"Artist","profile":"antialias"}'
# Get recommendations for a profile
curl "http://192.168.86.51:8321/api/recommendations?limit=20&profile=antialias"
# Generate playlist for a profile
curl -X POST http://192.168.86.51:8321/api/playlists/generate \
-H "Content-Type: application/json" \
-d '{"total_tracks":20,"profile":"antialias","speaker_entity":"media_player.study_speaker_2","auto_play":true}'
# Build taste profile manually
curl -X POST "http://192.168.86.51:8321/api/admin/build-taste-profile?profile=antialias"
All endpoints are backward compatible — omitting profile uses the "default" profile. Events with no profile assignment (including all existing events) belong to "default".
Other operations
# Log a listen event manually
curl -X POST http://192.168.86.51:8321/api/history/webhook \
-H "Content-Type: application/json" \
-d '{"title":"Paranoid Android","artist":"Radiohead","album":"OK Computer"}'
# Run Last.fm discovery (expand candidate pool)
curl -X POST http://192.168.86.51:8321/api/admin/discover \
-H "Content-Type: application/json" \
-d '{"limit": 50}'
# Rebuild taste profile
curl -X POST http://192.168.86.51:8321/api/admin/build-taste-profile
# Get recommendations (without playing)
curl http://192.168.86.51:8321/api/recommendations?limit=20
# Get vibe-matched recommendations
curl "http://192.168.86.51:8321/api/recommendations?limit=20&vibe=dark+electronic&alpha=0.3"
Pipeline Stages
- Listening History — HA automation POSTs to webhook when music plays on any Music Assistant speaker. Deduplicates events within 60 seconds.
- Discovery — Last.fm
track.getSimilarexpands each listened track to ~50 candidates. - Preview Lookup — iTunes Search API finds 30-second AAC preview URLs (rate-limited ~20 req/min).
- Embedding — Background worker downloads previews, runs CLAP model (
laion/larger_clap_music), stores 512-dim vectors in pgvector with HNSW index. - Taste Profile — Weighted average of listened-track embeddings (play count * recency decay).
- Recommendations — pgvector cosine similarity against taste profile, excluding known tracks.
- Playlist — Mix known-liked + new recommendations, interleave, play via Music Assistant.
Improving Recommendations Over Time
Recommendations improve as the system accumulates more data:
- Listen to music — every track played on any speaker is logged automatically
- Run discovery periodically —
POST /api/admin/discoverto expand the candidate pool via Last.fm - Rebuild taste profile —
POST /api/admin/build-taste-profileafter significant new listening activity - Embedding worker runs continuously — new candidates are automatically downloaded and embedded
The taste profile is a weighted average of all listened-track embeddings. More diverse listening history = more nuanced recommendations.
Tech Stack
| Component | Choice |
|---|---|
| App framework | FastAPI + SQLAlchemy async + Alembic |
| Database | PostgreSQL 17 + pgvector (HNSW cosine similarity) |
| Embedding model | CLAP laion/larger_clap_music (512-dim, PyTorch CPU) |
| Audio previews | iTunes Search API (free, no auth, 30s AAC) |
| Discovery | Last.fm track.getSimilar API |
| Playback | Music Assistant via Home Assistant REST API |
| Music catalog | Apple Music (via Music Assistant) |
| Reverse proxy | Traefik (recommend.haunt.house) |
Environment Variables
All prefixed with HAUNTFM_. Key ones:
| Variable | Purpose |
|---|---|
HAUNTFM_DATABASE_URL |
PostgreSQL connection string |
HAUNTFM_LASTFM_API_KEY |
Last.fm API key for discovery |
HAUNTFM_HA_URL |
Home Assistant URL |
HAUNTFM_HA_TOKEN |
Home Assistant long-lived access token |
HAUNTFM_EMBEDDING_WORKER_ENABLED |
Enable/disable background embedding worker |
HAUNTFM_EMBEDDING_BATCH_SIZE |
Tracks per batch (default 10) |
HAUNTFM_EMBEDDING_INTERVAL_SECONDS |
Seconds between batch checks (default 30) |
HAUNTFM_MODEL_CACHE_DIR |
CLAP model cache directory |
HAUNTFM_AUDIO_CACHE_DIR |
Downloaded preview cache directory |
See .env.example for full list.
Integrations
- Home Assistant — automation
haunt_fm_log_music_playcaptures listening history; REST API used for speaker playback - Music Assistant — resolves text search queries to Apple Music tracks, streams to Cast speakers
- OpenClaw — has a skill doc (
skills/haunt-fm/SKILL.md) so you can request playlists via Telegram/iMessage - Traefik — routes
recommend.haunt.houseto the service - Porkbun DNS — CNAME for
recommend.haunt.house