radio-now-playing 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are documented here. This project follows
4
+ [semantic versioning](https://semver.org/): the public surface is everything
5
+ exported from `radio-now-playing`, and the shape of `NowPlaying`, `CacheEntry`
6
+ and `RadioError`.
7
+
8
+ ## 1.0.0 — 2026-08-23
9
+
10
+ First release.
11
+
12
+ - `nowPlaying(station)` — read the current track from a stream's ICY metadata
13
+ or from a Shoutcast/Icecast status endpoint, closing the connection as soon
14
+ as the answer is in hand.
15
+ - `NowPlayingClient` — a TTL cache with request coalescing that never rejects
16
+ and never blanks a known title on a failed refresh.
17
+ - Windows-1252 fallback for metadata that is not UTF-8.
18
+ - `parseStreamTitle`, `parseStreamUrl`, `splitTitle`, `decodeMetadata` and
19
+ `isPlaceholder` exported for callers who already have the bytes.
20
+ - Dual ESM/CommonJS build with types for both.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kotatko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # radio-now-playing
2
+
3
+ [![npm](https://img.shields.io/npm/v/radio-now-playing)](https://www.npmjs.com/package/radio-now-playing)
4
+ [![ci](https://github.com/K0tatkoo/radio-now-playing/actions/workflows/ci.yml/badge.svg)](https://github.com/K0tatkoo/radio-now-playing/actions/workflows/ci.yml)
5
+ [![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](./package.json)
6
+
7
+ What is playing on an internet radio station, right now.
8
+
9
+ Reads the track title from a Shoutcast or Icecast station — either out of the
10
+ stream's own ICY metadata or from its status endpoint — and **hangs up as soon
11
+ as it has the answer**, so a lookup does not sit in one of the station's
12
+ listener slots. Ships a cache that collapses any number of callers into one
13
+ request upstream.
14
+
15
+ Zero dependencies. Native `fetch`. TypeScript types included. ESM and CommonJS.
16
+
17
+ ```bash
18
+ npm install radio-now-playing
19
+ ```
20
+
21
+ ## Quick start
22
+
23
+ ```js
24
+ import { nowPlaying } from 'radio-now-playing';
25
+
26
+ const track = await nowPlaying('https://example.com/stream');
27
+ console.log(track);
28
+ // {
29
+ // title: 'Miles Davis - So What',
30
+ // artist: 'Miles Davis',
31
+ // song: 'So What',
32
+ // listeners: null,
33
+ // source: 'icy',
34
+ // fetchedAt: 1755960000000
35
+ // }
36
+ ```
37
+
38
+ `null` means the station answered and nothing is playing. A station that is
39
+ unreachable, or that carries no metadata at all, **throws** a `RadioError` —
40
+ those are different situations and you usually want to treat them differently.
41
+
42
+ ## Serving it to a web page
43
+
44
+ Do not call `nowPlaying` per visitor. A page polled by a hundred people would
45
+ be a hundred requests to a station that has a few hundred listener slots
46
+ total. Use the client:
47
+
48
+ ```js
49
+ import { NowPlayingClient } from 'radio-now-playing';
50
+
51
+ const radio = new NowPlayingClient({ ttlMs: 15_000 });
52
+
53
+ app.get('/api/now', async (req, res) => {
54
+ const { track, stale, checkedAt } = await radio.get(station);
55
+ res.json({ title: track?.title ?? null, stale, checkedAt });
56
+ });
57
+ ```
58
+
59
+ - At most one request upstream per station per `ttlMs`, however many callers
60
+ arrive — including callers that arrive *while a request is in flight*.
61
+ - `get()` **never rejects**. If the station is down you get the last title you
62
+ had, with `stale: true` and the failure in `error`. A dropped request does
63
+ not mean the music stopped, and a UI that blanks every time a station
64
+ hiccups is worse than one that is fifteen seconds behind.
65
+
66
+ ## Use the status endpoint when the station has one
67
+
68
+ Reading the stream, however briefly, takes one of the station's listener
69
+ slots. A status endpoint does not. If you know the station's, give it:
70
+
71
+ ```js
72
+ // Shoutcast v2
73
+ await nowPlaying({
74
+ url: 'https://example.com/stream',
75
+ statsUrl: 'http://example.com:8000/stats?sid=1&json=1',
76
+ });
77
+
78
+ // Icecast — one endpoint serves every mount, so name yours
79
+ await nowPlaying({
80
+ url: 'https://example.com/live',
81
+ statsUrl: 'http://example.com:8000/status-json.xsl',
82
+ mount: '/live',
83
+ });
84
+ ```
85
+
86
+ The two formats are told apart by the shape of the response, not by the URL,
87
+ so you do not have to declare which server the station runs.
88
+
89
+ Status endpoints also report **listener counts**, which ICY never does.
90
+
91
+ If the status endpoint fails, this throws rather than quietly reading the
92
+ stream instead — configuring `statsUrl` is how you say "do not take one of my
93
+ listener slots", and undoing that at the exact moment the station is already
94
+ struggling is not helpful. Opt in with `{ fallbackToStream: true }` if you
95
+ would rather have the title.
96
+
97
+ ## API
98
+
99
+ ### `nowPlaying(station, options?): Promise<NowPlaying | null>`
100
+
101
+ `station` is a URL string, or `{ url, statsUrl?, mount? }`.
102
+
103
+ | Option | Default | |
104
+ |---|---|---|
105
+ | `timeoutMs` | `6000` | Abandon the request after this long. |
106
+ | `maxBytes` | `262144` | Stop reading audio while waiting for a metadata block. |
107
+ | `encoding` | `'auto'` | `'auto'`, `'utf-8'` or `'windows-1252'`. See below. |
108
+ | `userAgent` | package name | Some stations reject a blank one. |
109
+ | `signal` | — | An `AbortSignal` of your own, combined with `timeoutMs`. |
110
+ | `fallbackToStream` | `false` | Read the stream if the status endpoint fails. |
111
+
112
+ ### `new NowPlayingClient(options?)`
113
+
114
+ Takes everything above plus `ttlMs` (default `15000`).
115
+
116
+ - `get(station)` → `Promise<CacheEntry>`, never rejects
117
+ - `peek(station)` → `CacheEntry | undefined`, no lookup
118
+ - `clear(station?)` → forget one station, or all of them
119
+
120
+ ```ts
121
+ type CacheEntry = {
122
+ track: NowPlaying | null;
123
+ stale: boolean; // the last attempt failed; track is older than checkedAt
124
+ error: RadioError | null;
125
+ checkedAt: number; // when an attempt last finished, successful or not
126
+ };
127
+ ```
128
+
129
+ ### Parsing helpers
130
+
131
+ Exported because plenty of people already have the bytes and only need them
132
+ read properly:
133
+
134
+ ```js
135
+ import { parseStreamTitle, splitTitle, decodeMetadata } from 'radio-now-playing';
136
+
137
+ parseStreamTitle("StreamTitle='Guns N' Roses - November Rain';");
138
+ // → "Guns N' Roses - November Rain"
139
+
140
+ splitTitle('Miles Davis - So What');
141
+ // → { artist: 'Miles Davis', song: 'So What' }
142
+ ```
143
+
144
+ ### `RadioError`
145
+
146
+ Thrown for transport and protocol failures, never for "nothing is playing".
147
+ `error.code` is one of:
148
+
149
+ | code | meaning |
150
+ |---|---|
151
+ | `timeout` | No answer, or no metadata block, in time. |
152
+ | `http` | The station answered with an error status (see `error.status`). |
153
+ | `no-metadata` | The stream sends no `icy-metaint` at all. Retrying will not help. |
154
+ | `bad-response` | Connection failure, or a status endpoint that did not return JSON. |
155
+
156
+ ## Things that will bite you, and what this does about them
157
+
158
+ **ICY has no encoding field.** Stations send UTF-8 or Windows-1252 and there
159
+ is no header saying which, so naive UTF-8 decoding turns Björk into Bj�rk. The
160
+ default `'auto'` decodes as UTF-8, and falls back to Windows-1252 if the result
161
+ contains a replacement character — valid UTF-8 essentially never produces one,
162
+ and Windows-1252 text with accented letters essentially always does.
163
+
164
+ **"Artist - Song" is a convention, not a format.** `splitTitle` splits on the
165
+ first *spaced* separator, so `Jay-Z` and `re-mastered` stay intact but
166
+ `Scarborough Fair - Canticle` keeps its dash in the song rather than the
167
+ artist. There is no parse that is right for every station, which is why the
168
+ raw `title` is always there too — show that, and treat `artist`/`song` as a
169
+ hint.
170
+
171
+ **Stations park junk in the title between songs.** `Unknown`, `N/A`, `-` and
172
+ friends are reported as nothing playing so you can keep showing the last real
173
+ track.
174
+
175
+ **A zero-length metadata block is not an error.** It is the station saying
176
+ nothing has changed, and it means "no title right now".
177
+
178
+ ## Requirements and limits
179
+
180
+ - **Node 20.3+.** Uses `fetch`, `AbortSignal.any` and `TextDecoder`. The
181
+ bundled types reference `AbortSignal`, so a TypeScript consumer needs
182
+ `@types/node` or `"lib": ["dom"]` — any Node project already has the first. No
183
+ browser build: browsers cannot set the `Icy-MetaData` header or read a
184
+ cross-origin stream body, so this is a server-side library by nature.
185
+ - **Shoutcast v1 servers that answer `ICY 200 OK`** instead of an HTTP status
186
+ line cannot be read by `fetch` at all — nothing built on the platform HTTP
187
+ client can. Modern Shoutcast (v2, which answers `HTTP/1.0 200 OK`) and every
188
+ Icecast are fine. For a genuine v1 server, use its `/stats?json=1` endpoint.
189
+ - Playlist URLs (`.m3u`, `.pls`) are not resolved. Point at the stream.
190
+
191
+ ## Prior art
192
+
193
+ [`icy`](https://npmjs.com/package/icy) and
194
+ [`icecast-parser`](https://npmjs.com/package/icecast-parser) give you a
195
+ *streaming client* that stays connected and emits metadata as it changes.
196
+ That is the right tool if you are playing or relaying the audio; it is the
197
+ wrong one for a "what's playing" endpoint, because it holds a listener slot
198
+ for as long as it runs.
199
+ [`node-internet-radio`](https://npmjs.com/package/node-internet-radio) does the
200
+ same one-shot job as this, with callbacks and a dependency on the deprecated
201
+ `request`.
202
+
203
+ ## Contributing
204
+
205
+ ```bash
206
+ npm install
207
+ npm run check # typecheck, 46 unit tests, 7 packaging tests
208
+ ```
209
+
210
+ The tests run against a real HTTP server on loopback rather than a mocked
211
+ `fetch` — this is a protocol reader, and mocking the transport would test the
212
+ mock. `test/station.ts` is a fake station that serves genuine ICY-interleaved
213
+ audio and the specific ways real stations misbehave.
214
+
215
+ `npm run check` also runs in `prepublishOnly`, so a broken build cannot be
216
+ published by accident.
217
+
218
+ ## Licence
219
+
220
+ MIT
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The shared cache — the reason this package exists as more than a parser.
3
+ *
4
+ * A web page showing "now playing" is polled by every visitor, and each of
5
+ * those polls must not become a request to the station. This collapses them:
6
+ * one upstream request per station per TTL, no matter how many callers arrive,
7
+ * including callers that arrive while a request is already in flight.
8
+ *
9
+ * It also never throws and never blanks a known title on failure. A dropped
10
+ * request does not mean the music stopped, and a UI that flickers to empty
11
+ * every time a station hiccups is worse than one that is fifteen seconds
12
+ * behind.
13
+ */
14
+ import type { NowPlaying, Options, Station } from './types.ts';
15
+ import { RadioError } from './types.ts';
16
+ export type CacheEntry = {
17
+ /** The last title known, or null if none has ever been read. */
18
+ track: NowPlaying | null;
19
+ /** True when the most recent attempt failed, so `track` is older than `checkedAt`. */
20
+ stale: boolean;
21
+ /** Why the most recent attempt failed, if it did. */
22
+ error: RadioError | null;
23
+ /** When an attempt last finished, successful or not. Epoch milliseconds. */
24
+ checkedAt: number;
25
+ };
26
+ export type ClientOptions = Options & {
27
+ /** Never ask a station more often than this. Default 15000. */
28
+ ttlMs?: number;
29
+ /** See `nowPlaying`: read the stream when a status endpoint fails. Default false. */
30
+ fallbackToStream?: boolean;
31
+ };
32
+ export declare class NowPlayingClient {
33
+ #private;
34
+ readonly ttlMs: number;
35
+ constructor(options?: ClientOptions);
36
+ /**
37
+ * Never rejects. A station that is down, slow or lying yields the last good
38
+ * title with `stale: true` and the failure in `error`.
39
+ */
40
+ get(station: string | Station): Promise<CacheEntry>;
41
+ /** What is cached right now, without triggering a lookup. */
42
+ peek(station: string | Station): CacheEntry | undefined;
43
+ /** Forget everything, or one station. */
44
+ clear(station?: string | Station): void;
45
+ }
Binary file
@@ -0,0 +1,12 @@
1
+ import type { NowPlaying, Options } from './types.ts';
2
+ export declare const DEFAULT_TIMEOUT_MS = 6000;
3
+ /** 256 KiB. A sane station uses 8–64 KiB; this is the stop for one that does not. */
4
+ export declare const DEFAULT_MAX_BYTES = 262144;
5
+ export declare const DEFAULT_USER_AGENT = "radio-now-playing (+https://www.npmjs.com/package/radio-now-playing)";
6
+ /**
7
+ * Combines the caller's signal with a timeout. `AbortSignal.any` needs Node 20,
8
+ * which is this package's floor anyway, and unlike a hand-rolled listener it
9
+ * cannot leave a listener attached to a caller's long-lived signal.
10
+ */
11
+ export declare function abortAfter(timeoutMs: number, external?: AbortSignal): AbortSignal;
12
+ export declare function readIcyTitle(url: string, options?: Options): Promise<NowPlaying | null>;
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_USER_AGENT = exports.DEFAULT_MAX_BYTES = exports.DEFAULT_TIMEOUT_MS = void 0;
4
+ exports.abortAfter = abortAfter;
5
+ exports.readIcyTitle = readIcyTitle;
6
+ /**
7
+ * Reading the current track out of the audio stream itself.
8
+ *
9
+ * A Shoutcast or Icecast stream asked with `Icy-MetaData: 1` answers with an
10
+ * `icy-metaint` header and then interleaves metadata into the audio: every
11
+ * `icy-metaint` bytes of audio are followed by one length byte (in units of
12
+ * 16) and that many bytes of `key='value';` text. So the whole job is: skip
13
+ * that much audio, read the block, hang up.
14
+ *
15
+ * "Hang up" is the part that matters and the part other libraries skip. A
16
+ * station has a finite number of listener slots — a few hundred, typically —
17
+ * and an open stream occupies one whether or not anyone is listening to it.
18
+ * This aborts the request as soon as the block is in hand, so a lookup holds a
19
+ * slot for well under a second instead of forever.
20
+ */
21
+ const types_ts_1 = require("./types.js");
22
+ const title_ts_1 = require("./title.js");
23
+ exports.DEFAULT_TIMEOUT_MS = 6000;
24
+ /** 256 KiB. A sane station uses 8–64 KiB; this is the stop for one that does not. */
25
+ exports.DEFAULT_MAX_BYTES = 262_144;
26
+ exports.DEFAULT_USER_AGENT = 'radio-now-playing (+https://www.npmjs.com/package/radio-now-playing)';
27
+ /** A metadata block is one length byte times 16, so at most 255 × 16 bytes. */
28
+ const MAX_BLOCK = 255 * 16;
29
+ /**
30
+ * Combines the caller's signal with a timeout. `AbortSignal.any` needs Node 20,
31
+ * which is this package's floor anyway, and unlike a hand-rolled listener it
32
+ * cannot leave a listener attached to a caller's long-lived signal.
33
+ */
34
+ function abortAfter(timeoutMs, external) {
35
+ const timer = AbortSignal.timeout(timeoutMs);
36
+ return external ? AbortSignal.any([timer, external]) : timer;
37
+ }
38
+ async function readIcyTitle(url, options = {}) {
39
+ const timeoutMs = options.timeoutMs ?? exports.DEFAULT_TIMEOUT_MS;
40
+ const maxBytes = options.maxBytes ?? exports.DEFAULT_MAX_BYTES;
41
+ const signal = abortAfter(timeoutMs, options.signal);
42
+ let res;
43
+ try {
44
+ res = await fetch(url, {
45
+ headers: {
46
+ 'Icy-MetaData': '1',
47
+ 'User-Agent': options.userAgent ?? exports.DEFAULT_USER_AGENT,
48
+ // Without this some CDN-fronted mounts answer with a playlist.
49
+ Accept: '*/*',
50
+ },
51
+ redirect: 'follow',
52
+ signal,
53
+ });
54
+ }
55
+ catch (cause) {
56
+ const timedOut = cause instanceof Error && cause.name === 'TimeoutError';
57
+ throw new types_ts_1.RadioError(timedOut ? 'timeout' : 'bad-response', timedOut ? `no response within ${timeoutMs}ms` : `request failed: ${cause.message}`, url, undefined, { cause });
58
+ }
59
+ if (!res.ok) {
60
+ await res.body?.cancel().catch(() => { });
61
+ throw new types_ts_1.RadioError('http', `station answered ${res.status}`, url, res.status);
62
+ }
63
+ const interval = Number(res.headers.get('icy-metaint') ?? 0);
64
+ if (!Number.isInteger(interval) || interval <= 0 || !res.body) {
65
+ await res.body?.cancel().catch(() => { });
66
+ // Not an error the caller did anything wrong: plenty of streams simply
67
+ // carry no metadata. It is still not something a retry will fix, so it is
68
+ // distinguishable from "nothing playing".
69
+ throw new types_ts_1.RadioError('no-metadata', 'stream does not send ICY metadata', url);
70
+ }
71
+ if (interval + 1 + MAX_BLOCK > maxBytes) {
72
+ await res.body.cancel().catch(() => { });
73
+ throw new types_ts_1.RadioError('no-metadata', `icy-metaint is ${interval}, which needs more than the ${maxBytes}-byte budget`, url);
74
+ }
75
+ const reader = res.body.getReader();
76
+ const chunks = [];
77
+ let total = 0;
78
+ try {
79
+ while (total < maxBytes) {
80
+ const { done, value } = await reader.read();
81
+ if (done)
82
+ break;
83
+ if (!value)
84
+ continue;
85
+ chunks.push(value);
86
+ total += value.length;
87
+ // The length byte lives at exactly `interval`, so there is nothing to
88
+ // look at until the audio ahead of it has all arrived.
89
+ if (total <= interval)
90
+ continue;
91
+ const buf = concat(chunks, total);
92
+ const length = buf[interval] * 16;
93
+ // A zero-length block is the station saying "nothing has changed". It is
94
+ // a valid answer meaning "no title right now", not a failure.
95
+ if (length === 0)
96
+ return null;
97
+ if (buf.length < interval + 1 + length)
98
+ continue;
99
+ const block = (0, title_ts_1.decodeMetadata)(buf.subarray(interval + 1, interval + 1 + length), options.encoding);
100
+ const title = (0, title_ts_1.parseStreamTitle)(block);
101
+ if (title === null || (0, title_ts_1.isPlaceholder)(title))
102
+ return null;
103
+ return { title, ...(0, title_ts_1.splitTitle)(title), listeners: null, source: 'icy', fetchedAt: Date.now() };
104
+ }
105
+ return null;
106
+ }
107
+ catch (cause) {
108
+ if (cause instanceof Error && (cause.name === 'TimeoutError' || cause.name === 'AbortError')) {
109
+ throw new types_ts_1.RadioError('timeout', `stopped reading after ${timeoutMs}ms`, url, undefined, { cause });
110
+ }
111
+ throw new types_ts_1.RadioError('bad-response', `stream ended badly: ${cause.message}`, url, undefined, { cause });
112
+ }
113
+ finally {
114
+ // Cancelling the reader closes the socket, which is what gives the
115
+ // listener slot back. Without it the connection lingers until the station
116
+ // times it out.
117
+ await reader.cancel().catch(() => { });
118
+ }
119
+ }
120
+ function concat(chunks, total) {
121
+ if (chunks.length === 1)
122
+ return chunks[0];
123
+ const out = new Uint8Array(total);
124
+ let at = 0;
125
+ for (const chunk of chunks) {
126
+ out.set(chunk, at);
127
+ at += chunk.length;
128
+ }
129
+ return out;
130
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * radio-now-playing — what is playing on an internet radio station, right now.
3
+ *
4
+ * Zero dependencies, native fetch, and it hangs up as soon as it has the
5
+ * answer so a lookup does not sit in one of the station's listener slots.
6
+ *
7
+ * ```ts
8
+ * import { nowPlaying, NowPlayingClient } from 'radio-now-playing';
9
+ *
10
+ * await nowPlaying('https://example.com/stream');
11
+ * // → { title: 'Miles Davis - So What', artist: 'Miles Davis', … }
12
+ *
13
+ * const radio = new NowPlayingClient({ ttlMs: 15_000 });
14
+ * await radio.get(station); // a hundred callers, one request upstream
15
+ * ```
16
+ */
17
+ export { nowPlaying } from './lookup.ts';
18
+ export type { LookupOptions } from './lookup.ts';
19
+ export { NowPlayingClient } from './cache.ts';
20
+ export type { CacheEntry, ClientOptions } from './cache.ts';
21
+ export { readIcyTitle, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_BYTES, DEFAULT_USER_AGENT } from './icy.ts';
22
+ export { readStatsTitle, parseStats, pickSource } from './stats.ts';
23
+ export { parseStreamTitle, parseStreamUrl, splitTitle, decodeMetadata, isPlaceholder } from './title.ts';
24
+ export { RadioError } from './types.ts';
25
+ export type { NowPlaying, Options, Source, Station } from './types.ts';
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RadioError = exports.isPlaceholder = exports.decodeMetadata = exports.splitTitle = exports.parseStreamUrl = exports.parseStreamTitle = exports.pickSource = exports.parseStats = exports.readStatsTitle = exports.DEFAULT_USER_AGENT = exports.DEFAULT_MAX_BYTES = exports.DEFAULT_TIMEOUT_MS = exports.readIcyTitle = exports.NowPlayingClient = exports.nowPlaying = void 0;
4
+ /**
5
+ * radio-now-playing — what is playing on an internet radio station, right now.
6
+ *
7
+ * Zero dependencies, native fetch, and it hangs up as soon as it has the
8
+ * answer so a lookup does not sit in one of the station's listener slots.
9
+ *
10
+ * ```ts
11
+ * import { nowPlaying, NowPlayingClient } from 'radio-now-playing';
12
+ *
13
+ * await nowPlaying('https://example.com/stream');
14
+ * // → { title: 'Miles Davis - So What', artist: 'Miles Davis', … }
15
+ *
16
+ * const radio = new NowPlayingClient({ ttlMs: 15_000 });
17
+ * await radio.get(station); // a hundred callers, one request upstream
18
+ * ```
19
+ */
20
+ var lookup_ts_1 = require("./lookup.js");
21
+ Object.defineProperty(exports, "nowPlaying", { enumerable: true, get: function () { return lookup_ts_1.nowPlaying; } });
22
+ var cache_ts_1 = require("./cache.js");
23
+ Object.defineProperty(exports, "NowPlayingClient", { enumerable: true, get: function () { return cache_ts_1.NowPlayingClient; } });
24
+ var icy_ts_1 = require("./icy.js");
25
+ Object.defineProperty(exports, "readIcyTitle", { enumerable: true, get: function () { return icy_ts_1.readIcyTitle; } });
26
+ Object.defineProperty(exports, "DEFAULT_TIMEOUT_MS", { enumerable: true, get: function () { return icy_ts_1.DEFAULT_TIMEOUT_MS; } });
27
+ Object.defineProperty(exports, "DEFAULT_MAX_BYTES", { enumerable: true, get: function () { return icy_ts_1.DEFAULT_MAX_BYTES; } });
28
+ Object.defineProperty(exports, "DEFAULT_USER_AGENT", { enumerable: true, get: function () { return icy_ts_1.DEFAULT_USER_AGENT; } });
29
+ var stats_ts_1 = require("./stats.js");
30
+ Object.defineProperty(exports, "readStatsTitle", { enumerable: true, get: function () { return stats_ts_1.readStatsTitle; } });
31
+ Object.defineProperty(exports, "parseStats", { enumerable: true, get: function () { return stats_ts_1.parseStats; } });
32
+ Object.defineProperty(exports, "pickSource", { enumerable: true, get: function () { return stats_ts_1.pickSource; } });
33
+ // Exported because they are useful on their own: plenty of people already have
34
+ // the metadata block or the title and only need it parsed properly.
35
+ var title_ts_1 = require("./title.js");
36
+ Object.defineProperty(exports, "parseStreamTitle", { enumerable: true, get: function () { return title_ts_1.parseStreamTitle; } });
37
+ Object.defineProperty(exports, "parseStreamUrl", { enumerable: true, get: function () { return title_ts_1.parseStreamUrl; } });
38
+ Object.defineProperty(exports, "splitTitle", { enumerable: true, get: function () { return title_ts_1.splitTitle; } });
39
+ Object.defineProperty(exports, "decodeMetadata", { enumerable: true, get: function () { return title_ts_1.decodeMetadata; } });
40
+ Object.defineProperty(exports, "isPlaceholder", { enumerable: true, get: function () { return title_ts_1.isPlaceholder; } });
41
+ var types_ts_1 = require("./types.js");
42
+ Object.defineProperty(exports, "RadioError", { enumerable: true, get: function () { return types_ts_1.RadioError; } });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The single entry point most callers want: given a station, what is playing?
3
+ */
4
+ import type { NowPlaying, Options, Station } from './types.ts';
5
+ export type LookupOptions = Options & {
6
+ /**
7
+ * Read the stream when the status endpoint fails.
8
+ *
9
+ * Off by default, deliberately. Configuring `statsUrl` is how a caller says
10
+ * "do not take one of my station's listener slots"; silently taking one
11
+ * anyway the moment the endpoint hiccups would undo that at exactly the
12
+ * moment the station is already having a bad time.
13
+ */
14
+ fallbackToStream?: boolean;
15
+ };
16
+ export declare function nowPlaying(station: string | Station, options?: LookupOptions): Promise<NowPlaying | null>;
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nowPlaying = nowPlaying;
4
+ const icy_ts_1 = require("./icy.js");
5
+ const stats_ts_1 = require("./stats.js");
6
+ async function nowPlaying(station, options = {}) {
7
+ const target = typeof station === 'string' ? { url: station } : station;
8
+ if (!target.statsUrl)
9
+ return (0, icy_ts_1.readIcyTitle)(target.url, options);
10
+ try {
11
+ return await (0, stats_ts_1.readStatsTitle)(target.statsUrl, target.mount, options);
12
+ }
13
+ catch (err) {
14
+ if (!options.fallbackToStream)
15
+ throw err;
16
+ return (0, icy_ts_1.readIcyTitle)(target.url, options);
17
+ }
18
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,24 @@
1
+ import type { NowPlaying, Options } from './types.ts';
2
+ /** Icecast: `/status-json.xsl`. */
3
+ type IcecastSource = {
4
+ listenurl?: unknown;
5
+ title?: unknown;
6
+ yp_currently_playing?: unknown;
7
+ server_name?: unknown;
8
+ listeners?: unknown;
9
+ };
10
+ type IcecastStats = {
11
+ icestats?: {
12
+ source?: IcecastSource | IcecastSource[];
13
+ };
14
+ };
15
+ /**
16
+ * Icecast reports every mount from one endpoint, so a server with three
17
+ * streams answers with three sources and the caller has to say which. Matching
18
+ * on the tail of `listenurl` means the mount can be given as `/live`, `live`,
19
+ * or the whole URL, all of which people do.
20
+ */
21
+ export declare function pickSource(stats: IcecastStats, mount?: string): IcecastSource | null;
22
+ export declare function parseStats(body: unknown, mount?: string): NowPlaying | null;
23
+ export declare function readStatsTitle(url: string, mount?: string, options?: Options): Promise<NowPlaying | null>;
24
+ export {};