add radio playlist edit in UI

This commit is contained in:
Alexandre Storelli
2018-01-19 16:05:37 +01:00
parent 4f5fb590bf
commit 886cd27d29
12 changed files with 532 additions and 331 deletions
+8 -8
View File
@@ -6,8 +6,8 @@ log.setLevel("debug");
var cp = require("child_process");
var fs = require("fs");
var { getMeta } = require("webradio-metadata");
var Dl = require("../adblockradio-dl/dl.js");
var config = require("./config.js");
var { StreamDl } = require("../adblockradio-dl/dl.js");
//var config = require("./config.js");
class Db {
constructor(options) {
@@ -15,8 +15,8 @@ class Db {
this.name = options.name;
this.path = options.path;
this.ext = options.ext;
this.audioCache = new AudioCache({ bitrate: options.bitrate, cacheLen: config.user.cacheLen });
this.metaCache = new MetaCache({ cacheLen: config.user.cacheLen });
this.audioCache = new AudioCache({ bitrate: options.bitrate, cacheLen: options.cacheLen });
this.metaCache = new MetaCache({ cacheLen: options.cacheLen });
}
dirDate(now) {
@@ -208,9 +208,9 @@ class MetaCache extends Writable {
} else {
var samePayload = true;
for (var key in meta.payload) {
if (meta.payload[key] !== this.meta[meta.type][this.meta[meta.type].length-1].payload[key]) {
if ("" + meta.payload[key] && "" + meta.payload[key] !== "" + this.meta[meta.type][this.meta[meta.type].length-1].payload[key]) {
samePayload = false;
//log.debug("MetaCache: _write: different payload key=" + key + " new=" + meta.payload[key] + " vs old=" + this.meta[meta.type][this.meta[meta.type].length-1].payload[key]);
log.debug("MetaCache: _write: different payload key=" + key + " new=" + meta.payload[key] + " vs old=" + this.meta[meta.type][this.meta[meta.type].length-1].payload[key]);
break;
}
}
@@ -249,7 +249,7 @@ class MetaCache extends Writable {
}
module.exports = function(radio, options) {
var newDl = new Dl({ country: radio.country, name: radio.name, segDuration: options.segDuration });
var newDl = new StreamDl({ country: radio.country, name: radio.name, segDuration: options.segDuration });
var dbs = null;
newDl.on("error", function(err) {
log.error("dl err=" + err);
@@ -258,7 +258,7 @@ module.exports = function(radio, options) {
//metadataCallback(metadata);
radio.url = metadata.url;
radio.favicon = metadata.favicon;
var db = new Db({ country: radio.country, name: radio.name, ext: metadata.ext, bitrate: metadata.bitrate, path: __dirname });
var db = new Db({ country: radio.country, name: radio.name, ext: metadata.ext, bitrate: metadata.bitrate, cacheLen: options.cacheLen, path: __dirname });
log.debug("DlFactory: " + radio.country + "_" + radio.name + " metadata=" + JSON.stringify(metadata));
newDl.on("data", function(dataObj) {
+83 -36
View File
@@ -5,6 +5,7 @@ import './App.css';
import Radio from './Radio.js';
import Metadata from './Metadata.js';
import DelayCanvas from './DelayCanvas.js';
import Playlist from './Playlist.js';
import { load, refreshMetadata, HOST } from './load.js';
import { play, stop } from './audio.js';
@@ -15,6 +16,7 @@ import classNames from 'classnames';
import iconPlay from "./img/start_1279169.svg";
import iconStop from "./img/stop_1279170.svg";
import iconList from "./img/list_241386.svg";
//import iconNext from "./img/next_607554.svg";
class App extends Component {
@@ -25,57 +27,67 @@ class App extends Component {
config: [],
playingRadio: null,
playingDate: null,
clockDiff: 0
clockDiff: 0,
playlistEditMode: false
}
this.play = this.play.bind(this);
this.seekBackward = this.seekBackward.bind(this);
this.seekForward = this.seekForward.bind(this);
this.switchPlaylistEditMode = this.switchPlaylistEditMode.bind(this);
this.refreshMetadataContainer = this.refreshMetadataContainer.bind(this);
this.refreshConfig = this.refreshConfig.bind(this);
this.insertRadio = this.insertRadio.bind(this);
this.removeRadio = this.removeRadio.bind(this);
}
componentDidMount() {
let self = this;
this.refreshConfig();
this.timerID = setInterval(() => this.tick(), 300);
}
var refreshMetadataContainer = function() {
var stateChange = {};
var f = function(iradio, callback) {
if (iradio >= self.state.config.radios.length) return callback();
var radio = self.state.config.radios[iradio].country + "_" + self.state.config.radios[iradio].name;
refreshMetadata(radio, function(metadata) {
for (var type in metadata) {
if (type === "now") {
stateChange.clockDiff = +new Date() - metadata.now;
} else {
metadata[type][metadata[type].length-1].validTo = null;
stateChange[radio + "|" + type] = metadata[type].reverse();
}
componentWillUnmount() {
clearInterval(this.timerID);
clearInterval(this.metadataTimerID);
}
refreshMetadataContainer() {
var self = this;
var stateChange = {};
var f = function(iradio, callback) {
if (iradio >= self.state.config.radios.length) return callback();
var radio = self.state.config.radios[iradio].country + "_" + self.state.config.radios[iradio].name;
refreshMetadata(radio, function(metadata) {
for (var type in metadata) {
if (type === "now") {
stateChange.clockDiff = +new Date() - metadata.now;
} else {
metadata[type][metadata[type].length-1].validTo = null;
stateChange[radio + "|" + type] = metadata[type].reverse();
}
f(iradio+1, callback);
});
}
f(0, function() {
self.setState(stateChange);
}
f(iradio+1, callback);
});
}
f(0, function() {
self.setState(stateChange);
});
}
refreshConfig() {
var self = this;
load("/config?t=" + Math.round(Math.random()*1000000), function(res) {
try {
var config = JSON.parse(res);
console.log(config);
self.setState({ config: config }, function() {
self.metadataTimerID = setInterval(refreshMetadataContainer, 2000);
refreshMetadataContainer();
self.metadataTimerID = setInterval(self.refreshMetadataContainer, 2000);
self.refreshMetadataContainer();
});
} catch(e) {
console.log("problem parsing JSON from server: " + e.message);
}
self.setState({ configLoaded: true });
});
this.timerID = setInterval(function() { self.tick() }, 300);
}
componentWillUnmount() {
clearInterval(this.timerID);
clearInterval(this.metadataTimerID);
}
tick() {
@@ -87,7 +99,16 @@ class App extends Component {
//var delay = +new Date() - this.state.clockDiff - (date ? new Date(date) : new Date();
//var secondsDelay = Math.round(delay/1000);
if (!radio) radio = this.state.playingRadio;
if (!delay) delay = 0;
if (!delay) {
var startAvailable = new Date();
for (var i=0; i<this.state[radio + "|metadata"].length; i++) {
if (this.state[radio + "|metadata"][i].validFrom < startAvailable) {
startAvailable = this.state[radio + "|metadata"][i].validFrom;
}
}
delay = Math.min(Math.round(+this.state.date - startAvailable), this.state.config.user.cacheLen*1000*2/3) - 8000;
}
console.log("Play: radio=" + radio + " delay=" + delay);
this.setState({
playingRadio: radio,
@@ -96,7 +117,7 @@ class App extends Component {
playingLive: delay === 0
});
play(HOST + "/listen/" + radio + "/" + Math.round(delay/1000));
play(HOST + "/listen/" + radio + "/" + Math.round(delay/1000) + "?t=" + Math.round(Math.random()*1000000));
document.title = radio.split("_")[1] + " - Adblock Radio";
} else {
this.setState({
@@ -127,6 +148,24 @@ class App extends Component {
this.play(this.state.playingRadio, Math.max(this.state.playingDelay - 30000,0));
}
switchPlaylistEditMode() {
this.setState({ playlistEditMode: !this.state.playlistEditMode });
}
insertRadio(country, name) {
var self = this;
load("/config/radios/insert/" + country + "/" + name + "?t=" + Math.round(Math.random()*1000000), function(res) {
self.refreshConfig()
});
}
removeRadio(country, name) {
var self = this;
load("/config/radios/remove/" + country + "/" + name + "?t=" + Math.round(Math.random()*1000000), function(res) {
self.refreshConfig()
});
}
render() {
let config = this.state.config;
var self = this;
@@ -178,6 +217,7 @@ class App extends Component {
var buttons = (
<StatusButtonsContainer>
<PlaybackButton className={classNames({ inactive: !self.state.playlistEditMode })} src={iconList} alt="Edit playlist" onClick={self.switchPlaylistEditMode} />
<PlaybackButton className={classNames({ flip: true, inactive: !self.state.playingRadio })} src={iconPlay} alt="Backward 30s" onClick={self.seekBackward} />
<PlaybackButton className={classNames({ inactive: !self.state.playingRadio })} src={iconStop} alt="Stop" onClick={() => self.play(null, null)} />
<PlaybackButton className={classNames({ inactive: !self.state.playingRadio || self.state.playingLive })} src={iconPlay} alt="Forward 30s" onClick={self.seekForward} />
@@ -189,32 +229,39 @@ class App extends Component {
return (
<AppParent>
<AppView>
<RadioList className={classNames({ withPreview: !self.state.playingRadio })}>
<RadioList className={classNames({ withPreview: !self.state.playingRadio && !self.state.playlistEditMode })}>
{config.radios.map(function(radio, i) {
var playing = self.state.playingRadio === radio.country + "_" + radio.name;
return (
<Radio metadata={radio}
playCallback={self.play}
playing={playing}
showMetadata={!self.state.playingRadio}
showMetadata={!self.state.playingRadio && !self.state.playlistEditMode}
liveMetadata={self.state[radio.country + "_" + radio.name + "|metadata"]}
key={"radio" + i} />
)
})}
</RadioList>
{self.state.playingRadio &&
{self.state.playingRadio && !self.state.playlistEditMode &&
<Metadata playingRadio={self.state.playingRadio}
playingDelay={self.state.playingDelay}
metaList={self.state[self.state.playingRadio + "|metadata"]}
date={+self.state.date - self.state.clockDiff}
date={new Date(+self.state.date - self.state.clockDiff)}
playCallback={self.play} />
}
{self.state.playlistEditMode &&
<Playlist config={self.state.config}
insertRadio={self.insertRadio}
removeRadio={self.removeRadio} />
}
</AppView>
<Controls>
{status}
{buttons}
<DelayCanvas playing={!!self.state.playingRadio}
playingDelay={self.state.playingDelay}
<DelayCanvas playingDelay={self.state.playingDelay}
metaList={self.state[self.state.playingRadio + "|metadata"]}
date={new Date(+self.state.date - self.state.clockDiff)}
inactive={!self.state.playingRadio || !self.state[self.state.playingRadio + "|metadata"]}
cacheLen={self.state.config.user.cacheLen}
playCallback={self.play} />
{/*<PlayerStatus settings={this.props.settings} bsw={this.props.bsw} condensed={this.props.condensed} playbackAction={this.togglePlayer} />*/}
+30 -4
View File
@@ -3,7 +3,7 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
//import styled from "styled-components";
import classNames from 'classnames';
//import classNames from 'classnames';
class DelayCanvas extends Component {
@@ -36,6 +36,7 @@ class DelayCanvas extends Component {
}
getCursorPosition(event) {
if (this.props.inactive) return;
var rect = this.refs.canvas.getBoundingClientRect();
var x = event.clientX - rect.left;
@@ -49,14 +50,38 @@ class DelayCanvas extends Component {
this.play(newDelay);
}
getAvailableCache() {
var startAvailable = new Date();
for (var i=0; i<this.props.metaList.length; i++) {
if (this.props.metaList[i].validFrom < startAvailable) {
startAvailable = this.props.metaList[i].validFrom;
}
}
return (+this.props.date - startAvailable) / 1000;
}
updateCanvas() {
const ctx = this.refs.canvas.getContext("2d");
let height = ctx.canvas.height; //parseFloat(this.props.style.height);
let width = ctx.canvas.width; //parseFloat(this.props.style.width);
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "rgba(0,0,128,1)";
//console.log("canvas height=" + height + " px width=" + width + "px playingDelay=" + this.props.playingDelay + " cacheLen=" + this.props.cacheLen);
ctx.clearRect(0, 0, width, height);
if (this.props.inactive) {
ctx.fillStyle = "rgba(128,128,128,1)"; // grey
return ctx.fillRect(0, 0, width, height);
}
ctx.fillStyle = "rgba(0,0,128,1)"; // blue
ctx.fillRect(0, 0, Math.round(width*(1-this.props.playingDelay/1000/this.props.cacheLen)), height);
// fill unavailable audio in grey
var startCache = this.getAvailableCache();
if (startCache < this.props.cacheLen) {
ctx.fillStyle = "rgba(128,128,128,1)"; // grey
ctx.fillRect(0, 0, Math.round(width*(1-startCache/this.props.cacheLen)), height);
}
}
render() {
@@ -67,8 +92,9 @@ class DelayCanvas extends Component {
}
DelayCanvas.propTypes = {
playing: PropTypes.bool.isRequired,
playingDelay: PropTypes.number,
metaList: PropTypes.array,
date: PropTypes.object.isRequired,
cacheLen: PropTypes.number.isRequired,
playCallback: PropTypes.func.isRequired
};
+5 -1
View File
@@ -36,7 +36,10 @@ class Metadata extends Component {
this.props.metaList.map(function(item, i) {
if (!item.payload || (self.props.maxItems && i >= self.props.maxItems)) return null
//var playerTime = +self.props.playingDate;
var playing = +item.validFrom - 1000 <= (self.props.date-self.props.playingDelay) && (!item.validTo || (self.props.date-self.props.playingDelay) < +item.validTo - 1000);
var playing = false;
if (self.props.date) {
playing = +item.validFrom - 1000 <= (self.props.date-self.props.playingDelay) && (!item.validTo || (self.props.date-self.props.playingDelay) < +item.validTo - 1000);
}
//console.log("playing=" + playing + " start=" + item.start + " date=" + playerTime + " stop=" + item.end);
return (
<MetadataItem className={classNames({ playing: playing, compact: previewMode })} key={"item" + i} onClick={function() { self.play(item.validFrom); }}>
@@ -60,6 +63,7 @@ Metadata.propTypes = {
playingRadio: PropTypes.string.isRequired,
playCallback: PropTypes.func.isRequired,
metaList: PropTypes.array,
date: PropTypes.object,
maxItems: PropTypes.number
};
+135
View File
@@ -0,0 +1,135 @@
// Copyright (c) 2018 Alexandre Storelli
import React, { Component } from "react";
import PropTypes from "prop-types";
import styled from "styled-components";
import { load } from './load.js';
import classNames from 'classnames';
import defaultCover from "./img/default_radio_logo.svg";
class Playlist extends Component {
constructor(props) {
super(props);
this.insert = this.insert.bind(this);
this.remove = this.remove.bind(this);
this.state = {
radiosLoaded: false,
radios: []
}
}
componentDidMount() {
let self = this;
load("/config/radios/available?t=" + Math.round(Math.random()*1000000), function(res) {
try {
var radios = JSON.parse(res);
self.setState({ radios: radios });
} catch(e) {
console.log("problem parsing JSON from server: " + e.message);
}
self.setState({ radiosLoaded: true });
});
}
insert(country, name) {
this.props.insertRadio(country, name);
}
remove(country, name) {
this.props.removeRadio(country, name);
}
render() {
var self = this;
if (!this.state.radiosLoaded) {
return (
<p>Loading...</p>
);
}
var current = this.props.config.radios;
var available = this.state.radios;
var playlistFull = this.props.config.radios.length >= this.props.config.user.maxRadios;
return (
<PlaylistContainer>
<PlaylistSectionTitle>Your current favorites : click to remove</PlaylistSectionTitle>
{current.map(function(radio, i) {
return (
<PlaylistItem className={classNames({ active: true })} key={"item" + i} onClick={function() { self.remove(radio.country, radio.name); }}>
<PlaylistItemText>
{radio.name}
</PlaylistItemText>
<PlaylistItemLogo src={radio.favicon || defaultCover} alt="logo" />
</PlaylistItem>
)
})}
{!playlistFull ?
<PlaylistSectionTitle>More radios : click to add</PlaylistSectionTitle>
:
<PlaylistSectionTitle>More radios : make room in your favorites, then click to add</PlaylistSectionTitle>
}
{available.map(function(radio, i) {
return (
<PlaylistItem className={classNames({ active: !playlistFull })} key={"item" + i} onClick={function() { if (!playlistFull) self.insert(radio.country, radio.name); }}>
<PlaylistItemText>
{radio.name}
</PlaylistItemText>
<PlaylistItemLogo src={radio.favicon || defaultCover} alt="logo" />
</PlaylistItem>
)
})}
</PlaylistContainer>
)
}
}
Playlist.propTypes = {
config: PropTypes.object.isRequired,
insertRadio: PropTypes.func.isRequired,
removeRadio: PropTypes.func.isRequired
};
const PlaylistContainer = styled.div`
flex-grow: 1;
overflow-y: scroll;
`;
const PlaylistSectionTitle = styled.h3`
text-align: center;
`;
const PlaylistItem = styled.div`
border: 2px solid #eee;
border-radius: 10px;
margin: 10px;
padding: 10px;
flex-shrink: 0;
background: #eee;
display: flex;
cursor: not-allowed;
&.active {
cursor: pointer;
}
`;
const PlaylistItemText = styled.p`
flex-grow: 1;
align-self: center;
`
const PlaylistItemLogo = styled.img`
width: 60px;
height: 60px;
align-self: center;
margin-left: 10px;
`;
export default Playlist;
/*&.playing {
width: 60px;
height: 60px;
}*/
+1
View File
@@ -0,0 +1 @@
<svg width='200' height='200' fill="#000000" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 100 100" enable-background="new 0 0 100 100" xml:space="preserve"><path fill="#000000" d="M50,5C25.147,5,5,25.147,5,50s20.147,45,45,45s45-20.147,45-45S74.853,5,50,5z M30.312,68.281 c-2.33,0-4.219-1.889-4.219-4.219s1.889-4.219,4.219-4.219s4.219,1.889,4.219,4.219S32.642,68.281,30.312,68.281z M30.312,54.219 c-2.33,0-4.219-1.889-4.219-4.219s1.889-4.219,4.219-4.219S34.531,47.67,34.531,50S32.642,54.219,30.312,54.219z M30.312,40.156 c-2.33,0-4.219-1.889-4.219-4.219s1.889-4.219,4.219-4.219s4.219,1.889,4.219,4.219S32.642,40.156,30.312,40.156z M69.688,68.281 H41.562c-2.33,0-4.219-1.889-4.219-4.219s1.889-4.219,4.219-4.219h28.125c2.33,0,4.219,1.889,4.219,4.219 S72.017,68.281,69.688,68.281z M69.688,54.219H41.562c-2.33,0-4.219-1.889-4.219-4.219s1.889-4.219,4.219-4.219h28.125 c2.33,0,4.219,1.889,4.219,4.219S72.017,54.219,69.688,54.219z M69.688,40.156H41.562c-2.33,0-4.219-1.889-4.219-4.219 s1.889-4.219,4.219-4.219h28.125c2.33,0,4.219,1.889,4.219,4.219S72.017,40.156,69.688,40.156z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+2 -2
View File
@@ -1,5 +1,5 @@
const HOST = "http://localhost:9820";
//const HOST = "https://bufferapi.s00.adblockradio.com"
//const HOST = "http://localhost:9820";
const HOST = "https://bufferapi.s00.adblockradio.com"
exports.load = function(path, callback) {
var xhttp = new XMLHttpRequest();
+107 -1
View File
@@ -1,5 +1,10 @@
"use strict";
var log = require("loglevel");
var fs = require("fs");
var { getRadioMetadata } = require("../adblockradio-dl/dl.js");
var { getAvailable } = require("webradio-metadata");
// list of listened radios:
var config = new Object();
@@ -12,4 +17,105 @@ try {
return log.error("cannot load config. err=" + e);
}
module.exports = config;
exports.config = config;
var isRadioInConfig = function(country, name) {
var isAlreadyThere = false;
for (var i=0; i<config.radios.length; i++) {
if (config.radios[i].country == country && config.radios[i].name == name) {
isAlreadyThere = true;
break;
}
}
return isAlreadyThere;
}
var insertRadio = function(country, name, callback) {
if (isRadioInConfig()) return callback("Radio already in the list");
if (config.radios.length >= config.user.maxRadios) return callback("Playlist is already full");
// now check that the radio is known to our API.
getRadioMetadata(country, name, function(err, radio) {
if (!radio) {
return callback("Radio is not recognized");
} else {
config.radios.push({
"country": country,
"name": name,
"content": {
"ads": true,
"speech": true,
"music": true
},
"enable": true
});
saveRadios();
return callback(null);
}
});
}
var removeRadio = function(country, name, callback) {
for (var i=0; i<config.radios.length; i++) {
if (config.radios[i].country == country && config.radios[i].name == name) {
config.radios.splice(i, 1);
saveRadios();
return callback(null);
}
}
return callback("Radio not in the list");
}
exports.insertRadio = insertRadio;
exports.removeRadio = removeRadio;
var getRadios = function() {
var radios = [];
for (var i=0; i<config.radios.length; i++) { // control on what data is exposed via the api
var radio = config.radios[i];
if (!radio.enable) continue;
radios.push({
country: radio.country,
name: radio.name,
enable: radio.enable,
content: radio.content,
url: radio.enabled,
favicon: radio.favicon
});
}
return radios;
}
exports.getRadios = getRadios;
var saveRadios = function() {
var exportedRadios = getRadios();
fs.writeFile("config/radios.json", JSON.stringify(exportedRadios), function(err) {
if (err) {
log.error("saveRadios: could not save radio config. err=" + err);
} else {
log.debug("saveRadios: config saved");
}
});
}
var getAvailableInactive = function() {
var available = getAvailable();
// remove radios that are currently in playlist
for (let i=available.length-1; i>=0; i--) {
let itemInPlaylist = false;
for (let j=0; j<config.radios.length; j++) {
if (available[i].country === config.radios[j].country && available[i].name === config.radios[j].name) {
itemInPlaylist = true;
break;
}
}
if (itemInPlaylist) {
available.splice(i, 1);
}
}
return available;
}
exports.getAvailableInactive = getAvailableInactive;
-234
View File
@@ -1,234 +0,0 @@
"use strict";
var config = require("./config.js");
var log = require("loglevel");
var getDateFromPath = function(path) {
var spl = path.split("/");
return spl[spl.length-1];
}
var getRadio = function(country, name) {
if (name) { // both parameters used
for (var j=0; j<config.radios.length; j++) {
if (config.radios[j].country == country && config.radios[j].name == name) {
return config.radios[j];
}
}
} else { // only first parameter used
for (var j=0; j<config.radios.length; j++) {
if (config.radios[j].country + "_" + config.radios[j].name == country) {
return config.radios[j];
}
}
}
return null;
}
exports.getRadio = getRadio;
var isLive = function(radio, file) {
return getRadio(radio).liveStatus.currentPrefix == file;
}
/*var listenRequestDate = null;
exports.listenHandler = function(response, radio, after, state, callback) {
var before = new Date(+new Date(after)+6*SEG_DURATION*1000).toISOString();
log.debug("listenHandler: after=" + after + " before=" + before + " bufferSeg=" + state.nSegmentsInitialBuffer);
findDataFiles({ radios: [ radio ], after: after, before: before, path: __dirname }, function(classes) {
var list = {};
for (var classItem in classes) {
Object.assign(list, classes[classItem]);
}
var files = Object.keys(list);
files.sort();
if (files.length == 0) return callback();
var timeoutMonitor = null;
async.forEachOfSeries(files, function(file, index, filesCallback) {
log.debug("listen: read=" + file + state.ext);
var rs;
var livePlay = isLive(radio, file);
if (livePlay) {
rs = getRadio(radio).liveStatus.liveReadStream;
} else {
rs = fs.createReadStream(file + state.ext);
}
var dataRead = 0;
rs.on("data", function(data) {
response.write(data);
dataRead += data.length;
});
rs.on("error", function(err) {
log.error("listen: readFile err=" + err + " livePlay=" + livePlay);
});
rs.on("end", function() {
state.lastSentDate = new Date();
var willWaitDrain = !response.write("");
log.debug("listen: sent file " + (index+1) + "/" + files.length + " bytes=" + dataRead + " waitDrain=" + willWaitDrain + " live=" + livePlay);
if (livePlay || !willWaitDrain) { //willWaitDrain) { // detect congestion of stream
bufManager();
} else {
log.debug("listenHandler: will wait for drain event");
response.once("drain", function() {
clearTimeout(timeoutMonitor);
bufManager();
});
timeoutMonitor = setTimeout(function() {
response.removeListener("drain", bufManager);
response.destroy();
filesCallback({ message: "drain event not emitted, connection timeout" });
}, SEG_DURATION*1500);
}
});
var bufManager = function() {
before = new Date(+new Date(getDateFromPath(file))+1).toISOString();
if (state.requestDate !== listenRequestDate) {
filesCallback({ message: "request canceled because another one has been initiated" });
} else if (livePlay) {
filesCallback();
} else if (state.nSegmentsInitialBuffer > 0) {
state.nSegmentsInitialBuffer--;
filesCallback();
} else {
var delay = +SEG_DURATION*1000 - (new Date()-state.lastSentDate);
log.debug("listenHandler: send more data in " + delay + " ms");
setTimeout(filesCallback, delay);
}
}
}, function(err) {
if (err) {
log.error("listen: err=" + err.message);
callback();
} else {
listenHandler(response, radio, before, state, callback);
}
});
});
}*/
//const STREAM_INITIAL_BUFFER = 8; // send N seconds at connection.
//const STREAM_GRANULARITY = 1; // send N seconds of data every N seconds.
var listenRequestDate = null;
exports.listenHandler = function(response, radio, delay, state, callback) {
var initialBuffer = getRadio(radio).liveStatus.audioCache.readLast(+delay+config.user.streamInitialBuffer,config.user.streamInitialBuffer);
log.info("listen: send initial buffer of " + initialBuffer.length + " bytes");
response.write(initialBuffer);
var listenTimer = setInterval(function() {
if (state.newRequest) {
listenRequestDate = state.requestDate;
state.newRequest = false;
} else if (listenRequestDate !== state.requestDate) {
log.warn("request canceled because another one has been initiated");
clearInterval(listenTimer);
return callback();
}
var willWaitDrain = !response.write("");
if (!willWaitDrain) { // detect congestion of stream
sendMore();
} else {
log.debug("listenHandler: will wait for drain event");
var drainCallback = function() {
clearTimeout(timeoutMonitor);
sendMore();
}
response.once("drain", drainCallback);
var timeoutMonitor = setTimeout(function() {
response.removeListener("drain", drainCallback);
clearInterval(listenTimer);
log.error("listenHandler: drain event not emitted, connection timeout");
callback();
}, config.user.streamGranularity*1500);
}
}, 1000*config.user.streamGranularity);
var sendMore = function() {
response.write(getRadio(radio).liveStatus.audioCache.readAmountAfterCursor(config.user.streamGranularity));
}
}
/*exports.metadataHandler = function(response, radio, after) {
findDataFiles({ radios: [ radio ], after: after, path: __dirname }, function(classes) {
//log.debug(classes); // very verbose
var list = {};
for (var classItem in classes) {
Object.assign(list, classes[classItem]);
}
var files = Object.keys(list);
files.sort();
//response.json(list);
var isSameSegment = function(curSegment, newData) {
if (curSegment.class !== newData.class) {
//log.debug("isSameSegment: curEnd=" + curSegment.end + " diff class cur=" + curSegment.class + " vs new=" + newData.class);
return false;
} else if (!curSegment.title || !newData.title) {
//log.debug("isSameSegment: curEnd=" + curSegment.end + " no title");
return true;
} else if (curSegment.title.artist && newData.title.artist && curSegment.title.artist !== newData.title.artist) {
//log.debug("isSameSegment: curEnd=" + curSegment.end + " diff artist cur=" + curSegment.title.artist + " vs new=" + newData.title.artist);
return false;
} else if (curSegment.title.title && newData.title.title && curSegment.title.title !== newData.title.title) {
//log.debug("isSameSegment: curEnd=" + curSegment.end + " diff title cur=" + curSegment.title.title + " vs new=" + newData.title.title);
return false;
} else if (curSegment.title.cover && newData.title.cover && curSegment.title.cover !== newData.title.cover) {
//log.debug("isSameSegment: curEnd=" + curSegment.end + " diff cover");
return false;
} else {
//log.debug("isSameSegment: same segment");
return true;
}
}
var newSegment = function(index) {
return { class: list[files[index]].class, start: getDateFromPath(files[index]), end: getDateFromPath(files[index]), title: null }
}
var result = [];
if (files.length == 0) return response.json(result);
var curSegment = newSegment(0);
async.forEachOfSeries(files, function(file, index, filesCallback) {
//log.debug("metadata: read=" + file + ".json");
curSegment.end = getDateFromPath(file);
if (isLive(radio, file)) {
//log.info("listenHandler: currentAudioFile");
var pData = { class: list[file].class, title: getRadio(radio).liveStatus.metadata };
if (!isSameSegment(curSegment, pData)) {
result.push(curSegment);
curSegment = newSegment(index);
}
curSegment.title = pData.title;
filesCallback();
} else {
fs.readFile(file + ".json", function(err, data) {
if (err) log.error("metadata: readFile err=" + err);
try {
var pData = JSON.parse(data);
} catch(e) {
log.error("metadata: json parse error file=" + file + ".json");
return filesCallback();
}
pData.class = list[file].class;
if (!isSameSegment(curSegment, pData)) {
result.push(curSegment);
curSegment = newSegment(index);
}
curSegment.title = pData.title;
filesCallback();
});
}
}, function(err) {
if (err) log.error("metadata: err=" + err.message);
result.push(curSegment);
response.set({ 'Access-Control-Allow-Origin': '*' });
response.json(result);
});
});
}*/
+157 -41
View File
@@ -11,23 +11,49 @@ var findDataFiles = require("./findDataFiles.js");
var async = require("async");
var DlFactory = require("./DlFactory.js");
const DL = true;
const FETCH_METADATA = true;
const SEG_DURATION = 10; // in seconds
const LISTEN_BUFFER = 30; // in seconds
var config = require("./config.js");
var { config, getRadios, insertRadio, removeRadio, getAvailableInactive } = require("./config.js");
if (DL) {
var dl = [];
var dl = [];
var updateDlList = function() {
// add missing sockets
for (var i=0; i<config.radios.length; i++) {
config.radios[i].liveStatus = {};
if (config.radios[i].enable) {
dl.push(DlFactory(config.radios[i], { fetchMetadata: FETCH_METADATA, segDuration: SEG_DURATION }));
var alreadyThere = false;
for (var j=0; j<dl.length; j++) {
if (dl[j].country == config.radios[i].country && dl[j].name == config.radios[i].name) {
alreadyThere = true;
break;
}
}
if (!alreadyThere && config.radios[i].enable) {
config.radios[i].liveStatus = {};
log.info("updateDlList: start " + config.radios[i].country + "_" + config.radios[i].name);
dl.push(DlFactory(config.radios[i], { fetchMetadata: FETCH_METADATA, segDuration: SEG_DURATION, cacheLen: config.user.cacheLen }));
}
}
// remove obsolete ones.
for (var j=dl.length-1; j>=0; j--) {
var shouldBeThere = false;
for (var i=0; i<config.radios.length; i++) {
if (dl[j].country == config.radios[i].country && dl[j].name == config.radios[i].name) {
shouldBeThere = true;
break;
}
}
if (!shouldBeThere) {
log.info("updateDlList: stop " + dl[j].country + "_" + dl[j].name);
dl[j].stopDl();
dl.splice(j, 1);
}
}
}
updateDlList();
var http = require('http');
var express = require('express');
var app = express();
@@ -36,24 +62,62 @@ server.listen(9820, "localhost");
app.get('/config', function(request, response) {
response.set({ 'Access-Control-Allow-Origin': '*' });
var result = { radios: [], user: config.user };
for (var i=0; i<config.radios.length; i++) { // control on what data is exposed via the api
var radio = config.radios[i];
if (!radio.enable) continue;
result.radios.push({
country: radio.country,
name: radio.name,
enable: radio.enable,
content: radio.content,
url: radio.enabled,
favicon: radio.favicon
});
}
response.json(result);
//log.debug("config[0]=" + JSON.stringify(config.radios[0]));
response.json({ radios: getRadios(), user: config.user });
});
var { listenHandler, metadataHandler, getRadio } = require("./handlers.js");
app.get('/config/radios/insert/:country/:name', function(request, response) {
response.set({ 'Access-Control-Allow-Origin': '*' });
insertRadio(request.params.country, request.params.name, function(err) {
if (err) {
log.error("/config/insert/" + request.params.country + "/" + request.params.name + ": err=" + err);
response.writeHead(400);
response.end();
} else {
response.writeHead(200);
response.end();
updateDlList();
}
});
});
app.get('/config/radios/remove/:country/:name', function(request, response) {
response.set({ 'Access-Control-Allow-Origin': '*' });
removeRadio(request.params.country, request.params.name, function(err) {
if (err) {
log.error("/config/remove/" + request.params.country + "/" + request.params.name + ": err=" + err);
response.writeHead(400);
response.end();
} else {
response.writeHead(200);
response.end();
updateDlList();
}
});
});
app.get('/config/radios/available', function(request, response) {
response.set({ 'Access-Control-Allow-Origin': '*' });
response.json(getAvailableInactive());
});
var getRadio = function(country, name) {
if (name) { // both parameters used
for (var j=0; j<config.radios.length; j++) {
if (config.radios[j].country == country && config.radios[j].name == name) {
return config.radios[j];
}
}
} else { // only first parameter used
for (var j=0; j<config.radios.length; j++) {
if (config.radios[j].country + "_" + config.radios[j].name == country) {
return config.radios[j];
}
}
}
return null;
}
var listenRequestDate = null;
app.get('/:action/:radio/:delay', function(request, response) {
var action = request.params.action;
@@ -69,34 +133,86 @@ app.get('/:action/:radio/:delay', function(request, response) {
switch(action) {
case "listen":
var ext = ".mp3"; // TODO check for other extensions
/*var state = {
nSegmentsInitialBuffer: Math.floor(LISTEN_BUFFER/SEG_DURATION),
lastSentDate: new Date(),
ext: ext,
requestDate: new Date()
}
listenRequestDate = state.requestDate;*/
switch(ext) {
case ".aac": response.set('Content-Type', 'audio/aacp'); break;
case ".mp3": response.set('Content-Type', 'audio/mpeg'); break;
}
/*listenHandler(response, radio, after, state, function() {
var state = { newRequest: true, requestDate: new Date() };
var initialBuffer = getRadio(radio).liveStatus.audioCache.readLast(+delay+config.user.streamInitialBuffer,config.user.streamInitialBuffer);
if (!initialBuffer) {
log.error("/listen/" + radio + "/" + delay + ": initialBuffer not available");
response.writeHead(400);
return response.end();
}
log.info("listen: send initial buffer of " + initialBuffer.length + " bytes");
response.write(initialBuffer);
var finish = function() {
clearInterval(listenTimer);
response.end();
});*/
listenHandler(response, radio, delay, {
newRequest: true,
requestDate: new Date()
}, function() {
response.end();
});
}
var listenTimer = setInterval(function() {
if (state.newRequest) {
listenRequestDate = state.requestDate;
state.newRequest = false;
} else if (listenRequestDate !== state.requestDate) {
log.warn("request canceled because another one has been initiated");
return finish();
}
var willWaitDrain = !response.write("");
if (!willWaitDrain) { // detect congestion of stream
sendMore();
} else {
log.debug("listenHandler: will wait for drain event");
var drainCallback = function() {
clearTimeout(timeoutMonitor);
sendMore();
}
response.once("drain", drainCallback);
var timeoutMonitor = setTimeout(function() {
response.removeListener("drain", drainCallback);
log.error("listenHandler: drain event not emitted, connection timeout");
return finish();
}, config.user.streamGranularity*1500);
}
}, 1000*config.user.streamGranularity);
var sendMore = function() {
var radioObj = getRadio(radio);
if (!radioObj) {
log.error("/listen/" + radio + "/" + delay + ": radio not available");
return finish();
}
var audioCache = radioObj.liveStatus.audioCache;
if (!audioCache) {
log.error("/listen/" + radio + "/" + delay + ": audioCache not available");
return finish();
}
response.write(audioCache.readAmountAfterCursor(config.user.streamGranularity));
}
break;
case "metadata":
//metadataHandler(response, radio);
response.set({ 'Access-Control-Allow-Origin': '*' });
var result = getRadio(radio).liveStatus.metaCache.read();
response.json(result);
var radio = getRadio(radio);
if (!radio) {
log.error("/metadata/" + radio + "/" + delay + ": radio not available");
response.writeHead(400);
return response.end();
}
var metaCache = radio.liveStatus.metaCache;
if (!metaCache) {
log.error("/metadata/" + radio + "/" + delay + ": metadata not available");
response.writeHead(400);
return response.end();
} else {
response.json(metaCache.read());
}
break;
default:
+3 -3
View File
@@ -1742,9 +1742,9 @@
"integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw="
},
"webradio-metadata": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/webradio-metadata/-/webradio-metadata-0.1.4.tgz",
"integrity": "sha512-46GrZPxd9rQFmHEiRFjzVfV09XBrOlWVGrc+b2NVNLtXe5fMMgi5C/ftNGl9JEGy/UFLMny73WpmkWuVYtKt5A==",
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/webradio-metadata/-/webradio-metadata-0.1.6.tgz",
"integrity": "sha512-345c6QzlnstTB9CZ4s535P2DwqSD0R1Uw/E9yHd4hwCpw3jNITelnxQ7biK78zXIKTDy4Po1dkKUwqUVHyiNOA==",
"requires": {
"express": "4.16.2",
"helmet": "3.9.0",
+1 -1
View File
@@ -12,7 +12,7 @@
"adblockradio-sdk": "git://github.com/dest4/adblockradio-sdk.git#master",
"async": "^2.6.0",
"loglevel": "^1.6.1",
"webradio-metadata": "^0.1.4"
"webradio-metadata": "^0.1.6"
},
"devDependencies": {
"eslint": "^4.15.0"