Music sync daemon
This page covers the music-sync daemon that runs locally on your Mac to capture Apple Music listening data, correlate it with flow scores, and maintain dynamic playlists in Apple Music.
Flow Control series
- Flow Control
- Architecture
- Cursor sync daemon
- Music sync daemon - You are here
- Health sync daemon (disabled)
- HealthSync iOS app (recommended)
- Deployment
Why a music daemon?
Music is a significant environmental factor during coding sessions, but no tool connects what you are listening to with your productivity data. I wanted to answer:
- "What was I listening to at flow score 9?"
- "Which songs correlate with rising flow?"
- "What genre am I most productive with?"
| Challenge | Solution |
|---|---|
| Apple Music has no export API | JXA (JavaScript for Automation) queries playback state |
| Cannot run from Kubernetes | Local daemon on Mac where Apple Music runs |
| Need accurate start/end times | 15-second polling with change detection |
| Must not lose events on crash | Graceful shutdown flushes buffered events |
How it works
Polling mechanism
The daemon uses osascript -l JavaScript to query Apple Music every 15 seconds. The JXA script runs inside a try/catch block so that if Music.app is not running, it returns { state: 'stopped' } instead of throwing an error.
Change detection
Each poll compares the current track key (name|artist|album) against the previous track. When the key changes or playback stops:
- The previous track is recorded with its start and end timestamps
- Plays shorter than 5 seconds are discarded (likely skips)
- The new track becomes the current track
Event buffering
Events accumulate in memory up to a maximum of 20 before a forced flush. Under normal conditions, the 2-minute flush timer sends events to the dashboard in batches.
If a flush fails (dashboard unreachable), events are re-queued for retry on the next cycle.
Sync Now
The dashboard has a "Sync Now" button that triggers an immediate flush. It works by posting a sync request to the dashboard, which the daemon polls every 30 seconds. When the daemon sees a pending request, it flushes immediately rather than waiting for the next 2-minute timer.
Data extracted
Each event includes:
| Field | Description |
|---|---|
| trackName | Track title |
| artist | Track artist |
| album | Album name |
| albumArtist | Album artist (if different from track artist) |
| genre | Genre tag from Apple Music metadata |
| trackNumber | Position on album |
| year | Release year |
| duration | Track duration in seconds |
| startedAt | When playback started (ISO timestamp) |
| endedAt | When playback ended or track changed (ISO timestamp) |
| persistentId | Apple Music persistent hex ID for the track (see The auto-play problem) |
Installation
1. Build the daemon
cd ~/Projects/tools/music-sync
npm install
npm run build
2. Configure launchd
Create ~/Library/LaunchAgents/com.example.music-sync.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.music-sync</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/node</string>
<string>/Users/YOUR_USER/Projects/tools/music-sync/dist/index.js</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>STATS_DASHBOARD_URL</key>
<string>https://your-stats.example.com</string>
<key>STATS_API_KEY</key>
<string>your-api-key-here</string>
<key>DEBUG</key>
<string>false</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardOutPath</key>
<string>/tmp/music-sync.log</string>
<key>StandardErrorPath</key>
<string>/tmp/music-sync.error.log</string>
<key>ThrottleInterval</key>
<integer>30</integer>
</dict>
</plist>
Replace:
YOUR_USERwith your macOS usernameyour-stats.example.comwith your dashboard URLyour-api-key-herewith the API key from your Kubernetes secret
3. Load the service
launchctl load ~/Library/LaunchAgents/com.example.music-sync.plist
4. Verify
# Check if running
launchctl list | grep music-sync
# View logs
tail -f /tmp/music-sync.log
Configuration
| Variable | Default | Description |
|---|---|---|
STATS_DASHBOARD_URL | Required | Dashboard base URL |
STATS_API_KEY | Required | API key for authentication |
POLL_INTERVAL | 15000 | Music polling interval in ms (15 seconds) |
FLUSH_INTERVAL | 120000 | Event flush interval in ms (2 minutes) |
DEBUG | false | Enable verbose logging |
API endpoints
The daemon uses one endpoint for sending data. Two additional endpoints serve the Music page and flow timeline overlay.
| Endpoint | Method | Description |
|---|---|---|
/api/import/music-events | POST | Receive music events from daemon (Zod validated, rate limited) |
/api/music/timeline | GET | Query music events for a time range (?from=...&to=...) |
/api/music/top-flow | GET | Top songs during flow-building periods (?period=week|month|all) |
/api/music/playlist-export | GET | Ordered track lists for Apple Music playlist sync (?list=recent|recent30|flow&days=7), bearer-gated |
Import endpoint details
The import endpoint:
- Validates all events with Zod schemas (max 1000 per request)
- Deduplicates by
trackName + artist + startedAt(2-second tolerance window) - Updates
endedAtwhen a previously-open event receives its end time - Rate limits at 60 requests per minute per daemon ID
- Logs imports to the
ImportLogtable for the Sources page
Heartbeat tracking
The daemon sends identification with every flush:
| Field | Example | Purpose |
|---|---|---|
daemon.id | my-mac-music-sync | Unique identifier |
daemon.hostname | my-mac.local | Machine name |
daemon.version | 1.2.0 | Daemon version |
The Sources page shows daemon health based on the DaemonHeartbeat table (stale threshold: 5 minutes).
Dashboard integration
Music page
The /music page shows:
- Summary cards: unique tracks, total listening time, top genre, top flow-building track
- Flow-building tracks table: songs playing while flow score was rising, ranked by average flow score
- Most listened table: all tracks with Last Played column (sortable, default sort newest first), total listening time
- Period toggle: 7 days, 30 days, all time
- Sortable columns: click any column header to sort ascending/descending
- Text filter: search by track name, artist, album, or genre
Flow timeline overlay
When hovering over a data point on the flow timeline chart, if a track was playing during that 5-minute time bucket, the tooltip shows:
- Track name
- Artist and album
This enables instant correlation between flow peaks and music.
The auto-play problem
Apple Music's auto-play feature is excellent at surfacing tracks you have never heard. The problem is that those tracks are ephemeral. They get temporary identifiers that change every session, so you cannot reliably reference them later. If you want to build a playlist from your listening history, you need a stable identifier for each track.
The daemon solves this by adding unrecognised tracks to your Apple Music library automatically. When a new track starts playing:
- The daemon searches your library by track name and artist
- If found, it reads the stable persistent ID immediately
- If not found, it adds the track to your library (the same as tapping "Add to Library" in the Music app)
- On the next poll cycle (15 seconds later), it re-checks the library for the now-synced stable ID
This means every track you listen to, including auto-play and radio tracks, ends up in your library with a stable persistent ID that the daemon can use for playlist management.
Dynamic Apple Music playlists
I wanted the data Flow collects to be useful inside Apple Music itself, not just on a dashboard. The daemon maintains three playlists in Apple Music that update automatically every ten minutes:
| Playlist | Content | Sort order |
|---|---|---|
| Flow — Last 7 Days | All unique tracks played in the last 7 days | Most recently played first |
| Flow — Last 30 Days | All unique tracks played in the last 30 days | Most recently played first |
| Flow — Building Tracks | Tracks played during rising flow periods in the last 7 days | Highest avg flow score first |
How playlist sync works
Deleting and rebuilding the playlist every cycle caused it to vanish from my iPhone mid-listen. The daemon now computes a diff instead. It compares the desired track list from Flow against what is currently in the Apple Music playlist by trackName|artist, then only adds tracks that are missing and removes tracks that have dropped out of the time window. The playlist itself is never deleted or emptied.
New tracks are appended to the end of the playlist. Existing tracks are not reordered. Over time the playlist order may drift from Flow's ideal sort order. This is the accepted trade-off for uninterrupted playback.
What happens if you edit the playlists manually
| Action | Result |
|---|---|
| Delete a track from the playlist | Re-added on the next sync if still in the time window |
| Add a track to the playlist | Kept until the next sync, then removed (not in Flow's track list) |
| Reorder tracks in the playlist | Order preserved, new tracks appended at end |
| Rename the playlist | A new playlist with the original name is created, the renamed one remains |
| Delete the entire playlist | Re-created on the next sync with all current tracks |
These playlists are managed by Flow. To curate your own playlist from this data, duplicate the playlist in Apple Music. The copy will not be touched by sync.
Library growth
Your Apple Music library will grow as the daemon encounters new auto-play tracks. At roughly 15-20 new tracks per listening session, this is negligible against a library of any reasonable size. Tracks can be removed from the library manually without affecting Flow's data. The daemon will re-add them on the next play.
How to tell if the daemon is stuck
A hung JXA call can leave the daemon process alive but doing nothing. No errors, no output, just silence. This is the worst kind of failure because standard process monitoring sees the daemon as healthy.
The daemon includes three watchdog mechanisms:
| Mechanism | Interval | What it does |
|---|---|---|
| Heartbeat | Every 5 min | Logs a proof-of-life message with buffer size, last poll age, and play state |
| Poll guard | Every poll (15s) | Prevents overlapping polls. If a previous poll is still running, the next one is skipped |
| Stale detection | Continuous | If no poll completes for more than 60 seconds, logs a warning every 15 seconds |
If the log is silent for more than 5 minutes, the daemon is dead or frozen. A hung JXA call produces visible warnings within 60 seconds.
Security
| Control | Implementation |
|---|---|
| API key authentication | Bearer token in Authorization header |
| Input validation | Zod schemas for all incoming events |
| Rate limiting | 60 requests/minute per daemon ID |
| HTTPS enforcement | Warning logged for non-HTTPS URLs |
Troubleshooting
Daemon not recording tracks
# Check if Music.app is running and playing
osascript -l JavaScript -e 'Application("Music").playerState()'
# Check logs for errors
tail -50 /tmp/music-sync.log
Events not appearing on dashboard
- Verify
STATS_API_KEYmatchesDAEMON_API_KEYin your Kubernetes secret - Check dashboard import logs on the Sources page
- Ensure Music.app is the system music player (not Spotify or other players)
No flow correlation data
Flow-building track detection requires at least 2 flow data points during a track's playback. Flow data points are 5-minute buckets, so very short tracks may not overlap with any data points. Longer listening sessions produce better correlations.
Daemon not starting
# Check launchd status
launchctl list | grep music-sync
# Check error log
tail -50 /tmp/music-sync.error.log
# Common issues:
# - Node.js path incorrect in plist
# - STATS_API_KEY not set
# - Dashboard URL unreachable
Playlists not updating
Playlist sync runs after a successful flush, and only if at least 10 minutes have passed since the last sync. If no music is playing, there are no events to flush, so the playlist sync will not trigger. Check the log for [playlist-sync] lines. If the log shows the daemon is running but no playlist sync lines appear, wait for the next listening session and flush cycle.
Next: Health sync daemon covers the legacy daemon that syncs Apple Health data.