UNPKG

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
/** * @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(); }); } } }