radio-now-playing
v1.0.0
Published
What is playing on an internet radio station, right now. Reads ICY stream metadata or a Shoutcast/Icecast status endpoint, hangs up immediately, and caches so a hundred callers cost one request. Zero dependencies.
Maintainers
Readme
radio-now-playing
What is playing on an internet radio station, right now.
Reads the track title from a Shoutcast or Icecast station — either out of the stream's own ICY metadata or from its status endpoint — and hangs up as soon as it has the answer, so a lookup does not sit in one of the station's listener slots. Ships a cache that collapses any number of callers into one request upstream.
Zero dependencies. Native fetch. TypeScript types included. ESM and CommonJS.
npm install radio-now-playingQuick start
import { nowPlaying } from 'radio-now-playing';
const track = await nowPlaying('https://example.com/stream');
console.log(track);
// {
// title: 'Miles Davis - So What',
// artist: 'Miles Davis',
// song: 'So What',
// listeners: null,
// source: 'icy',
// fetchedAt: 1755960000000
// }null means the station answered and nothing is playing. A station that is
unreachable, or that carries no metadata at all, throws a RadioError —
those are different situations and you usually want to treat them differently.
Serving it to a web page
Do not call nowPlaying per visitor. A page polled by a hundred people would
be a hundred requests to a station that has a few hundred listener slots
total. Use the client:
import { NowPlayingClient } from 'radio-now-playing';
const radio = new NowPlayingClient({ ttlMs: 15_000 });
app.get('/api/now', async (req, res) => {
const { track, stale, checkedAt } = await radio.get(station);
res.json({ title: track?.title ?? null, stale, checkedAt });
});- At most one request upstream per station per
ttlMs, however many callers arrive — including callers that arrive while a request is in flight. get()never rejects. If the station is down you get the last title you had, withstale: trueand the failure inerror. A dropped request does not mean the music stopped, and a UI that blanks every time a station hiccups is worse than one that is fifteen seconds behind.
Use the status endpoint when the station has one
Reading the stream, however briefly, takes one of the station's listener slots. A status endpoint does not. If you know the station's, give it:
// Shoutcast v2
await nowPlaying({
url: 'https://example.com/stream',
statsUrl: 'http://example.com:8000/stats?sid=1&json=1',
});
// Icecast — one endpoint serves every mount, so name yours
await nowPlaying({
url: 'https://example.com/live',
statsUrl: 'http://example.com:8000/status-json.xsl',
mount: '/live',
});The two formats are told apart by the shape of the response, not by the URL, so you do not have to declare which server the station runs.
Status endpoints also report listener counts, which ICY never does.
If the status endpoint fails, this throws rather than quietly reading the
stream instead — configuring statsUrl is how you say "do not take one of my
listener slots", and undoing that at the exact moment the station is already
struggling is not helpful. Opt in with { fallbackToStream: true } if you
would rather have the title.
API
nowPlaying(station, options?): Promise<NowPlaying | null>
station is a URL string, or { url, statsUrl?, mount? }.
| Option | Default | |
|---|---|---|
| timeoutMs | 6000 | Abandon the request after this long. |
| maxBytes | 262144 | Stop reading audio while waiting for a metadata block. |
| encoding | 'auto' | 'auto', 'utf-8' or 'windows-1252'. See below. |
| userAgent | package name | Some stations reject a blank one. |
| signal | — | An AbortSignal of your own, combined with timeoutMs. |
| fallbackToStream | false | Read the stream if the status endpoint fails. |
new NowPlayingClient(options?)
Takes everything above plus ttlMs (default 15000).
get(station)→Promise<CacheEntry>, never rejectspeek(station)→CacheEntry | undefined, no lookupclear(station?)→ forget one station, or all of them
type CacheEntry = {
track: NowPlaying | null;
stale: boolean; // the last attempt failed; track is older than checkedAt
error: RadioError | null;
checkedAt: number; // when an attempt last finished, successful or not
};Parsing helpers
Exported because plenty of people already have the bytes and only need them read properly:
import { parseStreamTitle, splitTitle, decodeMetadata } from 'radio-now-playing';
parseStreamTitle("StreamTitle='Guns N' Roses - November Rain';");
// → "Guns N' Roses - November Rain"
splitTitle('Miles Davis - So What');
// → { artist: 'Miles Davis', song: 'So What' }RadioError
Thrown for transport and protocol failures, never for "nothing is playing".
error.code is one of:
| code | meaning |
|---|---|
| timeout | No answer, or no metadata block, in time. |
| http | The station answered with an error status (see error.status). |
| no-metadata | The stream sends no icy-metaint at all. Retrying will not help. |
| bad-response | Connection failure, or a status endpoint that did not return JSON. |
Things that will bite you, and what this does about them
ICY has no encoding field. Stations send UTF-8 or Windows-1252 and there
is no header saying which, so naive UTF-8 decoding turns Björk into Bj�rk. The
default 'auto' decodes as UTF-8, and falls back to Windows-1252 if the result
contains a replacement character — valid UTF-8 essentially never produces one,
and Windows-1252 text with accented letters essentially always does.
"Artist - Song" is a convention, not a format. splitTitle splits on the
first spaced separator, so Jay-Z and re-mastered stay intact but
Scarborough Fair - Canticle keeps its dash in the song rather than the
artist. There is no parse that is right for every station, which is why the
raw title is always there too — show that, and treat artist/song as a
hint.
Stations park junk in the title between songs. Unknown, N/A, - and
friends are reported as nothing playing so you can keep showing the last real
track.
A zero-length metadata block is not an error. It is the station saying nothing has changed, and it means "no title right now".
Requirements and limits
- Node 20.3+. Uses
fetch,AbortSignal.anyandTextDecoder. The bundled types referenceAbortSignal, so a TypeScript consumer needs@types/nodeor"lib": ["dom"]— any Node project already has the first. No browser build: browsers cannot set theIcy-MetaDataheader or read a cross-origin stream body, so this is a server-side library by nature. - Shoutcast v1 servers that answer
ICY 200 OKinstead of an HTTP status line cannot be read byfetchat all — nothing built on the platform HTTP client can. Modern Shoutcast (v2, which answersHTTP/1.0 200 OK) and every Icecast are fine. For a genuine v1 server, use its/stats?json=1endpoint. - Playlist URLs (
.m3u,.pls) are not resolved. Point at the stream.
Prior art
icy and
icecast-parser give you a
streaming client that stays connected and emits metadata as it changes.
That is the right tool if you are playing or relaying the audio; it is the
wrong one for a "what's playing" endpoint, because it holds a listener slot
for as long as it runs.
node-internet-radio does the
same one-shot job as this, with callbacks and a dependency on the deprecated
request.
Contributing
npm install
npm run check # typecheck, 46 unit tests, 7 packaging testsThe tests run against a real HTTP server on loopback rather than a mocked
fetch — this is a protocol reader, and mocking the transport would test the
mock. test/station.ts is a fake station that serves genuine ICY-interleaved
audio and the specific ways real stations misbehave.
npm run check also runs in prepublishOnly, so a broken build cannot be
published by accident.
Licence
MIT
