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 +20 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/dist/cjs/cache.d.ts +45 -0
- package/dist/cjs/cache.js +0 -0
- package/dist/cjs/icy.d.ts +12 -0
- package/dist/cjs/icy.js +130 -0
- package/dist/cjs/index.d.ts +25 -0
- package/dist/cjs/index.js +42 -0
- package/dist/cjs/lookup.d.ts +16 -0
- package/dist/cjs/lookup.js +18 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/stats.d.ts +24 -0
- package/dist/cjs/stats.js +113 -0
- package/dist/cjs/title.d.ts +42 -0
- package/dist/cjs/title.js +94 -0
- package/dist/cjs/types.d.ts +63 -0
- package/dist/cjs/types.js +18 -0
- package/dist/esm/cache.d.ts +45 -0
- package/dist/esm/cache.js +0 -0
- package/dist/esm/icy.d.ts +12 -0
- package/dist/esm/icy.js +125 -0
- package/dist/esm/index.d.ts +25 -0
- package/dist/esm/index.js +24 -0
- package/dist/esm/lookup.d.ts +16 -0
- package/dist/esm/lookup.js +15 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/stats.d.ts +24 -0
- package/dist/esm/stats.js +108 -0
- package/dist/esm/title.d.ts +42 -0
- package/dist/esm/title.js +87 -0
- package/dist/esm/types.d.ts +63 -0
- package/dist/esm/types.js +14 -0
- package/package.json +67 -0
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
|
+
[](https://www.npmjs.com/package/radio-now-playing)
|
|
4
|
+
[](https://github.com/K0tatkoo/radio-now-playing/actions/workflows/ci.yml)
|
|
5
|
+
[](./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>;
|
package/dist/cjs/icy.js
ADDED
|
@@ -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,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 {};
|