radiomanifest.js 8.0 KB

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