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,108 @@
1
+ /**
2
+ * Reading the current track from a station's status endpoint.
3
+ *
4
+ * Always prefer this over the stream when a station offers it: it is a small
5
+ * JSON request that costs the station nothing, where reading the stream —
6
+ * however briefly — takes one of its listener slots.
7
+ *
8
+ * Two formats, told apart by the shape of what comes back rather than by which
9
+ * URL was configured, because operators put these behind every path
10
+ * imaginable and a caller should not have to declare which server they run.
11
+ */
12
+ import { RadioError } from "./types.js";
13
+ import { splitTitle, isPlaceholder } from "./title.js";
14
+ import { abortAfter, DEFAULT_TIMEOUT_MS, DEFAULT_USER_AGENT } from "./icy.js";
15
+ const str = (v) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : null);
16
+ const int = (v) => {
17
+ const n = typeof v === 'string' ? Number(v) : v;
18
+ return typeof n === 'number' && Number.isFinite(n) ? n : null;
19
+ };
20
+ /**
21
+ * Icecast reports every mount from one endpoint, so a server with three
22
+ * streams answers with three sources and the caller has to say which. Matching
23
+ * on the tail of `listenurl` means the mount can be given as `/live`, `live`,
24
+ * or the whole URL, all of which people do.
25
+ */
26
+ export function pickSource(stats, mount) {
27
+ const raw = stats.icestats?.source;
28
+ if (!raw)
29
+ return null;
30
+ const sources = Array.isArray(raw) ? raw : [raw];
31
+ if (sources.length === 0)
32
+ return null;
33
+ if (!mount)
34
+ return sources[0];
35
+ const wanted = mount.startsWith('/') ? mount : `/${mount}`;
36
+ const hit = sources.find((s) => {
37
+ const listen = str(s.listenurl);
38
+ if (!listen)
39
+ return false;
40
+ try {
41
+ return new URL(listen).pathname === wanted;
42
+ }
43
+ catch {
44
+ return listen.endsWith(wanted);
45
+ }
46
+ });
47
+ return hit ?? null;
48
+ }
49
+ export function parseStats(body, mount) {
50
+ if (!body || typeof body !== 'object')
51
+ return null;
52
+ if ('icestats' in body) {
53
+ const source = pickSource(body, mount);
54
+ if (!source)
55
+ return null;
56
+ // `title` is what a source client sends; `yp_currently_playing` is what
57
+ // Icecast fills in from the directory listing. Either can be the only one.
58
+ const title = str(source.title) ?? str(source.yp_currently_playing);
59
+ if (!title || isPlaceholder(title))
60
+ return null;
61
+ return {
62
+ title,
63
+ ...splitTitle(title),
64
+ listeners: int(source.listeners),
65
+ source: 'icecast',
66
+ fetchedAt: Date.now(),
67
+ };
68
+ }
69
+ const sc = body;
70
+ const title = str(sc.songtitle) ?? str(sc.streamtitle);
71
+ if (!title || isPlaceholder(title))
72
+ return null;
73
+ return {
74
+ title,
75
+ ...splitTitle(title),
76
+ listeners: int(sc.currentlisteners),
77
+ source: 'shoutcast',
78
+ fetchedAt: Date.now(),
79
+ };
80
+ }
81
+ export async function readStatsTitle(url, mount, options = {}) {
82
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
83
+ const signal = abortAfter(timeoutMs, options.signal);
84
+ let res;
85
+ try {
86
+ res = await fetch(url, {
87
+ headers: { 'User-Agent': options.userAgent ?? DEFAULT_USER_AGENT, Accept: 'application/json, */*' },
88
+ redirect: 'follow',
89
+ signal,
90
+ });
91
+ }
92
+ catch (cause) {
93
+ const timedOut = cause instanceof Error && cause.name === 'TimeoutError';
94
+ throw new RadioError(timedOut ? 'timeout' : 'bad-response', timedOut ? `no response within ${timeoutMs}ms` : `request failed: ${cause.message}`, url, undefined, { cause });
95
+ }
96
+ if (!res.ok)
97
+ throw new RadioError('http', `status endpoint answered ${res.status}`, url, res.status);
98
+ // Shoutcast serves this as text/html and Icecast as text/json, so the
99
+ // content type is no help; parse and find out.
100
+ let body;
101
+ try {
102
+ body = JSON.parse(await res.text());
103
+ }
104
+ catch (cause) {
105
+ throw new RadioError('bad-response', 'status endpoint did not return JSON (a Shoutcast v1 server needs ?json=1)', url, res.status, { cause });
106
+ }
107
+ return parseStats(body, mount);
108
+ }
@@ -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,87 @@
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 function decodeMetadata(bytes, encoding = 'auto') {
17
+ if (encoding !== 'auto')
18
+ return new TextDecoder(encoding).decode(bytes);
19
+ const utf8 = new TextDecoder('utf-8').decode(bytes);
20
+ if (!utf8.includes('�'))
21
+ return utf8;
22
+ try {
23
+ return new TextDecoder('windows-1252').decode(bytes);
24
+ }
25
+ catch {
26
+ // A Node build without full ICU has only UTF-8. Mojibake beats throwing.
27
+ return utf8;
28
+ }
29
+ }
30
+ /**
31
+ * Pull `StreamTitle` out of an ICY metadata block.
32
+ *
33
+ * The block is a series of `key='value';` pairs, and the format has no
34
+ * escaping whatsoever — a title containing an apostrophe is simply ambiguous.
35
+ * Matching up to `';` (quote followed by semicolon) rather than the first
36
+ * quote is what keeps "Guns N' Roses" intact, and is what every real client
37
+ * does. A trailing key with no `;` is tolerated because some stations omit it.
38
+ */
39
+ export function parseStreamTitle(block) {
40
+ const terminated = /StreamTitle='([\s\S]*?)';/.exec(block);
41
+ const unterminated = terminated ? null : /StreamTitle='([\s\S]*)$/.exec(block);
42
+ const raw = (terminated ?? unterminated)?.[1];
43
+ if (raw === undefined)
44
+ return null;
45
+ // Stations pad the block with NUL bytes to a 16-byte boundary. The trailing
46
+ // quote only needs removing in the unterminated case — with no `';` to
47
+ // anchor on, the greedy match swallows the closing quote too.
48
+ let title = raw.replace(/\0+$/, '');
49
+ if (unterminated)
50
+ title = title.replace(/'$/, '');
51
+ title = title.trim();
52
+ return title === '' ? null : title;
53
+ }
54
+ /** Also in the block on some stations, and occasionally the only useful field. */
55
+ export function parseStreamUrl(block) {
56
+ const m = /StreamUrl='([\s\S]*?)';/.exec(block);
57
+ const value = m?.[1]?.replace(/\0+$/, '').trim();
58
+ return value ? value : null;
59
+ }
60
+ /**
61
+ * Split "Artist - Song" on the FIRST separator.
62
+ *
63
+ * Splitting on the last one would mangle "Simon & Garfunkel - Scarborough
64
+ * Fair - Canticle"; splitting on the first mangles "Sunday - Bloody Sunday" by
65
+ * a band whose name has no dash. Neither is right, because the format carries
66
+ * no structure — first-separator matches what listeners expect and what other
67
+ * clients do, and the raw title is always kept alongside.
68
+ */
69
+ export function splitTitle(title) {
70
+ // Real separators are surrounded by spaces. A bare hyphen is usually inside
71
+ // a word ("Jay-Z", "re-mastered") and must not split.
72
+ const m = /^(.+?)\s+[-–—]\s+(.+)$/.exec(title);
73
+ if (!m)
74
+ return { artist: null, song: null };
75
+ const artist = m[1].trim();
76
+ const song = m[2].trim();
77
+ return artist && song ? { artist, song } : { artist: null, song: null };
78
+ }
79
+ /**
80
+ * Some stations park an advert, the station name, or a literal "Unknown" in
81
+ * the title slot between songs. Callers usually want to show the last real
82
+ * track instead of that, so the obvious placeholders are reported as empty.
83
+ */
84
+ const PLACEHOLDERS = /^(unknown|unknown artist|n\/?a|none|no title|null|-|\.+)$/i;
85
+ export function isPlaceholder(title) {
86
+ return PLACEHOLDERS.test(title.trim());
87
+ }
@@ -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,14 @@
1
+ /** Thrown for transport and protocol failures — never for "no title yet". */
2
+ export class RadioError extends Error {
3
+ code;
4
+ url;
5
+ status;
6
+ constructor(code, message, url, status, options) {
7
+ super(message, options);
8
+ this.name = 'RadioError';
9
+ this.code = code;
10
+ this.url = url;
11
+ if (status !== undefined)
12
+ this.status = status;
13
+ }
14
+ }
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "radio-now-playing",
3
+ "version": "1.0.0",
4
+ "description": "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.",
5
+ "keywords": [
6
+ "icy",
7
+ "icecast",
8
+ "shoutcast",
9
+ "now-playing",
10
+ "metadata",
11
+ "streamtitle",
12
+ "internet-radio",
13
+ "radio",
14
+ "stream"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "kotatko",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/K0tatkoo/radio-now-playing.git"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/K0tatkoo/radio-now-playing/issues"
24
+ },
25
+ "homepage": "https://github.com/K0tatkoo/radio-now-playing#readme",
26
+ "type": "module",
27
+ "exports": {
28
+ ".": {
29
+ "import": {
30
+ "types": "./dist/esm/index.d.ts",
31
+ "default": "./dist/esm/index.js"
32
+ },
33
+ "require": {
34
+ "types": "./dist/cjs/index.d.ts",
35
+ "default": "./dist/cjs/index.js"
36
+ }
37
+ },
38
+ "./package.json": "./package.json"
39
+ },
40
+ "main": "./dist/cjs/index.js",
41
+ "module": "./dist/esm/index.js",
42
+ "types": "./dist/esm/index.d.ts",
43
+ "files": [
44
+ "dist",
45
+ "README.md",
46
+ "LICENSE",
47
+ "CHANGELOG.md"
48
+ ],
49
+ "sideEffects": false,
50
+ "engines": {
51
+ "node": ">=20.3.0"
52
+ },
53
+ "scripts": {
54
+ "build": "node scripts/build.mjs",
55
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
56
+ "typecheck": "tsc --noEmit -p tsconfig.json",
57
+ "test": "node --test \"test/**/*.test.ts\"",
58
+ "test:dist": "npm run build && node --test \"test/dist/*.test.mjs\"",
59
+ "smoke": "node scripts/smoke.mjs",
60
+ "check": "npm run typecheck && npm test && npm run test:dist",
61
+ "prepublishOnly": "npm run clean && npm run check"
62
+ },
63
+ "devDependencies": {
64
+ "@types/node": "^22.9.0",
65
+ "typescript": "^5.7.2"
66
+ }
67
+ }