radiomanifest.js 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. const fetch = require('isomorphic-unfetch')
  2. const shows = require('./shows.js')
  3. const calendar = require('./calendar.js')
  4. function getStreaminfoUrl (siteurl) {
  5. return siteurl + '/streaminfo.json' // XXX: improve this logic
  6. }
  7. function getManifestUrl (siteurl) {
  8. return siteurl + '/radiomanifest.xml' // XXX: improve this logic
  9. }
  10. function getAttribute(el, attr, default_value) {
  11. if(el.hasAttribute(attr))
  12. return el.getAttribute(attr);
  13. return default_value;
  14. }
  15. /**
  16. * Represents everything we know about a radio. This includes, but is not limited to, radiomanifest.xml.
  17. */
  18. class Radio {
  19. /**
  20. * @param {Array} [sources] optional
  21. * @param {string} [scheduleURL]
  22. * @param {string} [showsURL]
  23. * @param {string} [feed]
  24. */
  25. constructor (sources, scheduleURL, showsURL, feed) {
  26. this.streaming = new RadioStreaming(sources)
  27. this.scheduleURL = scheduleURL
  28. this.showsURL = showsURL
  29. this.feed = feed
  30. this.name = ''
  31. this.shows = []
  32. this.schedule = null
  33. }
  34. /**
  35. * @returns {RadioStreaming}
  36. */
  37. getStreaming () {
  38. return this.streaming
  39. }
  40. /**
  41. * Change radio name
  42. */
  43. setName (name) {
  44. this.name = name
  45. }
  46. /**
  47. *
  48. * @returns {Array<RadioShow>}
  49. */
  50. getShows() {
  51. return this.shows
  52. }
  53. /**
  54. * @returns {RadioShow} The lookup is exact and case-sensitive. If no such show can be found, `null`
  55. * is returned.
  56. */
  57. getShowByName (showName) {
  58. if (this.shows === undefined) return null
  59. return this.shows.find(s => s.name === showName)
  60. }
  61. /**
  62. * @returns {RadioSchedule} If no schedule is present, `null` is returned.
  63. */
  64. getSchedule () {
  65. return this.schedule
  66. }
  67. /**
  68. * Find if a show is running at the given moment. If there's none, `null` is returned.
  69. * If possible, a complete {@link RadioShow} including full informations (from shows.xml) is returned.
  70. * If, instead, we know from the `schedule` that there must be a show, but have no additional detail, a
  71. * {@link RadioShow} object will be created on the fly.
  72. *
  73. * @param {ICAL.Time} [now] If omitted, the current time is used.
  74. * @returns {RadioShow} If we don't know what's going on at the given moment, `null` is returned.
  75. */
  76. getShowAtTime (now) {
  77. if (this.schedule === undefined || this.schedule === null) return null
  78. return this.getSchedule().getNowShow(now)
  79. }
  80. /**
  81. * This static method can create a Radio object from a valid radiomanifest.xml file
  82. *
  83. * @param xml An already parsed xml block
  84. * @returns {Radio}
  85. */
  86. static fromDOM (xml) {
  87. const doc = xml.cloneNode(true)
  88. let res = doc.evaluate('/radio-manifest/streaming/source', doc, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
  89. const sources = []
  90. for (let i = 0; i < res.snapshotLength; i++) {
  91. const src = res.snapshotItem(i)
  92. if (!src.hasAttribute('priority')) {
  93. src.setAttribute('priority', '0')
  94. } else if (parseInt(src.getAttribute('priority'), 10) < 0) {
  95. continue
  96. }
  97. sources.push(src)
  98. }
  99. res = doc.evaluate('/radio-manifest/schedule', doc)
  100. const scheduleEl = res.iterateNext()
  101. let scheduleURL = null
  102. if (scheduleEl !== null) {
  103. scheduleURL = scheduleEl.getAttribute('src')
  104. }
  105. res = xml.evaluate('/radio-manifest/shows', xml)
  106. const showsEl = res.iterateNext()
  107. let showsURL = null
  108. if (showsEl !== null) {
  109. showsURL = showsEl.getAttribute('src')
  110. }
  111. res = xml.evaluate('/radio-manifest/feed', xml)
  112. const feedEl = res.iterateNext()
  113. let feed = null
  114. if (feedEl !== null) {
  115. feed = feedEl.getAttribute('src')
  116. }
  117. const manifest = new Radio(sources, scheduleURL, showsURL, feed)
  118. return manifest
  119. }
  120. }
  121. /**
  122. * Represents the streaming capabilities of a radio.
  123. * This has probably been derived from the <streaming> block in radiomanifest.xml
  124. */
  125. class RadioStreaming {
  126. constructor (sources) {
  127. this.sources = sources.sort(
  128. (a,b) => this.getPriority(a) < this.getPriority(a)
  129. )
  130. }
  131. /**
  132. * Get the list of possible options that are provided to the user
  133. * @returns {Array<string>}
  134. */
  135. getOptions() {
  136. return this.sources.map(function (x) {
  137. return x.getAttribute('name')
  138. })
  139. }
  140. /**
  141. * @private
  142. */
  143. getPriority(element) {
  144. return parseInt(getAttribute(element, 'priority', '1'))
  145. }
  146. /**
  147. * @private
  148. */
  149. getTopPrioritySources() {
  150. var topPriority = this.getPriority(this.sources[0])
  151. return this.sources.filter(
  152. (src) => parseInt(src.getAttribute('priority'), 10) === topPriority
  153. )
  154. }
  155. /**
  156. * @return {string} url of the source. Note that this is much probably a playlist, in M3U format
  157. */
  158. getSource(name) {
  159. if (name === undefined) {
  160. return this.getTopPrioritySources()[0]
  161. }
  162. const s = this.sources.find(function (x) {
  163. return x.getAttribute('name') === name
  164. })
  165. if (s === undefined) return s
  166. return s.getAttribute('src')
  167. }
  168. /**
  169. * This is your go-to function whenever you need a list of URLs to play.
  170. * They will be picked honoring priorities, and expanding the playlist source
  171. * @return {Array<string>}
  172. */
  173. async pickURLs() {
  174. var allSources = this.getTopPrioritySources()
  175. var allAudios = []
  176. for(let src of allSources) {
  177. let url = src.getAttribute('src')
  178. let resp = await fetch(url)
  179. allAudios.unshift(... parseM3U(await resp.text()))
  180. }
  181. return allAudios
  182. }
  183. /**
  184. * Just like {@link RadioStreaming#pickURLs}, but get a single URL
  185. * @return {string}
  186. */
  187. async pickURL() {
  188. var allAudios = await this.pickURLs()
  189. return allAudios[0]
  190. }
  191. }
  192. /**
  193. * Create everything you need - **you should start from here**
  194. *
  195. * @param {string} siteurl URL of website you want to load
  196. * @param {Object} options options. Currenly unused
  197. * @return {Radio}
  198. */
  199. async function get (siteurl, options) {
  200. let resp = await fetch(getManifestUrl(siteurl))
  201. let text = await resp.text()
  202. const parser = new DOMParser()
  203. const dom = parser.parseFromString(text, 'text/xml')
  204. const manifest = Radio.fromDOM(dom)
  205. try {
  206. manifest.shows = await shows.get(manifest)
  207. } catch (e) {
  208. console.error("Error while fetching shows file", e)
  209. }
  210. try {
  211. manifest.schedule = await calendar.get(manifest)
  212. if (manifest.schedule !== undefined)
  213. manifest.schedule.radio = manifest
  214. } catch (e) {
  215. console.error("Error while fetching shows file", e)
  216. }
  217. resp = null
  218. try {
  219. resp = await fetch(getStreaminfoUrl(siteurl))
  220. } catch (e) {
  221. true
  222. }
  223. if(resp !== null) {
  224. try {
  225. text = await resp.text()
  226. const data = JSON.parse(text)
  227. const name = data['icy-name']
  228. if (name !== undefined) {
  229. manifest.setName(name)
  230. }
  231. } catch (e) {
  232. if (e instanceof SyntaxError) {
  233. true
  234. } else {
  235. console.error('Error', e)
  236. throw e
  237. }
  238. }
  239. }
  240. return manifest
  241. }
  242. function parseM3U (body) {
  243. return body.split('\n').filter((line) => {
  244. if (line.startsWith('#')) {
  245. return false
  246. } else {
  247. try {
  248. new URL(line); return true
  249. } catch {
  250. return false
  251. }
  252. }
  253. })
  254. }
  255. module.exports = {
  256. get: get,
  257. objs: {
  258. Radio: Radio,
  259. RadioStreaming: RadioStreaming
  260. },
  261. parsers: {
  262. M3U: parseM3U,
  263. radioManifest: Radio.fromDOM,
  264. shows: shows.parse,
  265. }
  266. }