Overview
The station data WebSocket lets the website receive events the moment they happen, instead of polling
/radio/v2/nowplaying on a timer. One connection carries events
for both stations; every event states the StationID and StationName
it belongs to, so a client can show either station (or both) from a single socket.
Six station events are pushed:
| Event | Sent when | Carries full track data? |
|---|---|---|
nowplaying | A new song starts playing on a station, or a live override begins. | Yes (except live overrides) |
nextup | A new “next song” appears for a station. | Yes |
songrequested | A listener request is accepted on a station. | Yes |
ratingset | A star rating is set for a track. | Yes |
NowPlayingOnPlaylist | The playlist position of a running show changes. | No — the playlist entry is a plain text field. |
Override | A station goes live (override on) or returns to normal broadcast operations (override off). | No |
Authentication: none. The socket is read-only and carries the same public data as the REST endpoints. Clients cannot inject data through it — the only accepted client messages are the control actions listed below.
Connecting
Open the socket, optionally subscribe to a subset of stations, and handle incoming JSON messages.
Immediately after connecting the server sends a welcome message followed by a replay of the
latest known nowplaying, nextup and NowPlayingOnPlaylist events (marked
"Replay": 1), so a client is usually up to date without any REST call.
Example (browser JavaScript)
let ws;
function connect() {
ws = new WebSocket("wss://api.ericade.net/ws/stationdata");
ws.onopen = () => {
// Optional: only receive events for station 1.
ws.send(JSON.stringify({ Action: "subscribe", StationID: [1] }));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
switch (msg.Event) {
case "nowplaying":
console.log(`[${msg.StationName}] Now playing:`,
msg.Payload.Track ? msg.Payload.Track.Title : msg.Payload.Title);
break;
case "nextup":
console.log(`[${msg.StationName}] Next up:`, msg.Payload.Title);
break;
case "songrequested":
console.log(`[${msg.StationName}] ${msg.Payload.Requester} requested ${msg.Payload.Title}`);
break;
case "ratingset":
console.log(`[${msg.StationName}] ${msg.Payload.Title} rated ${msg.Payload.Stars} stars`);
break;
}
};
// Reconnect with backoff; see "Client best practices".
ws.onclose = () => setTimeout(connect, 2000 + Math.random() * 3000);
}
connect();
Message envelope
Every message from the server — station events and control messages alike — is a JSON object with this envelope:
| Attribute | Description | Example |
|---|---|---|
Event | Message type. Station events: nowplaying, nextup, songrequested, ratingset, NowPlayingOnPlaylist, Override. Control messages: welcome, subscribed, pong, error. | nowplaying |
StationID | Station the event belongs to. 0 for control messages, which concern the connection rather than a station. | 1 = ericade.radio 2 = Best of ericade.radio |
StationName | Station name in plain text. Empty for control messages. | 24/7 tracked music |
Timestamp | When the event occurred, Unix epoch. | 1785440425 |
TimestampHR | When the event occurred, human-readable server local time. | 2026-07-30 21:00:25 |
Replay | Present and set to 1 when the message is a cached snapshot replayed after connect/subscribe, rather than a freshly fired event. Absent on live events. | 1 |
Payload | Event-specific data, see each event below. Absent on some control messages. | Object |
Station events
nowplaying
Sent when a new song starts playing on a station. The Payload.Track object contains the complete
track data — the same object the Now Playing and
Track by ID REST endpoints return (Artist, Title, Slug, Image,
CompositeRating, TrackArtists, Playlog and so on). See the REST documentation for the full field list.
| Payload attribute | Description | Example |
|---|---|---|
TrackID | Track identifier. 0 when the track could not be resolved (rare; e.g. live broadcasts). | 14906 |
Artist | Complete artist field as ingested. For listener requests this includes the request prefix. | Dr. Future |
Title | Track title. | Plastic pop |
Guid | Internal GUID of the media file. | 4f2c… |
isListenerRequested | 1 when this play is a listener request, otherwise 0. Use this instead of the field inside Track, which is always 0. | 0 or 1 |
StartedAt | When the song started, Unix epoch. Remember to apply Track.WebStreamingOffset before flipping the display, exactly as with the REST endpoint. | 1785440425 |
Track | Full track object (see above). Omitted if the track lookup failed; fall back to the flat fields in that case. | Object |
Example
{
"Event": "nowplaying",
"StationID": 1,
"StationName": "24/7 tracked music",
"Timestamp": 1785440425,
"TimestampHR": "2026-07-30 21:00:25",
"Payload": {
"TrackID": 14906,
"Artist": "Dr. Future",
"Title": "Plastic pop",
"Guid": "4f2c8c31-6b2e-4a44-9d0a-1c2f3e4d5a6b",
"isListenerRequested": 0,
"StartedAt": 1785440425,
"Track": {
"Artist": "Dr. Future",
"Title": "Plastic pop",
"StationID": 1,
"StationName": "24/7 tracked music",
"Slug": "https://ericade.radio/#/song/14906/plastic-pop-dr-future",
"Image": "https://radio.ericade.net/images/demoscene.png",
"CompositeRating": 4.2,
"PlayLength": 213.4,
"TrackArtists": [ { "ArtistID": 4821, "Artist": "Dr. Future" } ]
}
}
}
Track object above is heavily shortened. The real object contains every field
documented for the Now Playing response.nowplaying event fires whose data comes from the
station’s settings table instead of the track database: TrackID is 0,
isOverride is 1, there is no Track object, and the flat
Artist/Title/Image fields carry the free-text override values. Display
those as they are.nextup
Sent when a new “next song” appears for a station. Payload attributes:
| Payload attribute | Description | Example |
|---|---|---|
TrackID | Track identifier of the upcoming song. For listener requests this is resolved to the real track. | 10663 |
Artist | Artist of the upcoming song. For listener requests the request prefix is stripped. | Cube |
Title | Title of the upcoming song. | My pixels are weapons |
Guid | Internal GUID of the media file. | 9a1b… |
Track | Full track object, same shape as for nowplaying. Omitted if the lookup failed. | Object |
nextup events in two ways:
1. Buffer window: a
nextup arriving within 5 seconds after the last
nowplaying for the same station is discarded — listeners are still hearing the previous song
then, and flipping the next-up display that early would spoil it. A discarded event is forgotten completely, so
a later resend of the same track is still broadcast.
2. Duplicate suppression: if the next-up slot for a station is set to the same track it already holds (matched by
TrackID, or by Artist/Title/Guid when the track could not be resolved), no
new event is broadcast. The cached snapshot replayed to newly connected clients is likewise left untouched, so it
still reflects the timestamp of the last genuine change rather than a repeat call.songrequested
Sent when a listener request passes all checks and is queued. The greeting and the requester’s IP are deliberately not broadcast. Payload attributes:
| Payload attribute | Description | Example |
|---|---|---|
TrackID | Track identifier of the requested song. | 10663 |
Artist | Artist of the requested song. | Dr Future |
Title | Title of the requested song. | Plastic pop (Plastic kills life mix) |
Requester | Name the requester entered. | Erik |
QueuePosition | Position in the request queue after this request was added. | 3 |
ExpectedWaitTime | Human-readable estimate of the time until play. | 48 minutes |
Track | Full track object, same shape as for nowplaying. | Object |
ratingset
Sent when a listener sets a star rating on a track. The voter’s IP and browser hash are deliberately not broadcast. Payload attributes:
| Payload attribute | Description | Example |
|---|---|---|
TrackID | Track identifier of the rated song. | 14906 |
Artist | Artist of the rated song. | Dr. Future |
Title | Title of the rated song. | Plastic pop |
Stars | The rating that was just given, 1–5. | 5 |
CompositeRating | New composite rating for the track after this vote. | 4.31 |
Votes | Total number of votes for the track after this vote. | 17 |
Track | Full track object with the fresh rating, same shape as for nowplaying. | Object |
ratingset concerns the track that was rated, which is not necessarily the track
currently playing. Match on Payload.TrackID before updating any “now playing” UI.NowPlayingOnPlaylist
Sent when the playlist position of a running show changes. Shows (for example live sets or podcast episodes)
carry a PlayList text field with timestamped entries; while such a show is playing, the relay
re-evaluates the current entry every 5 seconds and fires this event whenever it changes. The value is a plain
text field — there is no artist or song data behind it. Present it as it is.
| Payload attribute | Description | Example |
|---|---|---|
NowPlayingOnPlaylist | The playlist entry that is playing right now, exactly as written in the show’s playlist. May be empty when no entry matches the current position. | Jester of Elysium - Elysium |
Example
{
"Event": "NowPlayingOnPlaylist",
"StationID": 1,
"StationName": "24/7 tracked music",
"Timestamp": 1785441106,
"TimestampHR": "2026-07-30 21:11:46",
"Payload": {
"NowPlayingOnPlaylist": "Jester of Elysium - Elysium"
}
}
nowplaying event as
Track.NowPlayingOnPlaylist; this event only fires for subsequent changes. The latest value is
cached and replayed to newly connecting clients (marked "Replay": 1) for as long as the show is
playing; the cache is cleared when the next song starts.nowplaying events alone there:
it also actively checks every 5 seconds which track is currently playing and picks up the running
episode’s playlist by itself — for example after a relay restart in the middle of an episode. The
playlist position is polled continuously either way, so NowPlayingOnPlaylist events keep flowing
throughout the whole episode.Override
Sent when a station’s override state changes in the settings table — that is, when a live broadcast takes over the station (override on) or the station returns to normal broadcast operations (override off). The event only fires on an actual state change, not on every call to the settings endpoint.
| Payload attribute | Description | Example |
|---|---|---|
Override | The new override state. 1 = live override active, 0 = normal broadcast. | 0 or 1 |
Message | Human-readable state description: Just went live when the override turns on, Normal broadcast operations when it turns off. | Just went live |
Artist | Override artist text from the settings table. Only present when Override is 1. | ericade.radio |
Title | Override title text from the settings table. Only present when Override is 1. | Live from the demo party! |
Image | Override image URL from the settings table. Only present when Override is 1. | https://radio.ericade.net/images/live.png |
Example
{
"Event": "Override",
"StationID": 1,
"StationName": "24/7 tracked music",
"Timestamp": 1785442000,
"TimestampHR": "2026-07-30 21:26:40",
"Payload": {
"Override": 1,
"Message": "Just went live",
"Artist": "ericade.radio",
"Title": "Live from the demo party!",
"Image": "https://radio.ericade.net/images/live.png"
}
}
nowplaying event with the override
data from the settings table fires alongside this event (see the note in the
nowplaying section). When the override turns off, only the
Override event fires — the next regular nowplaying arrives when the playout
system starts the next song. Clients that want the current track immediately can make one REST call to
/radio/v2/nowplaying on receiving Override: 0.Control messages
Control messages use StationID: 0 and an empty StationName.
welcome
First message after the connection is established.
| Attribute | Description | Example |
|---|---|---|
Protocol | Protocol version. Bumped when the message format changes. | 1 |
Stations | Array of available stations: StationID and StationName. | [{"StationID":1,"StationName":"24/7 tracked music"}, …] |
Documentation | URL of this page. |
subscribed
Acknowledges a subscribe action. Subscribed lists the station IDs the connection now
receives. After the acknowledgement, cached nowplaying/nextup/NowPlayingOnPlaylist messages for the chosen
stations are replayed (marked "Replay": 1).
pong
Answer to the application-level ping action.
error
Sent when a client message could not be processed. Message contains a human-readable explanation.
The connection stays open.
Client actions
Clients may send the following JSON messages. Anything else is answered with an error message.
subscribe
Restrict (or widen) which stations the connection receives events for. Without a subscribe, a connection receives all stations.
{ "Action": "subscribe", "StationID": [1, 2] }
| Attribute | Mandatory? | Description | Notes |
|---|---|---|---|
Action | Yes | Must be subscribe. | |
StationID | Yes | Array of station IDs (a single integer is also accepted). An empty array means all stations. | Unknown IDs are rejected with an error and the previous subscription is kept. |
snapshot
Asks the server to resend the cached nowplaying/nextup/NowPlayingOnPlaylist messages (marked
"Replay": 1) for the subscribed stations. Useful after a mobile browser wakes a background tab.
{ "Action": "snapshot" }
ping
Application-level keepalive for clients that cannot send protocol-level pings (browsers). The server answers
with pong. Not normally needed — the server pings every client at the protocol level anyway.
{ "Action": "ping" }
Client best practices
| Topic | Recommendation |
|---|---|
| Initial state | Rely on the replayed messages after welcome. If none arrive within a couple of seconds (the relay was just restarted and has not seen a song change yet), fall back to one REST call to /radio/v2/nowplaying. |
| Reconnecting | Reconnect on close with a randomized backoff (e.g. 2–5 s, doubling up to ~60 s). Do not hammer the server with immediate retries. |
| Buffer offset | Apply Track.WebStreamingOffset (seconds) before flipping the now-playing display, exactly as when polling. The event fires when the playout system starts the song, which is earlier than listeners hear it. |
| Replay flag | Treat "Replay": 1 messages as state synchronization, not as fresh events — do not re-trigger animations or notifications for them. |
| Keep polling as fallback | The WebSocket is an optimization. Keep a slow REST poll (for example every few minutes) or use the Sequence mechanism as a safety net if your use case cannot tolerate a missed event. |
| Unknown events | Ignore Event values you do not recognize; new event types may be added without a protocol bump. |
Limits
| Limit | Value | What happens when exceeded |
|---|---|---|
| Simultaneous clients | 200 | New connections are refused with HTTP 503. |
| Simultaneous clients per IP | 20 | New connections are refused with HTTP 503. |
| Client messages | 20 per 10 seconds | Connection is closed (code 1008). |
| Client message size | 4 KB | Connection is closed (code 1009). |
| Keepalive | Protocol ping every 30 s | Clients that miss two pings in a row are dropped (code 1001). |
| Message types | Text frames with JSON only | Binary frames close the connection (code 1003). |
These values are configurable server-side; see Operations.
Operations (station staff)
This section documents how the WebSocket endpoint works internally and how to run it. API consumers can stop reading here.
Architecture
Apache/PHP cannot hold WebSocket connections, so the endpoint is a separate long-running CLI daemon,
websocket/stationdata-server.php (“the relay”), fronted by Apache. The flow is:
- An API endpoint does its normal work —
updatetrack()ingests a new play,nextup()stores the next song,addrequest()accepts a request,updatestar()stores a rating,Settings::Override()flips the override state of a station. - At the end of that work it calls
PublishStationEvent()(inconfig/common.php), which sends one small UDP datagram to the relay on loopback. This is fire-and-forget: if the relay is down the datagram is lost and the API call is completely unaffected. - The relay authenticates the datagram with the shared key, gates
nextupevents (buffer window and duplicate suppression, see the nextup section), enriches events that carry aTrackIDwith the full track data through the existingobjGetSong()object (no duplicated SQL), caches the latestnowplaying/nextup/NowPlayingOnPlaylistper station for replay, and broadcasts the result to all subscribed WebSocket clients. - One event type originates in the relay itself: while the current track of a station carries a
PlayList, the relay re-evaluates the playlist position every 5 seconds through the existingReturnPlayListData()function (a pure time computation, no database work) and broadcasts aNowPlayingOnPlaylistevent whenever the position changes. - For the stations listed in
$WebSocketPollNowPlaying(station 2, whose “songs” are long podcast episodes), the relay additionally checks every 5 seconds which track is currently playing (one cheap SELECT against thenowplayingtable) and re-establishes the playlist watch throughobjGetSong()when the track changed — so the running episode’s playlist is followed even if the relay started mid-episode. An unchanged episode costs only the SELECT; the full track lookup runs once per episode change.
Configuration (config.php)
| Setting | Default | Description |
|---|---|---|
$WebSocketEnabled | 1 | Master switch for event publishing. 0 also makes the relay refuse to start. |
$WebSocketListenAddress | 127.0.0.1 | Address the relay listens on for WebSocket clients. Keep on loopback; Apache proxies wss:// to it. |
$WebSocketListenPort | 8146 | TCP port for WebSocket clients. |
$WebSocketEventHost / $WebSocketEventPort | 127.0.0.1 / 8147 | Where PublishStationEvent() sends the internal UDP datagrams. |
$WebSocketEventKey | (secret) | Shared secret authenticating internal datagrams. Change before deploying. |
$WebSocketMaxClients / $WebSocketMaxClientsPerIP | 200 / 20 | Connection limits. |
$WebSocketPingInterval | 30 | Seconds between keepalive pings. |
$WebSocketPollNowPlaying | array(2) | Stations where the relay actively checks the currently playing track every 5 seconds, instead of relying on nowplaying events alone. Use for stations that play long podcast episodes. |
Apache
Enable mod_proxy and mod_proxy_wstunnel, then add to the
api.ericade.net TLS vhost:
ProxyPass "/ws/stationdata" "ws://127.0.0.1:8146/"
ProxyPassReverse "/ws/stationdata" "ws://127.0.0.1:8146/"
TLS terminates in Apache; the relay itself speaks plain TCP on loopback only.
Running the relay
The watchdog script websocket/stationdata-ws.sh starts the relay if it is not running and removes
duplicates, following the same pattern as the Discord bot. Add to root’s crontab:
* * * * * /var/www/html/api.ericade.net/websocket/stationdata-ws.sh
The relay logs to /var/log/stationdata-ws.txt (via the watchdog’s redirection). A plain HTTP
GET /health against the relay port answers 200 OK and can be used for monitoring:
curl -s http://127.0.0.1:8146/health
websocket/ directory is blocked from web access by its
.htaccess. Keep it that way — the relay must only ever run as a CLI daemon.