Compare commits

...

41 commits

Author SHA1 Message Date
49ef072ba9 improve metadata 2023-09-14 13:07:06 +02:00
f2f4fef8fb scoped package name 2023-09-14 13:06:55 +02:00
67fb372516 prettier 2023-09-14 00:34:55 +01:00
3d0ac08488 various fix thanks to eslint 2023-09-14 00:16:21 +01:00
4e19b6304e apply prettier 2023-09-14 00:07:13 +01:00
dd96ce7b2a add linters 2023-09-14 00:02:34 +01:00
207f06a791 make it ESM ready 2023-03-18 21:05:04 +01:00
baa181354f make it ESM ready 2023-01-23 12:42:25 +01:00
1a78e2344c ignores doc output 2022-02-02 10:16:47 +01:00
186ed67a8d more tests for getNext 2022-02-02 10:16:30 +01:00
52cf3c5387 getNext 2022-02-02 10:16:12 +01:00
303819879d supports description and logo 2022-01-30 14:57:10 +01:00
0e27a5d599 avoid date in output 2022-01-30 14:42:07 +01:00
9d2db78c98 improve quickstart tutorial 2022-01-30 14:38:43 +01:00
e8f97b8cae customize home page 2022-01-30 14:33:35 +01:00
a7253dc6c8 add quickstart 2022-01-30 14:33:35 +01:00
ba0a79cc49 jsdoc comments all over the place 2022-01-30 14:04:14 +01:00
eadb0fa7b6 doc support 2022-01-30 13:49:08 +01:00
8cde139866 more tests and demos for schedule 2022-01-30 02:38:37 +01:00
a19dbc8de1 some more tests for schedule 2022-01-30 02:32:37 +01:00
c139b1faef schedule support is tested 2022-01-30 02:24:56 +01:00
a3dcb0795a demo UI has "now" support 2022-01-30 02:24:40 +01:00
5bfe52c235 support schedule 2022-01-30 02:24:21 +01:00
d524cbbace shows is a separate file 2022-01-30 01:06:35 +01:00
d3fed0c424 demo website uses shows 2022-01-30 01:00:30 +01:00
eab5da1ec7 accepts half-baked shows files
this makes the similarity with XBEL a bit better
2022-01-30 00:42:18 +01:00
0d79eb615d improve tests 2022-01-30 00:26:58 +01:00
0085d53bf1 support RadioShows 2022-01-30 00:26:07 +01:00
29d74c1b93 new home: fix tests 2022-01-29 23:00:37 +01:00
ff579acfbb RadioStreaming has all the planned features 2021-12-26 20:02:19 +01:00
e1e740dbf0 simple UI to test player with pickURLs 2021-12-26 20:00:47 +01:00
935fc70e99 pickURLs added 2021-12-26 20:00:17 +01:00
f55ccec939 parseM3U tested 2021-12-26 19:59:59 +01:00
1fba07bd67 test more things about streaminfo 2021-12-05 17:40:36 +01:00
b21596d1d7 fetching streaminfo is more tolerant 2021-12-05 17:40:17 +01:00
6544f023e5 FIX document parsing and setting priorities 2021-12-05 17:39:52 +01:00
boyska
cec6cb9101 new tests for source404 2021-12-05 17:39:28 +01:00
boyska
86c58fc39d refactor tests 2021-12-05 16:58:36 +01:00
3bea9b610c fix url 2021-12-05 16:58:20 +01:00
1dbd19c6b2 more object-orientation 2021-11-25 22:33:17 +01:00
e5fa7fc3a2 tests for error 404 2021-11-25 22:33:06 +01:00
23 changed files with 1345 additions and 235 deletions

15
.eslintrc.json Normal file
View file

@ -0,0 +1,15 @@
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": ["eslint:recommended", "prettier"],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"no-unused-vars": ["error", {"argsIgnorePattern": "^_"}]
}
}

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
node_modules
dist/*.js
.*.swp
/docs/

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
libs/

View file

@ -1,6 +1,6 @@
# Radiomanifest.js
An implementation of the ever-famous [radiomanifest spec](https://boyska.degenerazione.xyz/radiomanifest/)
An implementation of the ever-famous [radiomanifest spec](https://radiomanifest.degenerazione.xyz//)
available as a Node module, a browser module, or an ugly-but-works UMD that exports a "radiomanifest" global.
@ -13,6 +13,5 @@ npm run test
## Supported spec
- [radiomanifest](https://boyska.degenerazione.xyz/radiomanifest/)
- [stream-meta v2](https://www.stream-meta.info/version_2_files.html): name of the radio is extracted from `/streaminfo.json`
- [radiomanifest](https://radiomanifest.degenerazione.xyz/)
- [stream-meta v2](https://www.stream-meta.info/version_2_files.html): name of the radio is extracted from `/streaminfo.json`

266
calendar.js Normal file
View file

@ -0,0 +1,266 @@
import ICAL from "ical.js";
import shows from "./shows";
function max(a, b) {
if (a < b) return b;
return a;
}
/**
* Represent a schedule (ie: a .ics file)
*/
class RadioSchedule {
constructor(calendar, radio) {
this.calendar = calendar; // ICAL.Calendar
this.radio = radio; // radiomanifest.Radio
}
/**
* Get a list of all known {@link vEvent}s
*
* @returns {Array<external:ICAL~Component>}
* */
getEvents() {
return this.calendar.getAllSubcomponents("vevent");
}
/**
* @returns {RadioShow} tries to get a matching show, or create a new one
*/
getShowByEvent(ev) {
if (ev === null) return null;
if (this.radio !== undefined) {
const showid = RadioSchedule.veventGetShowID(ev);
var show = this.radio.getShowByName(showid);
if (show === null || show === undefined) {
return new shows.RadioShow(RadioSchedule.veventGetSummary(ev));
}
return show;
}
return new shows.RadioShow(RadioSchedule.veventGetSummary(ev));
}
/**
* @returns {external:ICAL~Component} if nothing is going on right now, `null` is returned
*/
getNowEvent(now) {
var ev_now = this.getEvents().filter(function (vevent) {
const ev = new ICAL.Event(vevent);
return isNow(ev, now);
});
ev_now.sort((e1, e2) => {
return this.veventGetPriority(e1) - this.veventGetPriority(e2);
});
if (ev_now.length === 0) return null;
return ev_now[0];
}
/**
* @returns {RadioShow} if nothing is going on right now, `null` is returned
*/
getNowShow(now) {
const ev = this.getNowEvent(now);
return this.getShowByEvent(ev);
}
/**
* @returns {NextEvent} if there is none, `null` is returned
*/
getNextEvent(now) {
var nowEvent = this.getNowEvent(now);
let future_events = this.getEvents()
.filter((e) => {
return e != nowEvent;
})
.map((e) => {
const vEvent = new ICAL.Event(e);
return { event: e, time: getNext(vEvent, now) };
})
.filter((x) => {
return x.time !== null && x.time !== undefined;
});
// since ".sort()" is guaranteed to be stable, we can sort by priority, then by date, so that two events
// starting at the same time will be sorted observing priority
future_events.sort(
(x1, x2) =>
this.veventGetPriority(x1.event) - this.veventGetPriority(x2.event),
);
future_events.sort((x, y) => x.time.toUnixTime() - y.time.toUnixTime());
if (future_events.length === 0) {
return null;
}
return future_events[0];
}
/**
* @returns {NextShow} if there is no next show, null will be returned
*/
getNextShow(now) {
const next = this.getNextEvent(now);
if (next === null) return null;
const ev = next.event;
const show = this.getShowByEvent(ev);
return { show: show, time: next.time };
}
static veventGetSummary(vevent) {
return vevent.getFirstProperty("summary").getFirstValue();
}
static veventGetShowID(vevent) {
return RadioSchedule.veventGetSummary(vevent); // XXX: X-Show-ID
}
/**
* @return {integer} a normalized version of priority, easier to compare
*/
veventGetPriority(ev) {
const prop = ev.getFirstProperty("priority");
let prio;
if (prop === null) {
prio = null;
} else {
prio = prop.getFirstValue();
}
if (prio === null || prio === 0) {
prio = 100;
}
return prio;
}
}
function isNow(vEvent, now) {
if (now === undefined) {
now = ICAL.Time.now();
}
if (vEvent.isRecurring()) {
return isNowRecurring(vEvent, now);
}
return now < vEvent.endDate && now > vEvent.startDate;
}
function isNowRecurring(vEvent, now) {
var expand = vEvent.iterator(vEvent.startDate);
var next, next_end;
while ((next = expand.next())) {
next_end = next.clone();
next_end.addDuration(vEvent.duration);
if (next_end > now) {
break;
}
}
return now < next_end && now > next;
}
/*
* @private
* @param {external:ICAL~Component} vEvent a _recurring_ vEvent
* @param {external:ICAL~Time} [now]
* @return {external:ICAL~Time} first future occurrence of this event
*/
function getNext(vEvent, now) {
if (now === undefined) {
now = ICAL.Time.now();
}
if (vEvent.isRecurring()) {
return getNextRecurring(vEvent, now);
}
if (vEvent.endDate > now) {
const val = max(now, vEvent.startDate);
return val;
}
return null;
}
/*
* @private
* @param {external:ICAL~Component} vEvent a _recurring_ vEvent
* @param {external:ICAL~Time} now
* @return {external:ICAL~Time} first future occurrence of this event
*/
function getNextRecurring(vEvent, now) {
var expand = vEvent.iterator(vEvent.startDate);
var next, next_end;
while ((next = expand.next())) {
const start = next.clone();
next_end = start.clone();
next_end.addDuration(vEvent.duration);
if (next_end <= now) {
continue;
}
return max(start, now);
}
return null;
}
async function get(manifest) {
if (manifest.scheduleURL) {
let resp = null;
try {
resp = await fetch(manifest.scheduleURL);
} catch (e) {
true;
}
if (resp !== null) {
try {
const text = await resp.text();
return parse(text);
} catch (e) {
console.error("Error while parsing schedule", e);
throw e;
}
}
}
}
/**
* Parse ICAL and get a RadioSchedule
*
* @param {string} text The text, in ICS format
* @returns {RadioSchedule}
*/
function parse(text) {
var jcalData = ICAL.parse(text);
var vcalendar = new ICAL.Component(jcalData);
return new RadioSchedule(vcalendar);
}
export default {
get: get,
parse: parse,
RadioSchedule: RadioSchedule,
};
/**
* @typedef {Object} NextShow
* @property {RadioShow} show The next show scheduled
* @property {external:ICAL~Time} time When it will start
*/
/**
* @typedef {Object} NextEvent
* @property {RadioShow} event The next show scheduled
* @property {external:ICAL~Time} time When it will start
*/
/**
* @external ICAL
* @see https://mozilla-comm.github.io/ical.js/api/index.html
*/
/**
* @class Component
* @memberof external:ICAL
* @inner
* @see https://mozilla-comm.github.io/ical.js/api/ICAL.Component.html
*/
/**
* @class Time
* @memberof external:ICAL
* @inner
* @see https://mozilla-comm.github.io/ical.js/api/ICAL.Time.html
*/

7
doc/home.md Normal file
View file

@ -0,0 +1,7 @@
Radiomanifest is a specification to make webradios express their offer in a standard way. This is a client
library aiming at making the development of expressive webapps a breeze.
You are strongly invited to start from:
- {@tutorial quickstart} tutorial
- {@link get} this is the first function you'll ever call, in 99% of user scenarios

View file

@ -0,0 +1,55 @@
Using radiomanifest is pretty simple. In an ideal usecase, you can easily do this:
```javascript
const radiomanifest = require("radiomanifest");
const radio = radiomanifest.get("http://myradio.com/");
console.log(radio.getName());
```
Now we have `radio`, a {@link Radio} object, which can be seen as the "center" of our data. From here, we can
get more data.
## Streaming
The first thing we could want to do is just to _play_ the radio. Let's use the {@link RadioStreaming#pickURLs
RadioStreaming.pickURLs} method then
```javascript
var streaming = radio.getStreaming();
var urls = await streaming.pickURLs();
console.log(urls);
```
and here we go! This is a list of URLs that the radio is indicating to us as their preferred ones. Why not a
single one? Well, this could include different servers, so that the client itself can act as load-balancers.
## Shows
Another nice thing you might want to do, is to display a list of all the shows that the radio is broadcasting.
Our {@link Radio} keeps track of those, and for each show we can have useful metadata. See {@link RadioShow}
for more details.
```javascript
var shows = radio.getShows();
console.log(shows.map((s) => s.getName()));
```
## Schedule
```javascript
const show = radio.getShowAtTime();
if (show !== null) {
console.log(show.getName());
} else {
console.log("Nothing special going on right now, sorry");
}
```
{@link Radio#getShowAtTime getShowAtTime} is a shortcut, but using {@link Radio#getSchedule} you'll get a nice
{@link RadioSchedule}, which has many useful methods to get meaningful information about what's going on right
now and {@link RadioSchedule#getNextShow what will go on next}
## Conclusions
I hope this tutorial helped you get your feet wet. Hopefully, using radiomanifest you'd be able to create
great webapps that work on _any_ webradio (well, any webradio that supports radiomanifest, at least).

View file

@ -1,11 +1,27 @@
<!doctype html>
<html>
<head>
</head>
<body>
Ciao
<script src="libs/ical.min.js"></script>
<script src="dist/radiomanifest-oldstyle.bundle.js"></script>
<script src="ui.js"></script>
</body>
<head>
<style>
.info {
border: 1px solid gray;
max-width: 20em;
padding: 0.3em;
font-family: monospace;
}
</style>
</head>
<body>
Ciao
<script src="libs/ical.min.js"></script>
<script src="dist/radiomanifest-oldstyle.bundle.js"></script>
<script src="ui.js"></script>
<div id="player">
<audio controls></audio>
<div class="info" id="now-info"></div>
</div>
<div class="info" id="show-info"></div>
<div id="shows">
<ul></ul>
</div>
</body>
</html>

24
jsdoc.conf.json Normal file
View file

@ -0,0 +1,24 @@
{
"plugins": ["plugins/markdown.js"],
"opts": {
"template": "node_modules/docdash",
"encoding": "utf8",
"destination": "./docs"
},
"templates": {
"default": {
"includeDate": false
}
},
"docdash": {
"static": true,
"sort": true,
"search": true,
"collapse": true,
"typedefs": false,
"private": false,
"navLevel": 0,
"removeQuotes": "none",
"scripts": []
}
}

View file

@ -1,34 +1,30 @@
module.exports = function (config) {
config.set({
frameworks: ['mocha'],
frameworks: ["mocha"],
// plugins: ['karma-webpack', 'karma-mocha', 'karma-chai-as-promised'],
webpack: {
// karma watches the test entry points
// Do NOT specify the entry option
// webpack watches dependencies
// webpack configuration
},
preprocessors: {
'test/**/*.js': ['webpack'],
'radiomanifest.js': ['webpack']
"test/**/*.js": ["webpack"],
"radiomanifest.js": ["webpack"],
},
files: [
'radiomanifest.js',
'test/**/*.js'
],
reporters: ['progress'],
files: ["radiomanifest.js", "test/**/*.js"],
reporters: ["progress"],
port: 9876, // karma web server port
colors: true,
logLevel: config.LOG_INFO,
browsers: ['ChromeHeadless', 'FirefoxHeadless'],
browsers: ["ChromeHeadless", "FirefoxHeadless"],
autoWatch: false,
concurrency: Infinity,
customLaunchers: {
FirefoxHeadless: {
base: 'Firefox',
flags: ['-headless']
}
}
})
}
base: "Firefox",
flags: ["-headless"],
},
},
});
};

View file

@ -1,10 +1,12 @@
{
"name": "radiomanifest",
"name": "@radiomanifest/radiomanifest",
"version": "0.1.0",
"description": "",
"description": "An implementation of the ever-famous [radiomanifest spec](https://radiomanifest.degenerazione.xyz//)",
"main": "radiomanifest.js",
"module": "radiomanifest.js",
"directories": {
"test": "tests"
"doc": "doc",
"test": "test"
},
"repository": {
"type": "git",
@ -14,23 +16,34 @@
"test": "npm run test:node && npm run test:browser",
"test:node": "mocha",
"test:browser": "karma start --single-run --browsers ChromeHeadless,FirefoxHeadless karma.config.js",
"docs": "TZ=UTC jsdoc -c ./jsdoc.conf.json --tutorials doc/tutorials/ --package ./package.json . doc/home.md",
"build": "webpack"
},
"author": "",
"license": "ISC",
"author": "boyska",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"chai": "^4.3.4",
"chai-as-promised": "^7.1.1",
"docdash": "^1.2.0",
"eslint": "^8.49.0",
"eslint-config-prettier": "^9.0.0",
"jsdoc": "^4.0.2",
"karma": "^6.3.9",
"karma-chrome-launcher": "^3.1.0",
"karma-firefox-launcher": "^2.1.2",
"karma-mocha": "^2.0.1",
"karma-webpack": "^5.0.0",
"mocha": "^9.1.3",
"prettier": "3.0.3",
"webpack": "^5.64.1",
"webpack-cli": "^4.9.1"
},
"dependencies": {
"ical.js": "^1.5.0",
"isomorphic-unfetch": "^3.1.0"
}
},
"keywords": [
"radio",
"podcast"
]
}

View file

@ -1,159 +1,341 @@
const fetch = require('isomorphic-unfetch')
import fetch from "isomorphic-unfetch";
import shows from "./shows.js";
import calendar from "./calendar.js";
class RadioManifest {
constructor (baseURL, options) {
this.baseURL = baseURL
this.options = options
const radiomanifest = fetch(`${baseURL}/radiomanifest.xml`)
return radiomanifest
}
getShowByName (showName) {
}
getStreaming () {
}
getSchedule () {
}
getShowAtTime () {
}
function getStreaminfoUrl(siteurl) {
return siteurl + "/streaminfo.json"; // XXX: improve this logic
}
function getManifestUrl(siteurl) {
return siteurl + "/radiomanifest.xml"; // XXX: improve this logic
}
function getStreaminfoUrl (siteurl) {
return siteurl + '/streaminfo.json' // XXX: improve this logic
function getAttribute(el, attr, default_value) {
if (el.hasAttribute(attr)) return el.getAttribute(attr);
return default_value;
}
function getManifestUrl (siteurl) {
return siteurl + '/radiomanifest.xml' // XXX: improve this logic
}
function parseRadioManifest (xml) {
let res = xml.evaluate('/radio-manifest/streaming/source', xml)
const sources = []
while (true) {
const src = res.iterateNext()
if (src === null) break
if (!src.hasAttribute('priority')) {
src.setAttribute('priority', '0')
} else if (parseInt(src.getAttribute('priority'), 10) < 0) {
continue
/**
* 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<RadioShow>}
*/
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);
}
sources.push(src)
}
sources.sort(function cmp (a, b) {
return parseInt(a.getAttribute('priority', 10)) < parseInt(b.getAttribute('priority', 10))
})
res = xml.evaluate('/radio-manifest/schedule', xml)
const scheduleEl = res.iterateNext()
let schedule = null
if (scheduleEl !== null) {
schedule = scheduleEl.getAttribute('url')
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 <streaming> block in radiomanifest.xml
*/
class RadioStreaming {
constructor(sources) {
this.sources = sources.sort(
(a, b) => this.getPriority(a) < this.getPriority(b),
);
}
res = xml.evaluate('/radio-manifest/shows', xml)
const showsEl = res.iterateNext()
let shows = null
if (showsEl !== null) {
shows = showsEl.getAttribute('src')
/**
* Get the list of possible options that are provided to the user
* @returns {Array<string>}
*/
getOptions() {
return this.sources.map(function (x) {
return x.getAttribute("name");
});
}
const manifest = new Radio(sources, schedule, shows)
return manifest
}
function Radio (sources, schedule, shows) {
this.streaming = new RadioStreaming(sources)
this.schedule = schedule
this.shows = shows
this.name = ''
}
Radio.prototype.getStreaming = function () {
return this.streaming
}
Radio.prototype.setName = function (name) {
this.name = name
}
function RadioStreaming (sources) {
this.sources = sources
}
RadioStreaming.prototype.getOptions = function () {
return this.sources.map(function (x) {
return x.getAttribute('name')
})
}
RadioStreaming.prototype.getSource = function (name) {
if (name === undefined) {
/**
* @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<string>}
*/
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];
}
const s = this.sources.find(function (x) {
return x.getAttribute('name') === name
})
if (s === undefined) return s
return s.getAttribute('src')
}
async function get (siteurl, options) {
let resp = await fetch(getManifestUrl(siteurl))
let text = await resp.text()
/**
* 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 = parseRadioManifest(dom)
const parser = new DOMParser();
const dom = parser.parseFromString(text, "text/xml");
const manifest = Radio.fromDOM(dom);
resp = null
try {
resp = await fetch(getStreaminfoUrl(siteurl))
text = await resp.text()
const data = JSON.parse(text)
const name = data['icy-name']
if (name !== undefined) {
manifest.setName(name)
}
manifest.shows = await shows.get(manifest);
} catch (e) {
if (e instanceof TypeError && e.message.startsWith('NetworkError')) {
// the fetch has failed
true
} else if (e instanceof SyntaxError && e.message.startsWith('JSON.parse')) {
true
} else {
console.error('Error', e)
throw 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;
}
}
}
// XXX: in base alle options fai fetch anche di altra roba
return manifest
return manifest;
}
function parseM3U (body) {
body.split('\n').filter((e) => {
if (e.startsWith('#')) {
return false
function parseM3U(body) {
return body.split("\n").filter((line) => {
if (line.startsWith("#")) {
return false;
} else {
try { new URL(e); return true } catch { return false }
try {
new URL(line);
return true;
} catch {
return false;
}
}
})
});
}
module.exports = {
export default {
get: get,
objs: {
Radio: Radio,
RadioStreaming: RadioStreaming
RadioStreaming: RadioStreaming,
},
parsers: {
M3U: parseM3U,
radioManifest: parseRadioManifest
}
}
radioManifest: Radio.fromDOM,
shows: shows.parse,
},
};

143
shows.js Normal file
View file

@ -0,0 +1,143 @@
/**
* Represents a single show. This show could be defined in the shows.xml file, or could be inferred from the
* schedule.
*/
class RadioShow {
constructor(name, description, website, feed, schedule, radio_calendar) {
this.name = name;
this.description = description;
this.website = website;
this.feed = feed;
this.schedule = schedule;
this.radio_calendar = radio_calendar;
}
getName() {
return this.name;
}
getWebsite() {
return this.website;
}
getFeed() {
return this.feed;
}
getSchedule() {
return this.schedule;
}
}
/**
* @private
* @return {Array<RadioShow>}
*/
function parseRadioShows(xml) {
const doc = xml.cloneNode(true);
const bookmarks = doc.evaluate(
"//bookmark",
doc,
showsNamespaceResolver,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
null,
);
const shows = [];
for (let i = 0; i < bookmarks.snapshotLength; i++) {
const bm = bookmarks.snapshotItem(i);
let name = doc.evaluate(
"./info/metadata/show:name",
bm,
showsNamespaceResolver,
XPathResult.STRING_TYPE,
).stringValue;
if (name === "") {
name = doc.evaluate(
"./title",
bm,
showsNamespaceResolver,
XPathResult.STRING_TYPE,
).stringValue;
}
let website = doc.evaluate(
"./info/metadata/show:website",
bm,
showsNamespaceResolver,
XPathResult.STRING_TYPE,
).stringValue;
if (website === "") {
website = bm.getAttribute("href");
}
const feed = doc.evaluate(
"./info/metadata/show:feed",
bm,
showsNamespaceResolver,
XPathResult.STRING_TYPE,
).stringValue;
const schedule = doc.evaluate(
"./info/metadata/show:schedule",
bm,
showsNamespaceResolver,
XPathResult.STRING_TYPE,
).stringValue;
let description = doc.evaluate(
"./info/metadata/show:description",
bm,
showsNamespaceResolver,
XPathResult.STRING_TYPE,
).stringValue;
if (description === "") {
description = doc.evaluate(
"./desc",
bm,
showsNamespaceResolver,
XPathResult.STRING_TYPE,
).stringValue;
}
const show = new RadioShow(
name,
description || null,
website || null,
feed || null,
schedule || null,
);
shows.push(show);
}
return shows;
}
async function getShows(manifest) {
if (manifest.showsURL) {
let resp = null;
try {
resp = await fetch(manifest.showsURL);
} catch (e) {
true;
}
if (resp !== null) {
try {
const text = await resp.text();
const parser = new DOMParser();
const showsDom = parser.parseFromString(text, "text/xml");
return parseRadioShows(showsDom);
} catch (e) {
console.error("Error while parsing shows file", e);
throw e;
}
}
}
}
function showsNamespaceResolver(prefix) {
const prefixes = {
show: "https://radiomanifest.degenerazione.xyz/shows/",
};
return prefixes[prefix] || null;
}
export default {
get: getShows,
parse: parseRadioShows,
RadioShow: RadioShow,
};

17
test/404.test.js Normal file
View file

@ -0,0 +1,17 @@
const radiomanifest = require("../radiomanifest.js");
const chai = require("chai");
chai.use(require("chai-as-promised"));
const assert = chai.assert;
const expect = chai.expect;
const testName = "idontexist";
const url = "https://example.org/radiomanifest/examples/" + testName + "/";
describe("radiomanifest.js supports example " + testName, () => {
describe("Get empty radiomanifest", () => {
it("should throw", () => {
const p = radiomanifest.get(url);
expect(p).to.be.rejected;
});
});
});

View file

@ -1,23 +1,29 @@
const radiomanifest = require('../radiomanifest.js')
const chai = require('chai')
chai.use(require('chai-as-promised'))
const assert = chai.assert
const radiomanifest = require("../radiomanifest.js");
const chai = require("chai");
chai.use(require("chai-as-promised"));
const assert = chai.assert;
const expect = chai.expect
const url = 'https://boyska.degenerazione.xyz/radiomanifest/examples/empty/'
const expect = chai.expect;
const tests = ["empty", "empty-no-streaminfo", "empty-invalid-streaminfo"];
for (const exampleName of tests) {
let url =
"https://radiomanifest.degenerazione.xyz/v0.2/examples/" +
exampleName +
"/";
describe('radiomanifest.js', () => {
describe('Get empty radiomanifest', () => {
it('should return a Promise', () => {
const p = radiomanifest.get(url)
expect(p instanceof Promise).to.be.eql(true)
})
})
describe("examples/" + exampleName, () => {
describe("Get radiomanifest", () => {
it("should return a Promise", () => {
const p = radiomanifest.get(url);
expect(p instanceof Promise).to.be.eql(true);
});
});
describe('streaming', () => {
it('shoud return no streaming option', async () => {
const p = await radiomanifest.get(url)
expect(p.getStreaming().getOptions().length).to.be.equal(0)
})
})
})
describe("streaming", () => {
it("shoud return no streaming option", async () => {
const p = await radiomanifest.get(url);
expect(p.getStreaming().getOptions().length).to.be.equal(0);
});
});
});
}

View file

@ -0,0 +1,104 @@
const ICAL = require("ical.js");
const radiomanifest = require("../radiomanifest.js");
const chai = require("chai");
chai.use(require("chai-as-promised"));
const assert = chai.assert;
const exampleName = "full-ondarossa";
const expect = chai.expect;
const url =
"https://radiomanifest.degenerazione.xyz/v0.2/examples/" + exampleName + "/";
const testShowName = "Entropia Massima";
const testWebsite = "http://www.ondarossa.info/trx/entropia-massima";
const testFeed =
"http://www.ondarossa.info/podcast/by-trx-id/10497/podcast.xml";
describe("examples/" + exampleName, () => {
describe("shows", () => {
it("shoud find many shows", async () => {
const rm = await radiomanifest.get(url);
assert.isAbove(rm.getShows().length, 1);
});
it('one of which is called "Entropia Massima"', async () => {
const rm = await radiomanifest.get(url);
const show = rm.getShowByName(testShowName);
assert.equal(show.getName(), testShowName);
assert.equal(show.getWebsite(), testWebsite);
assert.equal(show.getSchedule(), null);
});
});
describe("schedule", () => {
it("should find many event", async () => {
const rm = await radiomanifest.get(url);
assert.isAbove(rm.getSchedule().getEvents().length, 1);
});
it("At 1AM, nothing is going on", async () => {
const rm = await radiomanifest.get(url);
const rs = rm.getSchedule();
const now = new ICAL.Time({
year: 2022,
month: 1,
day: 31,
hour: 1,
minute: 0,
second: 0,
isDate: false,
});
const ev = rs.getNowEvent(now);
assert.equal(ev, null);
});
it('monday at 8PM, "Entropia Massima" is going on', async () => {
const rm = await radiomanifest.get(url);
const rs = rm.getSchedule();
const now = new ICAL.Time({
year: 2022,
month: 1,
day: 31,
hour: 20,
minute: 10,
second: 0,
isDate: false,
});
const vevent = rs.getNowEvent(now);
assert.notEqual(vevent, null);
const show = rs.getNowShow(now);
assert.notEqual(show, null);
assert.equal(show.getName(), testShowName);
assert.equal(show.getFeed(), testFeed);
});
it('monday at 7PM, "Entropia Massima" is next', async () => {
const rm = await radiomanifest.get(url);
const rs = rm.getSchedule();
const now = new ICAL.Time({
year: 2022,
month: 1,
day: 31,
hour: 19,
minute: 10,
second: 0,
isDate: false,
});
const vevent = rs.getNowEvent(now);
assert.notEqual(vevent, null);
const show = rs.getNowShow(now);
assert.notEqual(show, null);
assert.equal(show.getName(), "Baraonda");
const next_event = rs.getNextEvent(now);
assert.notEqual(next_event, null);
assert.notEqual(next_event.event, null);
const next_show = rs.getNextShow(now);
assert.notEqual(next_show, null);
assert.notEqual(next_show.show, null);
assert.equal(next_show.show.getName(), testShowName);
assert.equal(next_show.show.getFeed(), testFeed);
});
});
});

View file

@ -0,0 +1,75 @@
const ICAL = require("ical.js");
const radiomanifest = require("../radiomanifest.js");
const chai = require("chai");
chai.use(require("chai-as-promised"));
const assert = chai.assert;
const exampleName = "full-spore";
const expect = chai.expect;
const url =
"https://radiomanifest.degenerazione.xyz/v0.2/examples/" + exampleName + "/";
const testShowName = "scaricomerci";
const testNextShowName = "nastrone notte";
describe("examples/" + exampleName, () => {
describe("schedule.getNow", () => {
it("observes priority correctly", async () => {
// tuesday, half past midnight
const rm = await radiomanifest.get(url);
const rs = rm.getSchedule();
const now = new ICAL.Time(
{
year: 2022,
month: 1,
day: 30,
hour: 2,
minute: 20,
second: 0,
isDate: false,
},
ICAL.Timezone.utcTimezone,
);
const vevent = rs.getNowEvent(now);
assert.notEqual(vevent, null);
const show = rs.getNowShow(now);
assert.notEqual(show, null);
assert.equal(show.getName(), testShowName);
});
});
describe("schedule.getNext", () => {
it("getNext observes priority correctly", async () => {
// tuesday, half past midnight
const rm = await radiomanifest.get(url);
const rs = rm.getSchedule();
const now = new ICAL.Time({
year: 2022,
month: 2,
day: 2,
hour: 2,
minute: 20,
second: 0,
isDate: false,
});
const vevent = rs.getNowEvent(now);
assert.notEqual(vevent, null);
const show = rs.getNowShow(now);
assert.notEqual(show, null);
assert.equal(show.getName(), testShowName);
const next_event = rs.getNextEvent(now);
assert.notEqual(next_event, null);
assert.isObject(next_event.event);
assert.isObject(next_event.time);
const next_show = rs.getNextShow(now);
assert.isObject(next_show);
assert.isObject(next_show.show);
assert.equal(next_show.show.getName(), testNextShowName);
const time = next_event.time.toJSDate();
assert.equal(time.getHours(), 2);
assert.equal(time.getMinutes(), 58);
});
});
});

View file

@ -0,0 +1,59 @@
const ICAL = require("ical.js");
const radiomanifest = require("../radiomanifest.js");
const chai = require("chai");
chai.use(require("chai-as-promised"));
const assert = chai.assert;
const exampleName = "onlyics";
const expect = chai.expect;
const url =
"https://radiomanifest.degenerazione.xyz/v0.2/examples/" + exampleName + "/";
describe("examples/" + exampleName, () => {
describe("schedule", () => {
it("should find one event", async () => {
const rm = await radiomanifest.get(url);
assert.equal(rm.getSchedule().getEvents().length, 1);
});
it("with a specific name", async () => {
const rm = await radiomanifest.get(url);
const ev = rm.getSchedule().getEvents()[0];
const summary = ev.getFirstProperty("summary").getFirstValue();
assert.equal(summary, "JavaScript show");
});
it("happens every monday at 6AM", async () => {
const rm = await radiomanifest.get(url);
const rs = rm.getSchedule();
const now = new ICAL.Time({
year: 2022,
month: 1,
day: 3,
hour: 6,
minute: 30,
second: 0,
isDate: false,
});
const show = rs.getNowShow(now);
assert.notEqual(show, null);
assert.equal(show.getName(), "JavaScript show");
});
it("doesnt happen at any other time than 6AM", async () => {
const rm = await radiomanifest.get(url);
const rs = rm.getSchedule();
const now = new ICAL.Time({
year: 2022,
month: 1,
day: 3,
hour: 9,
minute: 0,
second: 0,
isDate: false,
});
const ev = rs.getNowEvent(now);
assert.equal(ev, null);
});
});
});

View file

@ -0,0 +1,27 @@
const radiomanifest = require("../radiomanifest.js");
const chai = require("chai");
chai.use(require("chai-as-promised"));
const assert = chai.assert;
const exampleName = "source404";
const expect = chai.expect;
const url =
"https://radiomanifest.degenerazione.xyz/v0.2/examples/" + exampleName + "/";
describe("examples/" + exampleName, () => {
describe("streaming", () => {
it("shoud return one streaming option", async () => {
const p = await radiomanifest.get(url);
assert.equal(p.getStreaming().getOptions().length, 1);
});
it("... whose url is stream.m3u", async () => {
const p = await radiomanifest.get(url);
assert.equal(p.getStreaming().getOptions()[0], "try to find me");
const name = p.getStreaming().getOptions()[0];
assert.equal(
p.getStreaming().getSource(name),
"https://www.radioexample.org/stream.m3u",
);
});
});
});

52
test/parser-M3U.test.js Normal file
View file

@ -0,0 +1,52 @@
const parseM3U = require("../radiomanifest.js").parsers.M3U;
const chai = require("chai");
chai.use(require("chai-as-promised"));
const assert = chai.assert;
const expect = chai.expect;
describe("parseM3U parses basic M3U", () => {
describe("empty M3U", () => {
it("should return empty list", () => {
var r = parseM3U("");
assert.equal(r.length, 0);
});
it("should discard empty lines", () => {
var r = parseM3U("\n\n\n");
assert.equal(r.length, 0);
});
});
describe("just some lines", () => {
it("should return appropriate list", () => {
var r = parseM3U("http://foo");
assert.equal(r.length, 1);
assert.equal(r[0], "http://foo");
});
it("should work with longer list", () => {
var r = parseM3U("http://foo\nhttp://baz\nhttp://bar\n");
assert.equal(r.length, 3);
assert.equal(r[0], "http://foo");
assert.equal(r[1], "http://baz");
assert.equal(r[2], "http://bar");
});
it("should discard empty lines", () => {
var r = parseM3U("http://foo\n\nhttp://baz\nhttp://bar\n\n\n");
assert.equal(r.length, 3);
assert.equal(r[0], "http://foo");
assert.equal(r[1], "http://baz");
assert.equal(r[2], "http://bar");
});
});
describe("comments", () => {
it("comments should be ignored", () => {
var r = parseM3U("http://foo\n#asd");
assert.equal(r.length, 1);
assert.equal(r[0], "http://foo");
});
it("mid-line hash is not a comment", () => {
var r = parseM3U("http://foo#asd");
assert.equal(r.length, 1);
assert.equal(r[0], "http://foo#asd");
});
});
});

View file

@ -1,25 +1,23 @@
const radiomanifest = require('../radiomanifest.js')
const chai = require('chai')
chai.use(require('chai-as-promised'))
const radiomanifest = require("../radiomanifest.js");
const chai = require("chai");
chai.use(require("chai-as-promised"));
const expect = chai.expect
const expect = chai.expect;
describe('radiomanifest.js', () => {
describe('Get a radiomanifest', () => {
it('should return a Promise', () => {
const p = radiomanifest.get('http://omstring')
expect(p instanceof Promise).to.be.eql(true)
})
describe("radiomanifest.js", () => {
describe("Get a radiomanifest", () => {
it("should return a Promise", () => {
const p = radiomanifest.get("http://example.com/");
expect(p instanceof Promise).to.be.eql(true);
});
it('should reject on invalid URL', () => {
const p = radiomanifest.get('http://invalidurl')
expect(p).to.eventually.be.rejected
})
})
it("should reject on invalid URL", () => {
const p = radiomanifest.get("http://example.com/");
expect(p).to.eventually.be.rejected;
});
});
describe('streaming', () => {
it('shoud return a valid streaming URL', () => {
})
})
})
describe("streaming", () => {
it("shoud return a valid streaming URL", () => {});
});
});

72
ui.js
View file

@ -1,11 +1,67 @@
/* global radiomanifest */
// const radiomanifest = require('radiomanifest.js')
async function fai () {
const radio = await radiomanifest.get('https://www.ondarossa.info/')
// var radio = await get("https://boyska.degenerazione.xyz/radiomanifest/examples/empty/")
console.log('radio?', radio)
const s = radio.getStreaming()
console.log(s.getOptions())
console.log(s.getSource(s.getOptions()[0]))
function updateNow(radio) {
var box = document.querySelector("#now-info");
const show = radio.getSchedule().getNowShow();
var showText;
try {
showText = show.getName() + "\nfeed: " + show.getWebsite();
} catch (e) {
showText = String(show);
}
var text =
radio.getName() + " - " + radio.getDescription() + " -- " + showText;
box.textContent = text;
}
async function fai() {
const radio = await radiomanifest.get(
"https://radiomanifest.degenerazione.xyz/v0.2/examples/full-ondarossa",
);
console.log("radio?", radio);
const s = radio.getStreaming();
console.log(s.sources);
console.log(s.getOptions());
console.log(s.getSource(s.getOptions()[0]));
console.log(s.getSource());
var audioEl = document.querySelector("#player audio");
var urls = await s.pickURLs();
console.log("audios", urls);
urls.forEach(function (url) {
var srcEl = document.createElement("source");
srcEl.setAttribute("src", url);
console.log("src", srcEl, url);
audioEl.appendChild(srcEl);
});
const showList = document.querySelector("#shows > ul");
for (const show of radio.getShows()) {
const item = document.createElement("li");
const link = document.createElement("a");
link.dataset["show"] = show.getName();
link.textContent = show.getName();
link.setAttribute("href", show.getWebsite());
item.appendChild(link);
showList.appendChild(item);
}
showList.addEventListener(
"mouseenter",
function (evt) {
if (evt.target.dataset["show"] === undefined) return;
const info = document.querySelector("#show-info");
info.textContent = radio
.getShowByName(evt.target.dataset["show"])
.getFeed();
},
true,
);
updateNow(radio);
console.log(radio.getSchedule());
setInterval(function () {
updateNow(radio);
}, 2000);
}
fai()
fai();

View file

@ -1,27 +1,25 @@
const path = require('path')
const path = require("path");
const variants = [
{ name: 'web', libtype: 'amd', target: 'web' },
{ name: 'node', libtype: 'amd', target: 'node' },
{ name: 'oldstyle', libtype: 'umd', target: 'web' }
]
module.exports = variants.map(
variant => {
const obj = {
name: variant.name,
mode: 'production',
entry: './radiomanifest.js',
target: [variant.target],
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'radiomanifest-' + variant.name + '.bundle.js',
clean: false,
library: {
name: 'radiomanifest',
type: variant.libtype
}
}
}
return obj
}
)
{ name: "web", libtype: "amd", target: "web" },
{ name: "node", libtype: "amd", target: "node" },
{ name: "oldstyle", libtype: "umd", target: "web" },
];
module.exports = variants.map((variant) => {
const obj = {
name: variant.name,
mode: "production",
entry: "./radiomanifest.js",
target: [variant.target],
output: {
path: path.resolve(__dirname, "dist"),
filename: "radiomanifest-" + variant.name + ".bundle.js",
clean: false,
library: {
name: "radiomanifest",
type: variant.libtype,
},
},
};
return obj;
});