A headless service that streams a Last.fm user's currently-playing track, or their most recent scrobble, over a WebSocket connection.
Multiple clients watching the same username share one poller, so Last.fm API usage scales with unique usernames rather than open connections. When the last client for a username disconnects, its poller stops.
The server also sends a JSON heartbeat every 20 seconds, on top of WebSocket ping/pong frames, so browser code can tell an idle listener apart from a socket that died without a close frame.
| Variable | Required | Default | Description |
|---|---|---|---|
LASTFM_API_KEY |
yes | none | Your Last.fm API key |
PORT |
no | 3621 |
HTTP listen port |
ALLOWED_ORIGINS |
no | (allow all) | Comma-separated list of allowed WebSocket origins, such as https://example.com,https://other.com. Leave empty to allow all origins. |
MAX_POLLERS |
no | 1000 |
Maximum number of unique usernames monitored at once |
Requires Go 1.26 or newer.
export LASTFM_API_KEY=your_key_here
go run main.goConnect a WebSocket client to:
ws://localhost:3621/fm/USERNAME
The GitHub Actions workflow publishes an image to GitHub Container Registry on every push to main and on version tags.
docker pull ghcr.io/ndzn/lastfm-websocket:main
docker run -p 3621:3621 -e LASTFM_API_KEY=your_key_here ghcr.io/ndzn/lastfm-websocket:mainFor Docker Compose, see docker-compose.yml.example.
docker build -t lastfm-websocket .
docker run -p 3621:3621 -e LASTFM_API_KEY=your_key_here lastfm-websocketws://your-server:3621/fm/USERNAME
USERNAME is any Last.fm username: letters, digits, hyphens, and underscores, up to 15 characters. The server rejects anything else with a 400 before it calls the Last.fm API. Messages are typed JSON events, and clients should ignore event types they do not recognize.
{
"type": "track",
"data": {
"artist": "Grant",
"artist_mbid": "",
"artist_url": "https://www.last.fm/music/Grant",
"track": "Wishes",
"track_mbid": "",
"album": "Perch",
"album_mbid": "",
"image_url": "https://lastfm.freetls.fastly.net/i/u/174s/5bb0cf2d5c308fb4df40e6aab7514d4d.jpg",
"track_url": "https://www.last.fm/music/Grant/_/Wishes",
"is_now_playing": true,
"date_uts": 0,
"date_text": "",
"loved": false
}
}is_now_playing is true while the user is listening. false means this is their most recent scrobble instead. loved says whether the monitored user has loved the track. MusicBrainz IDs and other metadata come back empty when Last.fm has no value for them.
Three fields describe when the track was scrobbled. date_uts is the Unix timestamp, date_text is the date string Last.fm returns, such as "01 Jan 2025, 12:00", and scrobbled_at is the same instant as an RFC 3339 UTC timestamp, such as 2026-08-19T14:30:00Z. All three are cleared when is_now_playing is true or when Last.fm returns a timestamp the server cannot parse: date_uts is 0, date_text is "", and scrobbled_at is left out of the payload.
{"type":"heartbeat"}The server sends a heartbeat every 20 seconds. Browser clients should treat the connection as stale after 60 seconds with no valid event, close it, and reconnect. WebSocket ping/pong frames handle dead-connection detection on the server side separately.
<div id="now-playing"></div>
<script>
const socketURL = "ws://your-server:3621/fm/YOUR_USERNAME";
const staleAfter = 60_000;
let retryDelay = 1_000;
function renderTrack(track) {
const container = document.getElementById("now-playing");
container.innerHTML = "";
if (track.image_url) {
const img = document.createElement("img");
img.src = track.image_url;
img.width = 100;
container.appendChild(img);
}
const p = document.createElement("p");
const strong = document.createElement("strong");
strong.textContent = track.track;
p.appendChild(strong);
p.appendChild(document.createTextNode(" by " + track.artist));
container.appendChild(p);
}
function connect() {
const ws = new WebSocket(socketURL);
let lastSeen = Date.now();
let reconnectScheduled = false;
const watchdog = setInterval(function () {
if (Date.now() - lastSeen > staleAfter) {
reconnect();
}
}, 5_000);
function reconnect() {
if (reconnectScheduled) return;
reconnectScheduled = true;
clearInterval(watchdog);
try {
ws.close();
} catch (_) {
// The socket may still be connecting.
}
const delay = retryDelay + Math.random() * retryDelay * 0.25;
setTimeout(connect, delay);
retryDelay = Math.min(retryDelay * 2, 30_000);
}
ws.onmessage = function (event) {
let message;
try {
message = JSON.parse(event.data);
} catch (_) {
return;
}
if (!message || typeof message.type !== "string") return;
lastSeen = Date.now();
retryDelay = 1_000;
if (message.type === "track" && message.data) {
renderTrack(message.data);
}
// Heartbeats and unknown event types need no handling.
};
ws.onerror = reconnect;
ws.onclose = reconnect;
}
connect();
</script>Hub (max 1000 pollers by default)
├── UserPoller("alice") ← polls Last.fm every 5 s
│ ├── WebSocket Client 1
│ └── WebSocket Client 2
└── UserPoller("bob") ← polls Last.fm every 5 s
├── WebSocket Client 3
└── WebSocket Client 4
The hub runs one poller goroutine per unique username, capped at MAX_POLLERS. A poller broadcasts to its subscribers only when the track changes, and caches the last event so a new client gets the current track immediately instead of waiting for the next poll. On SIGINT or SIGTERM the server stops accepting new connections and exits. Open WebSocket connections are dropped rather than drained, because http.Server.Shutdown does not wait on hijacked connections.