icecast-metadata-stats
Version:
Simple to use Javascript class that queries an Icecast compatible server for metadata and statistics
444 lines (393 loc) • 14 kB
JavaScript
/**
* @license
* @see https://github.com/eshaz/icecast-metadata-js
* @copyright 2021-2023 Ethan Halsall
* This file is part of icecast-metadata-stats.
*
* icecast-metadata-stats free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* icecast-metadata-stats distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>
*/
import { IcecastReadableStream } from "icecast-metadata-js";
const noOp = () => {};
const STOPPED = "stopped";
const RUNNING = "running";
const FETCHING = "fetching";
const p = new WeakMap();
// variables
const icyController = Symbol();
const icyFetchStatus = Symbol();
const oggController = Symbol();
const oggFetchStatus = Symbol();
const icestatsEndpoint = Symbol();
const icestatsController = Symbol();
const icestatsFetchStatus = Symbol();
const statsEndpoint = Symbol();
const statsController = Symbol();
const statsFetchStatus = Symbol();
const nextsongsEndpoint = Symbol();
const nextsongsController = Symbol();
const nextsongsFetchStatus = Symbol();
const sevenhtmlEndpoint = Symbol();
const sevenhtmlController = Symbol();
const sevenhtmlFetchStatus = Symbol();
const streamEndpoint = Symbol();
const icyMetaInt = Symbol();
const icyCharacterEncoding = Symbol();
const icyDetectionTimeout = Symbol();
const sources = Symbol();
const interval = Symbol();
const onStats = Symbol();
const onStatsFetch = Symbol();
const state = Symbol();
const intervalId = Symbol();
// methods
const fetchStats = Symbol();
const getStreamMetadata = Symbol();
const parseIntNanToNull = (string) => {
let result = parseInt(string);
if (isNaN(result)) result = null;
return result;
};
export default class IcecastMetadataStats {
/**
* @constructor
* @param {URL} endpoint Stream endpoint
* @param {object} [options] Options object
*
* @callback [options.onStats] Called when the automatic query completes
* @callback [options.onStatsFetch] Called when the automatic query begins
* @param {Array} [options.sources] List of sources to automatically query ["icy", "ogg", "icestats", "stats", "sevenhtml", "nextsongs"]
* @param {number} [options.interval] Time in seconds to wait between automatically queries
* @param {URL} [options.icestatsEndpoint] Endpoint for the `status-json.xsl` source
* @param {URL} [options.statsEndpoint] Endpoint for the `stats` source
* @param {URL} [options.nextsongsEndpoint] Endpoint for the `nextsongs` source
* @param {URL} [options.sevenhtmlEndpoint] Endpoint for the `7.html` source
* @param {number} [options.icyMetaInt] Manually sets the ICY metadata interval
* @param {string} [options.icyCharacterEncoding] Character encoding to use for ICY metadata (defaults to "utf-8")
* @param {number} [options.icyDetectionTimeout] Time in milliseconds to search for ICY metadata
*/
constructor(endpoint, options = {}) {
const serverPath = endpoint.split("/").slice(0, -1).join("/");
// prettier-ignore
p.set(this, {
[streamEndpoint]: endpoint,
[icestatsEndpoint]: options.icestatsEndpoint || `${serverPath}/status-json.xsl`,
[statsEndpoint] : options.statsEndpoint || `${serverPath}/stats`,
[nextsongsEndpoint] : options.nextsongsEndpoint || `${serverPath}/nextsongs`,
[sevenhtmlEndpoint] : options.sevenhtmlEndpoint || `${serverPath}/7.html`,
[sources]: options.sources || [],
[interval]: (options.interval || 30) * 1000,
[onStats]: options.onStats || noOp,
[onStatsFetch]: options.onStatsFetch || noOp,
[icyMetaInt]: options.icyMetaInt,
[icyCharacterEncoding]: options.icyCharacterEncoding,
[icyDetectionTimeout]: options.icyDetectionTimeout,
[icyController]: new AbortController(),
[oggController]: new AbortController(),
[icestatsController]: new AbortController(),
[statsController]: new AbortController(),
[nextsongsController]: new AbortController(),
[sevenhtmlController]: new AbortController(),
[state]: STOPPED,
});
}
static xml2Json(xml) {
const deserialize = (xml) =>
new DOMParser().parseFromString(xml, "application/xml");
const serialize = (element) => {
if (!element.children.length) {
return Number.isNaN(Number(element.innerHTML))
? element.innerHTML
: Number(element.innerHTML);
}
const json = {};
for (const child of element.children) {
if (child.nodeName in json) {
if (Array.isArray(json[child.nodeName])) {
json[child.nodeName].push(serialize(child));
} else {
json[child.nodeName] = [json[child.nodeName], serialize(child)];
}
} else {
json[child.nodeName] = serialize(child);
}
}
return json;
};
return serialize(deserialize(xml));
}
/**
* @returns The current state ["stopped", "running", "fetching"]
*/
get state() {
return p.get(this)[state];
}
/**
* @returns The generated `status-json.xsl` endpoint
*/
get icestatsEndpoint() {
return p.get(this)[icestatsEndpoint];
}
/**
* @returns The generated `stats` endpoint
*/
get statsEndpoint() {
return p.get(this)[statsEndpoint];
}
/**
* @returns The generated `nextsongs` endpoint
*/
get nextsongsEndpoint() {
return p.get(this)[nextsongsEndpoint];
}
/**
* @returns The generated `7.html` endpoint
*/
get sevenhtmlEndpoint() {
return p.get(this)[sevenhtmlEndpoint];
}
/**
* @description Starts automatically fetching stats
*/
start() {
if (p.get(this)[state] === STOPPED) {
p.get(this)[state] = RUNNING;
this.fetch().then(p.get(this)[onStats]);
p.get(this)[intervalId] = setInterval(() => {
this.fetch().then(p.get(this)[onStats]);
}, p.get(this)[interval]);
}
}
/**
* @description Stops automatically fetching stats and cancels any inprogress stats
*/
stop() {
if (p.get(this)[state] !== STOPPED) {
p.get(this)[state] = STOPPED;
clearInterval(p.get(this)[intervalId]);
p.get(this)[icyController].abort();
p.get(this)[oggController].abort();
p.get(this)[icestatsController].abort();
p.get(this)[statsController].abort();
p.get(this)[sevenhtmlController].abort();
}
}
/**
* @description Manually fetches stats from the sources passed in to the `options.sources` parameter
* @async
* @returns {object} Object containing the stats from the sources
*/
async fetch() {
if (p.get(this)[state] !== FETCHING) {
const oldState = p.get(this)[state];
p.get(this)[state] = FETCHING;
p.get(this)[onStatsFetch](p.get(this)[sources]);
const promises = [];
if (p.get(this)[sources].includes("icestats"))
promises.push(this.getIcestats());
if (p.get(this)[sources].includes("sevenhtml"))
promises.push(this.getSevenhtml());
if (p.get(this)[sources].includes("stats"))
promises.push(this.getStats());
if (p.get(this)[sources].includes("nextsongs"))
promises.push(this.getNextsongs());
if (p.get(this)[sources].includes("icy"))
promises.push(this.getIcyMetadata());
if (p.get(this)[sources].includes("ogg"))
promises.push(this.getOggMetadata());
const stats = await Promise.all(promises).then((stats) =>
stats.reduce((acc, stat) => ({ ...acc, ...stat }), {}),
);
p.get(this)[state] =
p.get(this)[state] !== FETCHING ? p.get(this)[state] : oldState;
return stats;
}
}
/**
* @description Fetches the data from the `/status-json.xsl` endpoint
* @async
* @returns {object} Object containing results of `/status-json.xsl`
*/
async getIcestats() {
return this[fetchStats]({
status: icestatsFetchStatus,
endpoint: icestatsEndpoint,
controller: icestatsController,
mapper: (res) => res.json(),
}).then((stats) => ({ icestats: stats && stats.icestats }));
}
/*
<HTML><meta http-equiv="Pragma" content="no-cache"></head><body>350,1,132,1000,41,128,Dj Mixes Sety</body></html>
,141,1000,50,128,Gra AutoPilot audycje Energy 2000</body></html>
,27,1000,8,128,Gra Wavelogic audycje Rave With The Wave</body></html>
,578,1000,233,128,youtube.com/RadioPartyOfficial</body></html>
,15,1000,5,64,youtube.com/RadioPartyOfficial</body></html>
*/
// http://wiki.winamp.com/wiki/SHOUTcast_DNAS_Server_2_XML_Reponses#Equivalent_of_7.html
// CURRENTLISTENERS STREAMSTATUS PEAKLISTENERS MAXLISTENERS UNIQUELISTENERS BITRATE SONGTITLE
/**
* @description Fetches the data from the `/7.html` endpoint
* @async
* @returns {object} Object containing results of `/7.html`
*/
async getSevenhtml() {
return this[fetchStats]({
status: sevenhtmlFetchStatus,
endpoint: sevenhtmlEndpoint,
controller: sevenhtmlController,
mapper: async (res) =>
(await res.text()).match(/(.*?)<\/body>/gi).map((s) => {
const stats = s
.match(/(<body>|,)(?<stats>.*)<\/body>/i)
.groups.stats.split(",");
return stats.length === 7
? {
StreamTitle: stats[6],
currentListeners: parseIntNanToNull(stats[4]),
peakListeners: parseIntNanToNull(stats[2]),
maxListeners: parseIntNanToNull(stats[3]),
bitrate: parseIntNanToNull(stats[5]),
status: parseIntNanToNull(stats[1]),
serverListeners: parseIntNanToNull(stats[0]),
}
: {
StreamTitle: stats[4],
currentListeners: parseIntNanToNull(stats[2]),
peakListeners: parseIntNanToNull(stats[0]),
maxListeners: parseIntNanToNull(stats[1]),
bitrate: parseIntNanToNull(stats[3]),
};
}),
}).then((sevenhtml) => ({
sevenhtml,
}));
}
// http://wiki.winamp.com/wiki/SHOUTcast_DNAS_Server_2_XML_Reponses#General_Server_Summary
/**
* @description Fetches the data from the `/stats` endpoint
* @async
* @returns {object} Object containing results of `/stats`
*/
async getStats() {
return this[fetchStats]({
status: statsFetchStatus,
endpoint: statsEndpoint,
controller: statsController,
mapper: async (res) =>
IcecastMetadataStats.xml2Json(await res.text()).SHOUTCASTSERVER
.STREAMSTATS,
}).then((stats) => ({
stats,
}));
}
// http://wiki.winamp.com/wiki/SHOUTcast_DNAS_Server_2_XML_Reponses#Nextsongs
/**
* @description Fetches the data from the `/nextsongs` endpoint
* @async
* @returns {object} Object containing results of `/nextsongs`
*/
async getNextsongs() {
return this[fetchStats]({
status: nextsongsFetchStatus,
endpoint: nextsongsEndpoint,
controller: nextsongsController,
mapper: async (res) =>
IcecastMetadataStats.xml2Json(await res.text()).SHOUTCASTSERVER
.NEXTSONGS,
}).then((nextsongs) => ({
nextsongs,
}));
}
/**
* @description Fetches the first ICY metadata update from the stream
* @async
* @returns {object} Object containing ICY metadata
*/
async getIcyMetadata() {
return this[getStreamMetadata]({
status: icyFetchStatus,
endpoint: streamEndpoint,
controller: icyController,
metadataType: "icy",
headers: { "Icy-MetaData": 1 },
});
}
/**
* @description Fetches the first Ogg metadata update from the stream
* @async
* @returns {object} Object containing Ogg metadata
*/
async getOggMetadata() {
return this[getStreamMetadata]({
status: oggFetchStatus,
endpoint: streamEndpoint,
controller: oggController,
metadataType: "ogg",
});
}
async [getStreamMetadata]({
status,
endpoint,
controller,
headers,
metadataType,
}) {
return this[fetchStats]({
status,
endpoint,
controller,
headers,
mapper: async (res) =>
new Promise((resolve) => {
new IcecastReadableStream(res, {
onMetadata: ({ metadata }) => {
p.get(this)[controller].abort();
resolve(metadata);
},
onMetadataFailed: () => {
p.get(this)[controller].abort();
resolve();
},
metadataTypes: metadataType,
icyMetaInt: p.get(this)[icyMetaInt],
icyCharacterEncoding: p.get(this)[icyCharacterEncoding],
icyDetectionTimeout: p.get(this)[icyDetectionTimeout],
}).startReading();
}),
}).then((metadata) => ({ [metadataType]: metadata }));
}
async [fetchStats]({ status, endpoint, controller, mapper, headers = {} }) {
if (!p.get(this)[status]) {
p.get(this)[status] = true;
return fetch(p.get(this)[endpoint], {
method: "GET",
headers,
signal: p.get(this)[controller].signal,
})
.then((res) => {
if (!res.ok) throw new Error(`HTTP Error ${res.status}`);
return res;
})
.then(mapper)
.catch((e) => {
if (e.name !== "AbortError") {
console.warn(`Failed to fetch ${p.get(this)[endpoint]}`, e);
}
})
.finally(() => {
p.get(this)[status] = false;
p.get(this)[controller] = new AbortController();
});
}
}
}