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.
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pickSource = pickSource;
4
+ exports.parseStats = parseStats;
5
+ exports.readStatsTitle = readStatsTitle;
6
+ /**
7
+ * Reading the current track from a station's status endpoint.
8
+ *
9
+ * Always prefer this over the stream when a station offers it: it is a small
10
+ * JSON request that costs the station nothing, where reading the stream —
11
+ * however briefly — takes one of its listener slots.
12
+ *
13
+ * Two formats, told apart by the shape of what comes back rather than by which
14
+ * URL was configured, because operators put these behind every path
15
+ * imaginable and a caller should not have to declare which server they run.
16
+ */
17
+ const types_ts_1 = require("./types.js");
18
+ const title_ts_1 = require("./title.js");
19
+ const icy_ts_1 = require("./icy.js");
20
+ const str = (v) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : null);
21
+ const int = (v) => {
22
+ const n = typeof v === 'string' ? Number(v) : v;
23
+ return typeof n === 'number' && Number.isFinite(n) ? n : null;
24
+ };
25
+ /**
26
+ * Icecast reports every mount from one endpoint, so a server with three
27
+ * streams answers with three sources and the caller has to say which. Matching
28
+ * on the tail of `listenurl` means the mount can be given as `/live`, `live`,
29
+ * or the whole URL, all of which people do.
30
+ */
31
+ function pickSource(stats, mount) {
32
+ const raw = stats.icestats?.source;
33
+ if (!raw)
34
+ return null;
35
+ const sources = Array.isArray(raw) ? raw : [raw];
36
+ if (sources.length === 0)
37
+ return null;
38
+ if (!mount)
39
+ return sources[0];
40
+ const wanted = mount.startsWith('/') ? mount : `/${mount}`;
41
+ const hit = sources.find((s) => {
42
+ const listen = str(s.listenurl);
43
+ if (!listen)
44
+ return false;
45
+ try {
46
+ return new URL(listen).pathname === wanted;
47
+ }
48
+ catch {
49
+ return listen.endsWith(wanted);
50
+ }
51
+ });
52
+ return hit ?? null;
53
+ }
54
+ function parseStats(body, mount) {
55
+ if (!body || typeof body !== 'object')
56
+ return null;
57
+ if ('icestats' in body) {
58
+ const source = pickSource(body, mount);
59
+ if (!source)
60
+ return null;
61
+ // `title` is what a source client sends; `yp_currently_playing` is what
62
+ // Icecast fills in from the directory listing. Either can be the only one.
63
+ const title = str(source.title) ?? str(source.yp_currently_playing);
64
+ if (!title || (0, title_ts_1.isPlaceholder)(title))
65
+ return null;
66
+ return {
67
+ title,
68
+ ...(0, title_ts_1.splitTitle)(title),
69
+ listeners: int(source.listeners),
70
+ source: 'icecast',
71
+ fetchedAt: Date.now(),
72
+ };
73
+ }
74
+ const sc = body;
75
+ const title = str(sc.songtitle) ?? str(sc.streamtitle);
76
+ if (!title || (0, title_ts_1.isPlaceholder)(title))
77
+ return null;
78
+ return {
79
+ title,
80
+ ...(0, title_ts_1.splitTitle)(title),
81
+ listeners: int(sc.currentlisteners),
82
+ source: 'shoutcast',
83
+ fetchedAt: Date.now(),
84
+ };
85
+ }
86
+ async function readStatsTitle(url, mount, options = {}) {
87
+ const timeoutMs = options.timeoutMs ?? icy_ts_1.DEFAULT_TIMEOUT_MS;
88
+ const signal = (0, icy_ts_1.abortAfter)(timeoutMs, options.signal);
89
+ let res;
90
+ try {
91
+ res = await fetch(url, {
92
+ headers: { 'User-Agent': options.userAgent ?? icy_ts_1.DEFAULT_USER_AGENT, Accept: 'application/json, */*' },
93
+ redirect: 'follow',
94
+ signal,
95
+ });
96
+ }
97
+ catch (cause) {
98
+ const timedOut = cause instanceof Error && cause.name === 'TimeoutError';
99
+ throw new types_ts_1.RadioError(timedOut ? 'timeout' : 'bad-response', timedOut ? `no response within ${timeoutMs}ms` : `request failed: ${cause.message}`, url, undefined, { cause });
100
+ }
101
+ if (!res.ok)
102
+ throw new types_ts_1.RadioError('http', `status endpoint answered ${res.status}`, url, res.status);
103
+ // Shoutcast serves this as text/html and Icecast as text/json, so the
104
+ // content type is no help; parse and find out.
105
+ let body;
106
+ try {
107
+ body = JSON.parse(await res.text());
108
+ }
109
+ catch (cause) {
110
+ throw new types_ts_1.RadioError('bad-response', 'status endpoint did not return JSON (a Shoutcast v1 server needs ?json=1)', url, res.status, { cause });
111
+ }
112
+ return parseStats(body, mount);
113
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Turning bytes on the wire into a title a person can read.
3
+ *
4
+ * Two problems, both of which every station gets to solve differently.
5
+ */
6
+ /**
7
+ * ICY metadata carries no encoding declaration at all. In practice stations
8
+ * send UTF-8 or Windows-1252 (Latin-1 with the useful punctuation), and there
9
+ * is no header to tell you which.
10
+ *
11
+ * The trick that works: decode as UTF-8 with `fatal: false` and look for the
12
+ * replacement character. Valid UTF-8 essentially never produces one, and
13
+ * Windows-1252 text containing accented letters essentially always does — so
14
+ * a U+FFFD is a reliable signal that this was never UTF-8 to begin with.
15
+ */
16
+ export declare function decodeMetadata(bytes: Uint8Array, encoding?: 'auto' | 'utf-8' | 'windows-1252'): string;
17
+ /**
18
+ * Pull `StreamTitle` out of an ICY metadata block.
19
+ *
20
+ * The block is a series of `key='value';` pairs, and the format has no
21
+ * escaping whatsoever — a title containing an apostrophe is simply ambiguous.
22
+ * Matching up to `';` (quote followed by semicolon) rather than the first
23
+ * quote is what keeps "Guns N' Roses" intact, and is what every real client
24
+ * does. A trailing key with no `;` is tolerated because some stations omit it.
25
+ */
26
+ export declare function parseStreamTitle(block: string): string | null;
27
+ /** Also in the block on some stations, and occasionally the only useful field. */
28
+ export declare function parseStreamUrl(block: string): string | null;
29
+ /**
30
+ * Split "Artist - Song" on the FIRST separator.
31
+ *
32
+ * Splitting on the last one would mangle "Simon & Garfunkel - Scarborough
33
+ * Fair - Canticle"; splitting on the first mangles "Sunday - Bloody Sunday" by
34
+ * a band whose name has no dash. Neither is right, because the format carries
35
+ * no structure — first-separator matches what listeners expect and what other
36
+ * clients do, and the raw title is always kept alongside.
37
+ */
38
+ export declare function splitTitle(title: string): {
39
+ artist: string | null;
40
+ song: string | null;
41
+ };
42
+ export declare function isPlaceholder(title: string): boolean;
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ /**
3
+ * Turning bytes on the wire into a title a person can read.
4
+ *
5
+ * Two problems, both of which every station gets to solve differently.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.decodeMetadata = decodeMetadata;
9
+ exports.parseStreamTitle = parseStreamTitle;
10
+ exports.parseStreamUrl = parseStreamUrl;
11
+ exports.splitTitle = splitTitle;
12
+ exports.isPlaceholder = isPlaceholder;
13
+ /**
14
+ * ICY metadata carries no encoding declaration at all. In practice stations
15
+ * send UTF-8 or Windows-1252 (Latin-1 with the useful punctuation), and there
16
+ * is no header to tell you which.
17
+ *
18
+ * The trick that works: decode as UTF-8 with `fatal: false` and look for the
19
+ * replacement character. Valid UTF-8 essentially never produces one, and
20
+ * Windows-1252 text containing accented letters essentially always does — so
21
+ * a U+FFFD is a reliable signal that this was never UTF-8 to begin with.
22
+ */
23
+ function decodeMetadata(bytes, encoding = 'auto') {
24
+ if (encoding !== 'auto')
25
+ return new TextDecoder(encoding).decode(bytes);
26
+ const utf8 = new TextDecoder('utf-8').decode(bytes);
27
+ if (!utf8.includes('�'))
28
+ return utf8;
29
+ try {
30
+ return new TextDecoder('windows-1252').decode(bytes);
31
+ }
32
+ catch {
33
+ // A Node build without full ICU has only UTF-8. Mojibake beats throwing.
34
+ return utf8;
35
+ }
36
+ }
37
+ /**
38
+ * Pull `StreamTitle` out of an ICY metadata block.
39
+ *
40
+ * The block is a series of `key='value';` pairs, and the format has no
41
+ * escaping whatsoever — a title containing an apostrophe is simply ambiguous.
42
+ * Matching up to `';` (quote followed by semicolon) rather than the first
43
+ * quote is what keeps "Guns N' Roses" intact, and is what every real client
44
+ * does. A trailing key with no `;` is tolerated because some stations omit it.
45
+ */
46
+ function parseStreamTitle(block) {
47
+ const terminated = /StreamTitle='([\s\S]*?)';/.exec(block);
48
+ const unterminated = terminated ? null : /StreamTitle='([\s\S]*)$/.exec(block);
49
+ const raw = (terminated ?? unterminated)?.[1];
50
+ if (raw === undefined)
51
+ return null;
52
+ // Stations pad the block with NUL bytes to a 16-byte boundary. The trailing
53
+ // quote only needs removing in the unterminated case — with no `';` to
54
+ // anchor on, the greedy match swallows the closing quote too.
55
+ let title = raw.replace(/\0+$/, '');
56
+ if (unterminated)
57
+ title = title.replace(/'$/, '');
58
+ title = title.trim();
59
+ return title === '' ? null : title;
60
+ }
61
+ /** Also in the block on some stations, and occasionally the only useful field. */
62
+ function parseStreamUrl(block) {
63
+ const m = /StreamUrl='([\s\S]*?)';/.exec(block);
64
+ const value = m?.[1]?.replace(/\0+$/, '').trim();
65
+ return value ? value : null;
66
+ }
67
+ /**
68
+ * Split "Artist - Song" on the FIRST separator.
69
+ *
70
+ * Splitting on the last one would mangle "Simon & Garfunkel - Scarborough
71
+ * Fair - Canticle"; splitting on the first mangles "Sunday - Bloody Sunday" by
72
+ * a band whose name has no dash. Neither is right, because the format carries
73
+ * no structure — first-separator matches what listeners expect and what other
74
+ * clients do, and the raw title is always kept alongside.
75
+ */
76
+ function splitTitle(title) {
77
+ // Real separators are surrounded by spaces. A bare hyphen is usually inside
78
+ // a word ("Jay-Z", "re-mastered") and must not split.
79
+ const m = /^(.+?)\s+[-–—]\s+(.+)$/.exec(title);
80
+ if (!m)
81
+ return { artist: null, song: null };
82
+ const artist = m[1].trim();
83
+ const song = m[2].trim();
84
+ return artist && song ? { artist, song } : { artist: null, song: null };
85
+ }
86
+ /**
87
+ * Some stations park an advert, the station name, or a literal "Unknown" in
88
+ * the title slot between songs. Callers usually want to show the last real
89
+ * track instead of that, so the obvious placeholders are reported as empty.
90
+ */
91
+ const PLACEHOLDERS = /^(unknown|unknown artist|n\/?a|none|no title|null|-|\.+)$/i;
92
+ function isPlaceholder(title) {
93
+ return PLACEHOLDERS.test(title.trim());
94
+ }
@@ -0,0 +1,63 @@
1
+ /** How the title was obtained, which is worth knowing when one source lies. */
2
+ export type Source = 'icy' | 'shoutcast' | 'icecast';
3
+ export type NowPlaying = {
4
+ /** The raw `StreamTitle`, exactly as the station sent it. */
5
+ title: string;
6
+ /**
7
+ * `title` split on the first " - ". A heuristic, and a lossy one: stations
8
+ * are inconsistent, and a song legitimately called "Sunday - Bloody Sunday"
9
+ * splits wrongly. Always keep `title` for display and treat these as a hint.
10
+ */
11
+ artist: string | null;
12
+ song: string | null;
13
+ /** Current listeners, where the source reports it. Never available over ICY. */
14
+ listeners: number | null;
15
+ source: Source;
16
+ /** When this was read, as epoch milliseconds. */
17
+ fetchedAt: number;
18
+ };
19
+ export type Station = {
20
+ /** The stream URL a player would open. */
21
+ url: string;
22
+ /**
23
+ * A Shoutcast or Icecast status endpoint. Preferred when present: it is a
24
+ * small JSON request that does not occupy one of the station's listener
25
+ * slots, where reading the stream briefly does.
26
+ *
27
+ * Shoutcast: `http://host:8000/stats?sid=1&json=1`
28
+ * Icecast: `http://host:8000/status-json.xsl`
29
+ */
30
+ statsUrl?: string;
31
+ /** Icecast serves every mount from one endpoint; this picks yours. */
32
+ mount?: string;
33
+ };
34
+ export type Options = {
35
+ /** Abandon the request after this many milliseconds. Default 6000. */
36
+ timeoutMs?: number;
37
+ /**
38
+ * Give up after reading this many bytes of audio while waiting for a
39
+ * metadata block. Default 262144 (256 KiB) — enough for any sane
40
+ * `icy-metaint`, and a hard stop on a station that advertises a huge one.
41
+ */
42
+ maxBytes?: number;
43
+ /**
44
+ * How to decode metadata bytes. ICY has no encoding field and stations
45
+ * disagree; the default tries UTF-8 and falls back to Windows-1252 when that
46
+ * produces replacement characters, which is right far more often than
47
+ * committing to either.
48
+ */
49
+ encoding?: 'auto' | 'utf-8' | 'windows-1252';
50
+ /** Sent as User-Agent. Some stations reject a blank or default one. */
51
+ userAgent?: string;
52
+ /** Cancel from outside; combined with `timeoutMs`, whichever fires first. */
53
+ signal?: AbortSignal;
54
+ };
55
+ /** Thrown for transport and protocol failures — never for "no title yet". */
56
+ export declare class RadioError extends Error {
57
+ readonly code: 'timeout' | 'http' | 'no-metadata' | 'bad-response';
58
+ readonly url: string;
59
+ readonly status?: number;
60
+ constructor(code: RadioError['code'], message: string, url: string, status?: number, options?: {
61
+ cause?: unknown;
62
+ });
63
+ }
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RadioError = void 0;
4
+ /** Thrown for transport and protocol failures — never for "no title yet". */
5
+ class RadioError extends Error {
6
+ code;
7
+ url;
8
+ status;
9
+ constructor(code, message, url, status, options) {
10
+ super(message, options);
11
+ this.name = 'RadioError';
12
+ this.code = code;
13
+ this.url = url;
14
+ if (status !== undefined)
15
+ this.status = status;
16
+ }
17
+ }
18
+ exports.RadioError = RadioError;
@@ -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,125 @@
1
+ /**
2
+ * Reading the current track out of the audio stream itself.
3
+ *
4
+ * A Shoutcast or Icecast stream asked with `Icy-MetaData: 1` answers with an
5
+ * `icy-metaint` header and then interleaves metadata into the audio: every
6
+ * `icy-metaint` bytes of audio are followed by one length byte (in units of
7
+ * 16) and that many bytes of `key='value';` text. So the whole job is: skip
8
+ * that much audio, read the block, hang up.
9
+ *
10
+ * "Hang up" is the part that matters and the part other libraries skip. A
11
+ * station has a finite number of listener slots — a few hundred, typically —
12
+ * and an open stream occupies one whether or not anyone is listening to it.
13
+ * This aborts the request as soon as the block is in hand, so a lookup holds a
14
+ * slot for well under a second instead of forever.
15
+ */
16
+ import { RadioError } from "./types.js";
17
+ import { decodeMetadata, parseStreamTitle, splitTitle, isPlaceholder } from "./title.js";
18
+ export const DEFAULT_TIMEOUT_MS = 6000;
19
+ /** 256 KiB. A sane station uses 8–64 KiB; this is the stop for one that does not. */
20
+ export const DEFAULT_MAX_BYTES = 262_144;
21
+ export const DEFAULT_USER_AGENT = 'radio-now-playing (+https://www.npmjs.com/package/radio-now-playing)';
22
+ /** A metadata block is one length byte times 16, so at most 255 × 16 bytes. */
23
+ const MAX_BLOCK = 255 * 16;
24
+ /**
25
+ * Combines the caller's signal with a timeout. `AbortSignal.any` needs Node 20,
26
+ * which is this package's floor anyway, and unlike a hand-rolled listener it
27
+ * cannot leave a listener attached to a caller's long-lived signal.
28
+ */
29
+ export function abortAfter(timeoutMs, external) {
30
+ const timer = AbortSignal.timeout(timeoutMs);
31
+ return external ? AbortSignal.any([timer, external]) : timer;
32
+ }
33
+ export async function readIcyTitle(url, options = {}) {
34
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
35
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
36
+ const signal = abortAfter(timeoutMs, options.signal);
37
+ let res;
38
+ try {
39
+ res = await fetch(url, {
40
+ headers: {
41
+ 'Icy-MetaData': '1',
42
+ 'User-Agent': options.userAgent ?? DEFAULT_USER_AGENT,
43
+ // Without this some CDN-fronted mounts answer with a playlist.
44
+ Accept: '*/*',
45
+ },
46
+ redirect: 'follow',
47
+ signal,
48
+ });
49
+ }
50
+ catch (cause) {
51
+ const timedOut = cause instanceof Error && cause.name === 'TimeoutError';
52
+ throw new RadioError(timedOut ? 'timeout' : 'bad-response', timedOut ? `no response within ${timeoutMs}ms` : `request failed: ${cause.message}`, url, undefined, { cause });
53
+ }
54
+ if (!res.ok) {
55
+ await res.body?.cancel().catch(() => { });
56
+ throw new RadioError('http', `station answered ${res.status}`, url, res.status);
57
+ }
58
+ const interval = Number(res.headers.get('icy-metaint') ?? 0);
59
+ if (!Number.isInteger(interval) || interval <= 0 || !res.body) {
60
+ await res.body?.cancel().catch(() => { });
61
+ // Not an error the caller did anything wrong: plenty of streams simply
62
+ // carry no metadata. It is still not something a retry will fix, so it is
63
+ // distinguishable from "nothing playing".
64
+ throw new RadioError('no-metadata', 'stream does not send ICY metadata', url);
65
+ }
66
+ if (interval + 1 + MAX_BLOCK > maxBytes) {
67
+ await res.body.cancel().catch(() => { });
68
+ throw new RadioError('no-metadata', `icy-metaint is ${interval}, which needs more than the ${maxBytes}-byte budget`, url);
69
+ }
70
+ const reader = res.body.getReader();
71
+ const chunks = [];
72
+ let total = 0;
73
+ try {
74
+ while (total < maxBytes) {
75
+ const { done, value } = await reader.read();
76
+ if (done)
77
+ break;
78
+ if (!value)
79
+ continue;
80
+ chunks.push(value);
81
+ total += value.length;
82
+ // The length byte lives at exactly `interval`, so there is nothing to
83
+ // look at until the audio ahead of it has all arrived.
84
+ if (total <= interval)
85
+ continue;
86
+ const buf = concat(chunks, total);
87
+ const length = buf[interval] * 16;
88
+ // A zero-length block is the station saying "nothing has changed". It is
89
+ // a valid answer meaning "no title right now", not a failure.
90
+ if (length === 0)
91
+ return null;
92
+ if (buf.length < interval + 1 + length)
93
+ continue;
94
+ const block = decodeMetadata(buf.subarray(interval + 1, interval + 1 + length), options.encoding);
95
+ const title = parseStreamTitle(block);
96
+ if (title === null || isPlaceholder(title))
97
+ return null;
98
+ return { title, ...splitTitle(title), listeners: null, source: 'icy', fetchedAt: Date.now() };
99
+ }
100
+ return null;
101
+ }
102
+ catch (cause) {
103
+ if (cause instanceof Error && (cause.name === 'TimeoutError' || cause.name === 'AbortError')) {
104
+ throw new RadioError('timeout', `stopped reading after ${timeoutMs}ms`, url, undefined, { cause });
105
+ }
106
+ throw new RadioError('bad-response', `stream ended badly: ${cause.message}`, url, undefined, { cause });
107
+ }
108
+ finally {
109
+ // Cancelling the reader closes the socket, which is what gives the
110
+ // listener slot back. Without it the connection lingers until the station
111
+ // times it out.
112
+ await reader.cancel().catch(() => { });
113
+ }
114
+ }
115
+ function concat(chunks, total) {
116
+ if (chunks.length === 1)
117
+ return chunks[0];
118
+ const out = new Uint8Array(total);
119
+ let at = 0;
120
+ for (const chunk of chunks) {
121
+ out.set(chunk, at);
122
+ at += chunk.length;
123
+ }
124
+ return out;
125
+ }
@@ -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,24 @@
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.js";
18
+ export { NowPlayingClient } from "./cache.js";
19
+ export { readIcyTitle, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_BYTES, DEFAULT_USER_AGENT } from "./icy.js";
20
+ export { readStatsTitle, parseStats, pickSource } from "./stats.js";
21
+ // Exported because they are useful on their own: plenty of people already have
22
+ // the metadata block or the title and only need it parsed properly.
23
+ export { parseStreamTitle, parseStreamUrl, splitTitle, decodeMetadata, isPlaceholder } from "./title.js";
24
+ export { RadioError } from "./types.js";
@@ -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,15 @@
1
+ import { readIcyTitle } from "./icy.js";
2
+ import { readStatsTitle } from "./stats.js";
3
+ export async function nowPlaying(station, options = {}) {
4
+ const target = typeof station === 'string' ? { url: station } : station;
5
+ if (!target.statsUrl)
6
+ return readIcyTitle(target.url, options);
7
+ try {
8
+ return await readStatsTitle(target.statsUrl, target.mount, options);
9
+ }
10
+ catch (err) {
11
+ if (!options.fallbackToStream)
12
+ throw err;
13
+ return readIcyTitle(target.url, options);
14
+ }
15
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
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 {};