Skip to main content

Music sync daemon

info

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

  1. Flow Control
  2. Architecture
  3. Cursor sync daemon
  4. Music sync daemon - You are here
  5. Health sync daemon (disabled)
  6. HealthSync iOS app (recommended)
  7. 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?"
ChallengeSolution
Apple Music has no export APIJXA (JavaScript for Automation) queries playback state
Cannot run from KubernetesLocal daemon on Mac where Apple Music runs
Need accurate start/end times15-second polling with change detection
Must not lose events on crashGraceful 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:

  1. The previous track is recorded with its start and end timestamps
  2. Plays shorter than 5 seconds are discarded (likely skips)
  3. 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:

FieldDescription
trackNameTrack title
artistTrack artist
albumAlbum name
albumArtistAlbum artist (if different from track artist)
genreGenre tag from Apple Music metadata
trackNumberPosition on album
yearRelease year
durationTrack duration in seconds
startedAtWhen playback started (ISO timestamp)
endedAtWhen playback ended or track changed (ISO timestamp)
persistentIdApple 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_USER with your macOS username
  • your-stats.example.com with your dashboard URL
  • your-api-key-here with 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

VariableDefaultDescription
STATS_DASHBOARD_URLRequiredDashboard base URL
STATS_API_KEYRequiredAPI key for authentication
POLL_INTERVAL15000Music polling interval in ms (15 seconds)
FLUSH_INTERVAL120000Event flush interval in ms (2 minutes)
DEBUGfalseEnable verbose logging

API endpoints

The daemon uses one endpoint for sending data. Two additional endpoints serve the Music page and flow timeline overlay.

EndpointMethodDescription
/api/import/music-eventsPOSTReceive music events from daemon (Zod validated, rate limited)
/api/music/timelineGETQuery music events for a time range (?from=...&to=...)
/api/music/top-flowGETTop songs during flow-building periods (?period=week|month|all)
/api/music/playlist-exportGETOrdered 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 endedAt when a previously-open event receives its end time
  • Rate limits at 60 requests per minute per daemon ID
  • Logs imports to the ImportLog table for the Sources page

Heartbeat tracking

The daemon sends identification with every flush:

FieldExamplePurpose
daemon.idmy-mac-music-syncUnique identifier
daemon.hostnamemy-mac.localMachine name
daemon.version1.2.0Daemon 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:

  1. The daemon searches your library by track name and artist
  2. If found, it reads the stable persistent ID immediately
  3. If not found, it adds the track to your library (the same as tapping "Add to Library" in the Music app)
  4. 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:

PlaylistContentSort order
Flow — Last 7 DaysAll unique tracks played in the last 7 daysMost recently played first
Flow — Last 30 DaysAll unique tracks played in the last 30 daysMost recently played first
Flow — Building TracksTracks played during rising flow periods in the last 7 daysHighest 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

ActionResult
Delete a track from the playlistRe-added on the next sync if still in the time window
Add a track to the playlistKept until the next sync, then removed (not in Flow's track list)
Reorder tracks in the playlistOrder preserved, new tracks appended at end
Rename the playlistA new playlist with the original name is created, the renamed one remains
Delete the entire playlistRe-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:

MechanismIntervalWhat it does
HeartbeatEvery 5 minLogs a proof-of-life message with buffer size, last poll age, and play state
Poll guardEvery poll (15s)Prevents overlapping polls. If a previous poll is still running, the next one is skipped
Stale detectionContinuousIf 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

ControlImplementation
API key authenticationBearer token in Authorization header
Input validationZod schemas for all incoming events
Rate limiting60 requests/minute per daemon ID
HTTPS enforcementWarning 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

  1. Verify STATS_API_KEY matches DAEMON_API_KEY in your Kubernetes secret
  2. Check dashboard import logs on the Sources page
  3. 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.