Compare commits
41 commits
Author | SHA1 | Date | |
---|---|---|---|
49ef072ba9 | |||
f2f4fef8fb | |||
67fb372516 | |||
3d0ac08488 | |||
4e19b6304e | |||
dd96ce7b2a | |||
207f06a791 | |||
baa181354f | |||
1a78e2344c | |||
186ed67a8d | |||
52cf3c5387 | |||
303819879d | |||
0e27a5d599 | |||
9d2db78c98 | |||
e8f97b8cae | |||
a7253dc6c8 | |||
ba0a79cc49 | |||
eadb0fa7b6 | |||
8cde139866 | |||
a19dbc8de1 | |||
c139b1faef | |||
a3dcb0795a | |||
5bfe52c235 | |||
d524cbbace | |||
d3fed0c424 | |||
eab5da1ec7 | |||
0d79eb615d | |||
0085d53bf1 | |||
29d74c1b93 | |||
ff579acfbb | |||
e1e740dbf0 | |||
935fc70e99 | |||
f55ccec939 | |||
1fba07bd67 | |||
b21596d1d7 | |||
6544f023e5 | |||
|
cec6cb9101 | ||
|
86c58fc39d | ||
3bea9b610c | |||
1dbd19c6b2 | |||
e5fa7fc3a2 |
23 changed files with 1345 additions and 235 deletions
15
.eslintrc.json
Normal file
15
.eslintrc.json
Normal 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
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
node_modules
|
||||
dist/*.js
|
||||
.*.swp
|
||||
/docs/
|
||||
|
|
1
.prettierignore
Normal file
1
.prettierignore
Normal file
|
@ -0,0 +1 @@
|
|||
libs/
|
|
@ -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
266
calendar.js
Normal 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
7
doc/home.md
Normal 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
|
55
doc/tutorials/quickstart.md
Normal file
55
doc/tutorials/quickstart.md
Normal 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).
|
32
index.html
32
index.html
|
@ -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
24
jsdoc.conf.json
Normal 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": []
|
||||
}
|
||||
}
|
|
@ -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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
25
package.json
25
package.json
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
436
radiomanifest.js
436
radiomanifest.js
|
@ -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
143
shows.js
Normal 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
17
test/404.test.js
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
104
test/example-full-ondarossa.test.js
Normal file
104
test/example-full-ondarossa.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
75
test/example-full-spore.test.js
Normal file
75
test/example-full-spore.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
59
test/example-onlyics.test.js
Normal file
59
test/example-onlyics.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
27
test/example-source404.test.js
Normal file
27
test/example-source404.test.js
Normal 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
52
test/parser-M3U.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
72
ui.js
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue