radiomanifest.js 8.8 KB

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