const fetch = require('isomorphic-unfetch') const shows = require('./shows.js') const calendar = require('./calendar.js') function getStreaminfoUrl (siteurl) { return siteurl + '/streaminfo.json' // XXX: improve this logic } function getManifestUrl (siteurl) { return siteurl + '/radiomanifest.xml' // XXX: improve this logic } function getAttribute(el, attr, default_value) { if(el.hasAttribute(attr)) return el.getAttribute(attr); return default_value; } /** * Represents everything we know about a radio. This includes, but is not limited to, radiomanifest.xml. */ class Radio { /** * @param {Array} [sources] optional * @param {string} [scheduleURL] * @param {string} [showsURL] * @param {string} [feed] */ constructor (sources, scheduleURL, showsURL, feed) { this.streaming = new RadioStreaming(sources) this.scheduleURL = scheduleURL this.showsURL = showsURL this.feed = feed this.name = '' this.description = '' this.logo = null this.shows = [] this.schedule = null } /** * @returns {RadioStreaming} */ getStreaming () { return this.streaming } setName (name) { this.name = name } /** * The radio name, as inferred by stream-meta * * @returns {string} */ getName() { return this.name } setDescription(desc) { this.description = desc } /** * The description of the radio, as inferred by stream-meta * * @returns {string} */ getDescription() { return this.description } setLogo(logo) { this.logo = logo } /** * @returns {string} the URL of the logo, or `null` if not found */ getLogo() { return this.logo } /** * * @returns {Array} */ getShows() { return this.shows } /** * @returns {RadioShow} The lookup is exact and case-sensitive. If no such show can be found, `null` * is returned. */ getShowByName (showName) { if (this.shows === undefined) return null return this.shows.find(s => s.name === showName) } /** * @returns {RadioSchedule} If no schedule is present, `null` is returned. */ getSchedule () { return this.schedule } /** * Find if a show is running at the given moment. If there's none, `null` is returned. * If possible, a complete {@link RadioShow} including full informations (from shows.xml) is returned. * If, instead, we know from the `schedule` that there must be a show, but have no additional detail, a * {@link RadioShow} object will be created on the fly. * * @param {ICAL.Time} [now] If omitted, the current time is used. * @returns {RadioShow} If we don't know what's going on at the given moment, `null` is returned. */ getShowAtTime (now) { if (this.schedule === undefined || this.schedule === null) return null return this.getSchedule().getNowShow(now) } /** * This static method can create a Radio object from a valid radiomanifest.xml file * * @param xml An already parsed xml block * @returns {Radio} */ static fromDOM (xml) { const doc = xml.cloneNode(true) let res = doc.evaluate('/radio-manifest/streaming/source', doc, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null) const sources = [] for (let i = 0; i < res.snapshotLength; i++) { const src = res.snapshotItem(i) if (!src.hasAttribute('priority')) { src.setAttribute('priority', '0') } else if (parseInt(src.getAttribute('priority'), 10) < 0) { continue } sources.push(src) } res = doc.evaluate('/radio-manifest/schedule', doc) const scheduleEl = res.iterateNext() let scheduleURL = null if (scheduleEl !== null) { scheduleURL = scheduleEl.getAttribute('src') } res = xml.evaluate('/radio-manifest/shows', xml) const showsEl = res.iterateNext() let showsURL = null if (showsEl !== null) { showsURL = showsEl.getAttribute('src') } res = xml.evaluate('/radio-manifest/feed', xml) const feedEl = res.iterateNext() let feed = null if (feedEl !== null) { feed = feedEl.getAttribute('src') } const manifest = new Radio(sources, scheduleURL, showsURL, feed) return manifest } } /** * Represents the streaming capabilities of a radio. * This has probably been derived from the block in radiomanifest.xml */ class RadioStreaming { constructor (sources) { this.sources = sources.sort( (a,b) => this.getPriority(a) < this.getPriority(a) ) } /** * Get the list of possible options that are provided to the user * @returns {Array} */ getOptions() { return this.sources.map(function (x) { return x.getAttribute('name') }) } /** * @private */ getPriority(element) { return parseInt(getAttribute(element, 'priority', '1')) } /** * @private */ getTopPrioritySources() { var topPriority = this.getPriority(this.sources[0]) return this.sources.filter( (src) => parseInt(src.getAttribute('priority'), 10) === topPriority ) } /** * @return {string} url of the source. Note that this is much probably a playlist, in M3U format */ getSource(name) { if (name === undefined) { return this.getTopPrioritySources()[0] } const s = this.sources.find(function (x) { return x.getAttribute('name') === name }) if (s === undefined) return s return s.getAttribute('src') } /** * This is your go-to function whenever you need a list of URLs to play. * They will be picked honoring priorities, and expanding the playlist source * @return {Array} */ async pickURLs() { var allSources = this.getTopPrioritySources() var allAudios = [] for(let src of allSources) { let url = src.getAttribute('src') let resp = await fetch(url) allAudios.unshift(... parseM3U(await resp.text())) } return allAudios } /** * Just like {@link RadioStreaming#pickURLs}, but get a single URL * @return {string} */ async pickURL() { var allAudios = await this.pickURLs() return allAudios[0] } } /** * Create everything you need - **you should start from here** * * @param {string} siteurl URL of website you want to load * @param {Object} options options. Currenly unused * @return {Radio} */ async function get (siteurl, options) { let resp = await fetch(getManifestUrl(siteurl)) let text = await resp.text() const parser = new DOMParser() const dom = parser.parseFromString(text, 'text/xml') const manifest = Radio.fromDOM(dom) try { manifest.shows = await shows.get(manifest) } catch (e) { console.error("Error while fetching shows file", e) } try { manifest.schedule = await calendar.get(manifest) if (manifest.schedule !== undefined) manifest.schedule.radio = manifest } catch (e) { console.error("Error while fetching shows file", e) } resp = null try { resp = await fetch(getStreaminfoUrl(siteurl)) } catch (e) { true } if(resp !== null) { try { text = await resp.text() const data = JSON.parse(text) const name = data['icy-name'] if (name !== undefined) { manifest.setName(name) } const desc = data['icy-description'] if (desc !== undefined) { manifest.setDescription(desc) } const logo = data['icy-logo'] if (desc !== undefined) { manifest.setLogo(logo) } } catch (e) { if (e instanceof SyntaxError) { true } else { console.error('Error', e) throw e } } } return manifest } function parseM3U (body) { return body.split('\n').filter((line) => { if (line.startsWith('#')) { return false } else { try { new URL(line); return true } catch { return false } } }) } module.exports = { get: get, objs: { Radio: Radio, RadioStreaming: RadioStreaming }, parsers: { M3U: parseM3U, radioManifest: Radio.fromDOM, shows: shows.parse, } }