radiomanifest.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  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(
  160. (a, b) => this.getPriority(a) < this.getPriority(b),
  161. );
  162. }
  163. /**
  164. * Get the list of possible options that are provided to the user
  165. * @returns {Array<string>}
  166. */
  167. getOptions() {
  168. return this.sources.map(function (x) {
  169. return x.getAttribute("name");
  170. });
  171. }
  172. /**
  173. * @private
  174. */
  175. getPriority(element) {
  176. return parseInt(getAttribute(element, "priority", "1"));
  177. }
  178. /**
  179. * @private
  180. */
  181. getTopPrioritySources() {
  182. var topPriority = this.getPriority(this.sources[0]);
  183. return this.sources.filter(
  184. (src) => parseInt(src.getAttribute("priority"), 10) === topPriority,
  185. );
  186. }
  187. /**
  188. * @return {string} url of the source. Note that this is much probably a playlist, in M3U format
  189. */
  190. getSource(name) {
  191. if (name === undefined) {
  192. return this.getTopPrioritySources()[0];
  193. }
  194. const s = this.sources.find(function (x) {
  195. return x.getAttribute("name") === name;
  196. });
  197. if (s === undefined) return s;
  198. return s.getAttribute("src");
  199. }
  200. /**
  201. * This is your go-to function whenever you need a list of URLs to play.
  202. * They will be picked honoring priorities, and expanding the playlist source
  203. * @return {Array<string>}
  204. */
  205. async pickURLs() {
  206. var allSources = this.getTopPrioritySources();
  207. var allAudios = [];
  208. for (let src of allSources) {
  209. let url = src.getAttribute("src");
  210. let resp = await fetch(url);
  211. allAudios.unshift(...parseM3U(await resp.text()));
  212. }
  213. return allAudios;
  214. }
  215. /**
  216. * Just like {@link RadioStreaming#pickURLs}, but get a single URL
  217. * @return {string}
  218. */
  219. async pickURL() {
  220. var allAudios = await this.pickURLs();
  221. return allAudios[0];
  222. }
  223. }
  224. /**
  225. * Create everything you need - **you should start from here**
  226. *
  227. * @param {string} siteurl URL of website you want to load
  228. * @param {Object} options options. Currenly unused
  229. * @return {Radio}
  230. */
  231. async function get(siteurl, _options) {
  232. let resp = await fetch(getManifestUrl(siteurl));
  233. let text = await resp.text();
  234. const parser = new DOMParser();
  235. const dom = parser.parseFromString(text, "text/xml");
  236. const manifest = Radio.fromDOM(dom);
  237. try {
  238. manifest.shows = await shows.get(manifest);
  239. } catch (e) {
  240. console.error("Error while fetching shows file", e);
  241. }
  242. try {
  243. manifest.schedule = await calendar.get(manifest);
  244. if (manifest.schedule !== undefined) manifest.schedule.radio = manifest;
  245. } catch (e) {
  246. console.error("Error while fetching shows file", e);
  247. }
  248. resp = null;
  249. try {
  250. resp = await fetch(getStreaminfoUrl(siteurl));
  251. } catch (e) {
  252. true;
  253. }
  254. if (resp !== null) {
  255. try {
  256. text = await resp.text();
  257. const data = JSON.parse(text);
  258. const name = data["icy-name"];
  259. if (name !== undefined) {
  260. manifest.setName(name);
  261. }
  262. const desc = data["icy-description"];
  263. if (desc !== undefined) {
  264. manifest.setDescription(desc);
  265. }
  266. const logo = data["icy-logo"];
  267. if (desc !== undefined) {
  268. manifest.setLogo(logo);
  269. }
  270. } catch (e) {
  271. if (e instanceof SyntaxError) {
  272. true;
  273. } else {
  274. console.error("Error", e);
  275. throw e;
  276. }
  277. }
  278. }
  279. return manifest;
  280. }
  281. function parseM3U(body) {
  282. return body.split("\n").filter((line) => {
  283. if (line.startsWith("#")) {
  284. return false;
  285. } else {
  286. try {
  287. new URL(line);
  288. return true;
  289. } catch {
  290. return false;
  291. }
  292. }
  293. });
  294. }
  295. export default {
  296. get: get,
  297. objs: {
  298. Radio: Radio,
  299. RadioStreaming: RadioStreaming,
  300. },
  301. parsers: {
  302. M3U: parseM3U,
  303. radioManifest: Radio.fromDOM,
  304. shows: shows.parse,
  305. },
  306. };