Merge pull request #38 from icedream/develop

Version 0.3.
develop
Carl Kittelberger 2015-11-05 19:57:33 +01:00
commit 7160acee75
17 changed files with 417 additions and 273 deletions

55
README.md Normal file
View File

@ -0,0 +1,55 @@
# TS3Bot
A new and easy way to set up your own TeamSpeak 3 bot!
This repository contains the Node.js/IcedCoffeeScript code needed for the TS3Bot Docker image.
## Running TS3Bot without Docker
Basically these instructions are derived from [the Docker build files](https://github.com/icedream/ts3bot-docker).
You can adopt these depending on your OS (the Docker image uses Debian Jessie, so the instructions below are for
that OS).
We assume that we want to run the "develop" branch of TS3Bot here. You can easily replace "develop" with another branch you want to run, like "master" for the stable code.
Commands being run as your own user are marked with `$` and commands being run as root are marked with `#`.
- Install the dependencies:
$ apt-get install blackbox xvfb xdotool pulseaudio pulseaudio-utils cmake libvlc-dev vlc-plugin-pulse
- Create a new user to run TS3Bot on - do not run this on your own user if you use TeamSpeak3 on it as that will overwrite your configuration!
# adduser --system --disabled-login --disabled-password ts3bot
- Download and unpack TeamSpeak3 client for your platform into a folder accessible by the TS3Bot user. Only read access is required. Replace `3.0.18.2` with whatever version of TeamSpeak3 you prefer to install, usually that is the most recent one. Accept the license that shows up in the process.
# wget -Ots3client.run http://dl.4players.de/ts/releases/3.0.18.2/TeamSpeak3-Client-linux_$(uname -p)-3.0.18.2.run
# chmod +x ts3client.run
# ./ts3client.run --target ~ts3bot/ts3client
# rm ts3client.run
- Download the TS3Bot control application into your TS3Bot user's home folder. The TS3Bot user itself only needs read access to the code. You can do this in two ways:
o By downloading the tar.gz archive from GitHub and unpacking it.
# wget -q -O- https://github.com/icedream/ts3bot-control/archive/develop.tar.gz | tar xz -C ~ts3bot
o By cloning the Git repository from GitHub.
# git clone https://github.com/icedream/ts3bot-control -b develop ~ts3bot/ts3bot-control-develop
- Install the Node.js dependencies using `npm`. Note how a simple `npm install` will install the wrong version of WebChimera.js and you need to provide it with correct Node.js information (environment variables `npm_config_wcjs_runtime` and `npm_config_wcjs_runtime_version`) like this:
# cd ~ts3bot/ts3bot-control-develop
# npm_config_wcjs_runtime="node" npm_config_wcjs_runtime_version="$(node --version | tr -d 'v')" npm install
- Now set up your TS3Bot configuration files in your TS3Bot user's home folder. For this create a folder `.ts3bot` in the home directory and put a `config.json` with your configuration there. The most minimal configuration consists of:
o `identity-path` - The path to your identity INI file, export a newly generated identity from your own TeamSpeak3 client for that.
o `ts3-install-path` - The path where you installed the TeamSpeak3 client to, you can skip this if you used exactly the same path as in the instructions above.
o `ts3-server` - The URL to the server/channel you want the bot to connect to, you can get from your own TS3 client via "Extras" > "Invite friend", select the checkbox "Channel" and select "ts3server link" as invitation type.
Running the bot can finally be done like this:
$ sudo -u ts3bot -H node ~ts3bot/ts3bot-control-develop
You can provide your configuration as command line arguments instead if you want, that can be useful for just temporary configuration you want to test. For that just append the configuration options to the command line above and prefix every command line option with `--`, for example for `ts3-install-path` you would write `--ts3-install-path=/your/path/to/ts3client`

278
app.iced
View File

@ -8,6 +8,9 @@ request = require "request"
fs = require("fs") fs = require("fs")
path = require("path") path = require("path")
qs = require "querystring" qs = require "querystring"
temp = require("temp").track()
youtubedl = require "youtube-dl"
isValidUrl = (require "valid-url").isWebUri
log = getLogger "Main" log = getLogger "Main"
@ -16,13 +19,6 @@ removeBB = (str) -> str.replace /\[(\w+)[^\]]*](.*?)\[\/\1]/g, "$2"
module.exports = module.exports =
shutdown: (cb) => shutdown: (cb) =>
apiService = services.find("api")
if apiService and apiService.state == "started"
await apiService.stop defer(err)
if err
cb? new Error "Could not stop API"
return
ts3clientService = services.find("ts3client") ts3clientService = services.find("ts3client")
if ts3clientService and ts3clientService.state == "started" if ts3clientService and ts3clientService.state == "started"
await ts3clientService.stop defer(err) await ts3clientService.stop defer(err)
@ -39,10 +35,37 @@ module.exports =
cb?() cb?()
shutdownSync: => Sync @shutdown shutdownSync: => Sync @shutdown
# Separate our own PulseAudio from any system one by using our own custom XDG directories.
process.env.XDG_RUNTIME_DIR = temp.mkdirSync "ts3bot-xdg"
# Xvfb for isolated graphical interfaces!
xvfbService = services.find("xvfb")
await xvfbService.start defer err, vlc
if err
if not process.env.DISPLAY? or process.env.DISPLAY.trim() == ""
log.error "Xvfb could not start up and no display is available!", err
await module.exports.shutdown defer()
process.exit 1
log.warn "Xvfb could not start up - will use existing display!", err
# PulseAudio daemon # PulseAudio daemon
await services.find("pulseaudio").start defer err await services.find("pulseaudio").start defer err
if err if err
log.warn "PulseAudio could not start up, audio may not act as expected!" log.warn "PulseAudio could not start up, audio may not act as expected!", err
# VLC via WebChimera.js
vlcService = services.find("vlc")
await vlcService.start defer err, vlc
if err
log.warn "VLC could not start up!", err
await module.exports.shutdown defer()
process.exit 1
# This is where we keep track of the volume
vlcVolume = 50
# Cached information for tracks in playlist
vlcMediaInfo = {}
# TeamSpeak3 # TeamSpeak3
ts3clientService = services.find("ts3client") ts3clientService = services.find("ts3client")
@ -50,6 +73,32 @@ ts3clientService = services.find("ts3client")
ts3clientService.on "started", (ts3proc) => ts3clientService.on "started", (ts3proc) =>
ts3query = ts3clientService.query ts3query = ts3clientService.query
ts3clientService.once "stopped", () =>
ts3query = undefined
# VLC event handling
vlc.onPlaying = () =>
try
# TODO: Check why info is sometimes null, something must be wrong with the "add"/"play" commands here!
# TODO: Do not format as URL in text message if MRL points to local file
item = vlc.playlist.items[vlc.playlist.currentItem]
info = vlcMediaInfo[item.mrl]
url = info?.originalUrl or item.mrl
title = info?.title or item.mrl
ts3query?.sendtextmessage 2, 0, "Now playing [URL=#{url}]#{title}[/URL]."
# Restore audio volume
vlc.audio.volume = vlcVolume
catch e
log.warn "Error in VLC onPlaying handler", e
vlc.onPaused = () => ts3query?.sendtextmessage 2, 0, "Paused."
vlc.onForward = () => ts3query?.sendtextmessage 2, 0, "Fast-forwarding..."
vlc.onBackward = () => ts3query?.sendtextmessage 2, 0, "Rewinding..."
vlc.onEncounteredError = () => log.error "VLC has encountered an error! You will need to restart the bot.", arguments
vlc.onStopped = () => ts3query?.sendtextmessage 2, 0, "Stopped."
ts3query.currentScHandlerID = 1 ts3query.currentScHandlerID = 1
ts3query.mydata = {} ts3query.mydata = {}
@ -114,6 +163,9 @@ ts3clientService.on "started", (ts3proc) =>
ts3query.on "message.notifytextmessage", (args) => ts3query.on "message.notifytextmessage", (args) =>
await ts3query.use args.schandlerid, defer(err, data) await ts3query.use args.schandlerid, defer(err, data)
if not args.msg?
return
msg = args.msg msg = args.msg
invoker = { name: args.invokername, uid: args.invokeruid, id: args.invokerid } invoker = { name: args.invokername, uid: args.invokeruid, id: args.invokerid }
targetmode = args.targetmode # 1 = private, 2 = channel targetmode = args.targetmode # 1 = private, 2 = channel
@ -134,61 +186,177 @@ ts3clientService.on "started", (ts3proc) =>
params = [] params = []
switch name.toLowerCase() switch name.toLowerCase()
when "play" when "current"
q = item = vlc.playlist.items[vlc.playlist.currentItem]
uid: invoker.uid info = vlcMediaInfo[item.mrl]
input: removeBB paramline url = info?.originalUrl or item.mrl
await request "http://127.0.0.1:16444/play?#{qs.stringify q}", defer(err, response) title = info?.title or item.mrl
switch response.statusCode ts3query?.sendtextmessage 2, 0, "Currently playing [URL=#{url}]#{title}[/URL]."
when 200 then ts3query.sendtextmessage args.targetmode, invoker.id, "Now playing #{paramline}."
when 400 then ts3query.sendtextmessage args.targetmode, invoker.id, "Something seems to be wrong with what you wrote. Maybe check the URL/sound name you provided?" # Restore audio volume
when 403 then ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, you're not allowed to play #{q.input} via the bot." vlc.audio.volume = vlcVolume
else when "pause"
log.warn "API reported error", response.statusCode, err # now we can toggle-pause playback this easily! yay!
ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, an error occurred. Try again later." vlc.togglePause()
when "stop"
q =
uid: invoker.uid
await request "http://127.0.0.1:16444/stop?#{qs.stringify q}", defer(err, response)
switch response.statusCode
when 200 then ts3query.sendtextmessage args.targetmode, invoker.id, "Stopped playback."
when 403 then ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, you're not allowed to do that."
else
log.warn "API reported error", response.statusCode, err
ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, an error occurred. Try again later."
when "setvolume"
q =
uid: invoker.uid
volume: parseFloat paramline
await request "http://127.0.0.1:16444/setvolume?#{qs.stringify q}", defer(err, response)
switch response.statusCode
when 200 then ts3query.sendtextmessage args.targetmode, invoker.id, "Set volume to #{q.volume}"
when 400 then ts3query.sendtextmessage args.targetmode, invoker.id, "Something seems to be wrong with what you wrote. Maybe check the volume? It's supposed to be a floating-point number between 0 and 2."
when 403 then ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, you're not allowed to do that."
else
log.warn "API reported error", response.statusCode, err
ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, an error occurred. Try again later."
when "changenick"
nick = if paramline.length > params[0].length then paramline else params[0]
if nick.length < 1 or nick.length > 32
ts3query.sendtextmessage args.targetmode, invoker.id, "Invalid nickname."
return return
when "play"
inputBB = paramline.trim()
input = (removeBB paramline).trim()
# we gonna interpret play without a url as an attempt to unpause the current song
if input.length <= 0
vlc.play()
return
# only allow playback from file if it's a preconfigured alias
if isValidUrl input
log.debug "Got input URL:", input
else
input = config.get "aliases:#{input}"
if not(isValidUrl input) and not(fs.existsSync input)
log.debug "Got neither valid URL nor valid alias:", input
ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, you're not allowed to play #{inputBB} via the bot."
return
# TODO: permission system to check if uid is allowed to play this url or alias
vlc.playlist.clear()
# let's give youtube-dl a shot!
await youtubedl.getInfo input, [
"--format=bestaudio"
], defer(err, info)
if err or not info?
log.debug "There is no audio-only download for #{inputBB}, downloading full video instead."
await youtubedl.getInfo input, [
"--format=best"
], defer(err, info)
if err or not info?
info =
url: input
if not info.url?
info.url = input
info.title = input # URL as title
info.originalUrl = input
vlcMediaInfo[info.url] = info
# play it in VLC
vlc.play info.url
when "stop-after"
vlc.playlist.mode = vlc.playlist.Single
ts3query.sendtextmessage args.targetmode, invoker.id, "Playback will stop after the current playlist item."
when "loop"
inputBB = paramline
input = null
switch (removeBB paramline).toLowerCase().trim()
when ""
# just show current mode
ts3query.sendtextmessage args.targetmode, invoker.id, "Playlist looping is #{if vlc.playlist.mode == vlc.playlist.Loop then "on" else "off"}."
when "on"
# enable looping
vlc.playlist.mode = vlc.playlist.Loop
ts3query.sendtextmessage args.targetmode, invoker.id, "Playlist looping is now on."
when "off"
# disable looping
vlc.playlist.mode = vlc.playlist.Normal
ts3query.sendtextmessage args.targetmode, invoker.id, "Playlist looping is now off."
else
ts3query.sendtextmessage args.targetmode, invoker.id, "[B]#{name} on|off[/B] - Turns playlist looping on or off"
return
when "next"
if vlc.playlist.items.count == 0
ts3query.sendtextmessage args.targetmode, invoker.id, "The playlist is empty."
return
if vlc.playlist.mode != vlc.playlist.Loop and vlc.playlist.currentItem == vlc.playlist.items.count - 1
ts3query.sendtextmessage args.targetmode, invoker.id, "Can't jump to next playlist item, this is the last one!"
return
vlc.playlist.next()
when "prev", "previous"
if vlc.playlist.items.count == 0
ts3query.sendtextmessage args.targetmode, invoker.id, "The playlist is empty."
return
if vlc.playlist.mode != vlc.playlist.Loop and vlc.playlist.currentItem <= 0
ts3query.sendtextmessage args.targetmode, invoker.id, "Can't jump to previous playlist item, this is the first one!"
return
vlc.playlist.prev()
when "empty", "clear"
vlc.playlist.clear()
ts3query.sendtextmessage args.targetmode, invoker.id, "Cleared the playlist."
when "enqueue", "add", "append"
inputBB = paramline.trim()
input = (removeBB paramline).trim()
if inputBB.length <= 0
ts3query.sendtextmessage args.targetmode, invoker.id, "[B]#{name} <url>[/B] - Adds the specified URL to the current playlist"
return
# only allow playback from file if it's a preconfigured alias
if isValidUrl input
log.debug "Got input URL:", input
else
input = config.get "aliases:#{input}"
if not(isValidUrl input) and not(fs.existsSync input)
log.debug "Got neither valid URL nor valid alias:", input
ts3query.sendtextmessage args.targetmode, invoker.id, "Sorry, you're not allowed to play #{inputBB} via the bot."
return
# TODO: permission system to check if uid is allowed to play this url or alias
# let's give youtube-dl a shot!
await youtubedl.getInfo input, [
"--format=bestaudio"
], defer(err, info)
if err or not info?
log.debug "There is no audio-only download for #{inputBB}, downloading full video instead."
await youtubedl.getInfo input, [
"--format=best"
], defer(err, info)
if err or not info?
info =
url: input
if not info.url?
info.url = input
info.title = input # URL as title
info.originalUrl = input
vlcMediaInfo[info.url] = info
# add it in VLC
vlc.playlist.add info.url
ts3query.sendtextmessage args.targetmode, invoker.id, "Added [URL=#{input}]#{info.title}[/URL] to the playlist."
# TODO: Do we need to make sure that vlc.playlist.mode is not set to "Single" here or is that handled automatically?
when "stop"
vlc.stop()
when "vol"
inputBB = paramline.trim()
input = (removeBB paramline).trim()
if inputBB.length <= 0
ts3query.sendtextmessage args.targetmode, invoker.id, "Volume is currently set to #{vlcVolume}%."
return
vol = parseInt input
if paramline.trim().length <= 0 or isNaN(vol) or vol > 200 or vol < 0
ts3query.sendtextmessage args.targetmode, invoker.id, "[B]vol <number>[/B] - takes a number between 0 (0%) and 200 (200%) to set the volume. 100% is 100. Defaults to 50 (50%) on startup."
return
vlc.audio.volume = vlcVolume = vol
ts3query.sendtextmessage args.targetmode, invoker.id, "Volume set to #{vol}%."
when "changenick"
nick = paramline
Sync () => Sync () =>
try try
ts3query.clientupdate.sync ts3query, { client_nickname: nick } ts3query.clientupdate.sync ts3query, { client_nickname: nick }
catch err catch err
ts3query.sendtextmessage args.targetmode, invoker.id, "That unfortunately didn't work out."
log.warn "ChangeNick failed, error information:", err log.warn "ChangeNick failed, error information:", err
switch err.id
when 513 then ts3query.sendtextmessage args.targetmode, invoker.id, "That nickname is already in use."
when 1541 then ts3query.sendtextmessage args.targetmode, invoker.id, "That nickname is too short or too long."
else ts3query.sendtextmessage args.targetmode, invoker.id, "That unfortunately didn't work out."
await ts3clientService.start [ config.get("ts3-server") ], defer(err, ts3proc) await ts3clientService.start [ config.get("ts3-server") ], defer(err, ts3proc)
if err if err
log.error "TeamSpeak3 could not start, shutting down." log.error "TeamSpeak3 could not start, shutting down.", err
await module.exports.shutdown defer()
process.exit 1
# HTTP API
await services.find("api").start defer err
if err
log.error "API could not start up, shutting down!"
await module.exports.shutdown defer() await module.exports.shutdown defer()
process.exit 1 process.exit 1

23
app.js
View File

@ -1,3 +1,5 @@
#!/usr/bin/env node
require("iced-coffee-script/register"); require("iced-coffee-script/register");
Sync = require("sync"); Sync = require("sync");
@ -33,6 +35,7 @@ doShutdownAsync = function(cb) {
process.on("uncaughtException", function(err) { process.on("uncaughtException", function(err) {
log.error("Shutting down due to an uncaught exception!", err); log.error("Shutting down due to an uncaught exception!", err);
app.shutdownSync(); app.shutdownSync();
process.exit(0xFF);
}); });
process.on("exit", function(e) { process.on("exit", function(e) {
@ -42,25 +45,35 @@ process.on("exit", function(e) {
process.on("SIGTERM", function(e) { process.on("SIGTERM", function(e) {
log.debug("Caught SIGTERM signal"); log.debug("Caught SIGTERM signal");
app.shutdown(); app.shutdown(function() {
process.exit(0);
});
}); });
process.on("SIGINT", function() { process.on("SIGINT", function() {
log.debug("Caught SIGINT signal"); log.debug("Caught SIGINT signal");
app.shutdown(); app.shutdown(function() {
process.exit(0);
});
}); });
process.on("SIGHUP", function() { process.on("SIGHUP", function() {
log.debug("Caught SIGHUP signal"); log.debug("Caught SIGHUP signal");
app.shutdown(); app.shutdown(function() {
process.exit(0);
});
}); });
process.on("SIGQUIT", function() { process.on("SIGQUIT", function() {
log.debug("Caught SIGQUIT signal"); log.debug("Caught SIGQUIT signal");
app.shutdown(); app.shutdown(function() {
process.exit(0);
});
}); });
process.on("SIGABRT", function() { process.on("SIGABRT", function() {
log.debug("Caught SIGABRT signal"); log.debug("Caught SIGABRT signal");
app.shutdown(); app.shutdown(function() {
process.exit(0);
});
}); });

View File

@ -28,6 +28,9 @@ nconf.defaults
if not nconf.get("ts3-server") if not nconf.get("ts3-server")
throw new Error "You need to provide a TeamSpeak3 server URL (starts with ts3server:// and can be generated from any TS3 client GUI)." throw new Error "You need to provide a TeamSpeak3 server URL (starts with ts3server:// and can be generated from any TS3 client GUI)."
if nconf.get("nickname")? and (nconf.get("nickname").length < 3 or nconf.get("nickname").length > 30)
throw new Error "Nickname must be between 3 and 30 characters long."
console.log "Configuration loaded." console.log "Configuration loaded."
if nconf.get "dump-config" if nconf.get "dump-config"

View File

@ -1,8 +1,11 @@
{ {
"name": "ts3bot", "name": "ts3bot",
"version": "0.2.1", "version": "0.3.0",
"description": "Allows running TeamSpeak3 as a bot for all kinds of media (local music/videos/streams/YouTube/...) without the need for a real GUI to exist.", "description": "Allows running TeamSpeak3 as a bot for all kinds of media (local music/videos/streams/YouTube/...) without the need for a real GUI to exist.",
"main": "app.js", "main": "app.js",
"bin": {
"ts3bot": "app.js"
},
"keywords": [ "keywords": [
"teamspeak", "teamspeak",
"teamspeak3", "teamspeak3",
@ -38,8 +41,9 @@
"stream-splitter": "^0.3.2", "stream-splitter": "^0.3.2",
"string.prototype.startswith": "^0.2.0", "string.prototype.startswith": "^0.2.0",
"sync": "^0.2.5", "sync": "^0.2.5",
"temp": "^0.8.3",
"valid-url": "^1.0.9", "valid-url": "^1.0.9",
"vlc-api": "0.0.0", "webchimera.js": "^0.1.38",
"which": "^1.1.2", "which": "^1.1.2",
"winston": "^1.0.1", "winston": "^1.0.1",
"xvfb": "git://github.com/icedream/node-xvfb.git", "xvfb": "git://github.com/icedream/node-xvfb.git",

View File

@ -2,7 +2,9 @@ which = require("which").sync
path = require "path" path = require "path"
log = require("./logger")("RequireBin") log = require("./logger")("RequireBin")
module.exports = (binName) => module.exports = (binName, doErrorIfNotFound) =>
doErrorIfNotFound = true unless doErrorIfNotFound?
# check if xvfb is findable from here # check if xvfb is findable from here
if path.resolve(binName) == path.normalize(binName) if path.resolve(binName) == path.normalize(binName)
# this is an absolute path # this is an absolute path
@ -14,5 +16,9 @@ module.exports = (binName) =>
log.debug "#{binName} detected:", binPath log.debug "#{binName} detected:", binPath
return binPath return binPath
catch err catch err
log.error "#{binName} could not be found.", err if doErrorIfNotFound
log.error "#{binName} could not be found."
throw new Error "#{binName} could not be found." throw new Error "#{binName} could not be found."
else
log.warn "#{binName} could not be found."
return null

View File

@ -19,7 +19,7 @@ module.exports = class Service extends EventEmitter
state: "stopped" state: "stopped"
start: () => @_start.apply @, [ false ].concat Array.prototype.slice.call(arguments) start: () => @_start false, arguments...
startSync: () => Sync () => @start.sync @ startSync: () => Sync () => @start.sync @
@ -78,14 +78,14 @@ module.exports = class Service extends EventEmitter
if not quiet if not quiet
@log.info "Started #{@name}" @log.info "Started #{@name}"
@_lastArgs = args @_lastArgs = serviceArgs
@state = "started" @state = "started"
@emit "started", service @emit "started", service
cb? null, service cb? null, service
stop: () => @_stop.apply @, [ false ].concat Array.prototype.slice.call(arguments) stop: () => @_stop false, arguments...
stopSync: () => Sync () => @stop.sync @ stopSync: () => Sync () => @stop.sync @

View File

@ -56,12 +56,11 @@ module.exports.Service = require "./service_template"
# register services # register services
services = [ services = [
new(require "./services/api")
new(require "./services/pulseaudio") new(require "./services/pulseaudio")
new(require "./services/ts3client") new(require "./services/ts3client")
new(require "./services/vlc") new(require "./services/vlc")
new(require "./services/xvfb") new(require "./services/xvfb")
new(require "./services/blackbox") new(require "./services/xwm")
] ]
services.sort require("./service_depcomparer") # sort services by dependency services.sort require("./service_depcomparer") # sort services by dependency
for service in services for service in services

View File

@ -1,111 +0,0 @@
express = require "express"
url = require "url"
path = require "path"
spawn = require("child_process").spawn
net = require "net"
Socket = net.Socket
getLogger = require "../logger"
config = require "../config"
log = getLogger "API"
youtubedl = require "youtube-dl"
#PulseAudio = require "pulseaudio"
isValidUrl = (require "valid-url").isWebUri
services = require "../services"
module.exports = class APIService extends services.Service
dependencies: [
"pulseaudio"
"vlc"
"ts3client"
]
constructor: () -> super "API",
start: (cb) ->
if @httpServer
cb? null
return
vlc = services.find("vlc").instance
ts3query = services.find("ts3client").query
# set up HTTP server
log.debug "Starting up HTTP API..."
app = express()
app.get "/play", (req, res) =>
if not req.query.uid
log.debug "Didn't get a UID, sending forbidden"
res.status(400).send("Forbidden")
return
if not req.query.input
log.debug "Didn't get an input URI/alias, sending bad request"
res.status(400).send("Bad request")
return
input = null
# only allow playback from file if it's a preconfigured alias
if isValidUrl req.query.input
log.debug "Got input URL:", req.query.input
input = req.query.input
else
input = config.get("aliases:#{req.query.input}")
if not(isValidUrl input) and not(fs.existsSync input)
log.debug "Got neither valid URL nor valid alias:", req.query.input
res.status(403).send("Forbidden")
return
# TODO: permission system to check if uid is allowed to play this url or alias
await vlc.status.empty defer(err)
if err
res.status(503).send("Something went wrong")
log.warn "VLC API returned an error when trying to empty", err
return
# let's give youtube-dl a shot!
await youtubedl.getInfo input, [
"--format=bestaudio"
], defer(err, info)
if err or not info?
await youtubedl.getInfo input, [
"--format=best"
], defer(err, info)
if err or not info?
info =
url: input
if not info.url?
info.url = input
info.title = input # URL as title
await vlc.status.play info.url, defer(err)
if err
vlc.status.empty()
res.status(503).send("Something went wrong")
log.warn "VLC API returned an error when trying to play", err
return
res.send JSON.stringify info
app.get "/stop", (req, res) =>
if not req.query.uid
log.debug "Didn't get a UID, sending forbidden"
res.status(403).send("Forbidden - missing UID")
return
# TODO: permission system to check if uid is allowed to stop playback
vlc.status.stop()
vlc.status.empty()
res.send("OK")
app.get "/setvolume", (req, res) =>
throw new "Not implemented yet" # FIXME below, still need to implement audio
@httpServer = app.listen 16444
cb? null
stop: (cb) ->
@httpServer.close()
cb?()

View File

@ -10,7 +10,6 @@ pacmdPath = require_bin "pacmd"
module.exports = class PulseAudioService extends services.Service module.exports = class PulseAudioService extends services.Service
dependencies: [ dependencies: [
"xvfb"
] ]
constructor: -> super "PulseAudio", constructor: -> super "PulseAudio",
start: (cb) -> start: (cb) ->

View File

@ -16,12 +16,18 @@ ts3client_binpath = require_bin path.join(config.get("ts3-install-path"), "ts3cl
module.exports = class TS3ClientService extends services.Service module.exports = class TS3ClientService extends services.Service
dependencies: [ dependencies: [
"xvfb",
"blackbox",
"pulseaudio" "pulseaudio"
] ]
constructor: -> super "TS3Client", constructor: -> super "TS3Client",
start: (args, cb) => start: (args, cb) =>
if not process.env.XDG_RUNTIME_DIR? or process.env.XDG_RUNTIME_DIR.trim() == ""
cb? new Error "XDG runtime directory needs to be set."
return
if not process.env.DISPLAY? or process.env.DISPLAY.trim() == ""
cb? new Error "There is no display to run TeamSpeak3 on."
return
if typeof args == "function" if typeof args == "function"
cb = args cb = args
args = null args = null

View File

@ -1,87 +1,45 @@
spawn = require("child_process").spawn spawn = require("child_process").spawn
services = require("../services") services = require("../services")
config = require("../config") config = require("../config")
VLCApi = require("vlc-api") wc = require("webchimera.js")
StreamSplitter = require("stream-splitter") StreamSplitter = require("stream-splitter")
require_bin = require("../require_bin")
vlcBinPath = require_bin "vlc"
module.exports = class VLCService extends services.Service module.exports = class VLCService extends services.Service
dependencies: [ dependencies: [
"pulseaudio" "pulseaudio"
] ]
constructor: -> super "VLC", constructor: -> super "VLC",
###
# Starts an instance of VLC and keeps it ready for service.
###
start: (cb) -> start: (cb) ->
if @_process if @_instance
cb? null, @_process cb? null, @_instance
return return
calledCallback = false calledCallback = false
proc = null instance = wc.createPlayer [
doStart = null
doStart = () =>
await services.find("pulseaudio").start defer(err)
if err
throw new Error "Dependency pulseaudio failed."
proc = spawn vlcBinPath, [
"-I", "http",
"--http-host", config.get("vlc-host"),
"--http-port", config.get("vlc-port"),
"--http-password", config.get("vlc-password")
"--aout", "pulse", "--aout", "pulse",
"--no-video" "--no-video"
], ]
stdio: ['ignore', 'pipe', 'pipe'] instance.audio.volume = 50
detached: true
# logging @_instance = instance
stdoutTokenizer = proc.stdout.pipe StreamSplitter "\n" cb? null, @_instance
stdoutTokenizer.encoding = "utf8";
stdoutTokenizer.on "token", (token) =>
token = token.trim() # get rid of \r
@log.debug token
stderrTokenizer = proc.stderr.pipe StreamSplitter "\n"
stderrTokenizer.encoding = "utf8";
stderrTokenizer.on "token", (token) =>
token = token.trim() # get rid of \r
@log.debug token
proc.on "exit", () =>
if @state == "stopping"
return
if not calledCallback
calledCallback = true
@log.warn "VLC terminated unexpectedly during startup."
cb? new Error "VLC terminated unexpectedly."
@log.warn "VLC terminated unexpectedly, restarting."
doStart()
@_process = proc
doStart()
setTimeout (() =>
if not calledCallback
calledCallback = true
@instance = new VLCApi
host: ":#{encodeURIComponent config.get("vlc-password")}@#{config.get("vlc-host")}",
port: config.get("vlc-port")
cb? null, @instance), 1500 # TODO: Use some more stable condition
###
# Shuts down the VLC instance.
###
stop: (cb) -> stop: (cb) ->
if not @_process if not @_instance
cb?() cb?()
return return
@instance = null @_instance.stop()
@_process.kill() # TODO: Is there even a proper way to shut this down?
await @_process.once "exit", defer() @_instance = null
cb?() cb?()

View File

@ -4,11 +4,15 @@ config = require("../config")
services = require("../services") services = require("../services")
require_bin = require("../require_bin") require_bin = require("../require_bin")
require_bin "Xvfb" xvfbPath = require_bin "Xvfb", false
module.exports = class XvfbService extends services.Service module.exports = class XvfbService extends services.Service
constructor: -> super "Xvfb", constructor: -> super "Xvfb",
start: (cb) -> start: (cb) ->
if not xvfbPath?
cb? new Error "Xvfb is not available."
return
if @instance if @instance
cb? null, @instance cb? null, @instance
return return

View File

@ -1,17 +1,28 @@
spawn = require("child_process").spawn spawn = require("child_process").spawn
log = require("../logger")("BlackBox") log = require("../logger")("XWindowManager")
services = require("../services") services = require("../services")
StreamSplitter = require("stream-splitter") StreamSplitter = require("stream-splitter")
require_bin = require("../require_bin") require_bin = require("../require_bin")
blackboxBinPath = require_bin "blackbox" xwmBinPath = require_bin "x-window-manager", false
module.exports = class BlackBoxService extends services.Service module.exports = class XWindowManagerService extends services.Service
dependencies: [ dependencies: [
"xvfb"
] ]
constructor: -> super "BlackBox", constructor: -> super "XWindowManager",
start: (cb) -> start: (cb) ->
if not xwmBinPath?
cb? new Error "A window manager not available."
return
if not process.env.XDG_RUNTIME_DIR? or process.env.XDG_RUNTIME_DIR.trim() == ""
cb? new Error "XDG runtime directory needs to be set."
return
if not process.env.DISPLAY? or process.env.DISPLAY.trim() == ""
cb? new Error "There is no display to run TeamSpeak3 on."
return
if @process if @process
cb? null, @process cb? null, @process
return return
@ -21,11 +32,7 @@ module.exports = class BlackBoxService extends services.Service
proc = null proc = null
doStart = null doStart = null
doStart = () => doStart = () =>
await services.find("xvfb").start defer(err) proc = spawn xwmBinPath, [],
if err
throw new Error "Dependency xvfb failed."
proc = spawn blackboxBinPath, [ "-rc", "/dev/null" ],
stdio: ['ignore', 'pipe', 'pipe'] stdio: ['ignore', 'pipe', 'pipe']
detached: true detached: true
env: env:
@ -51,9 +58,9 @@ module.exports = class BlackBoxService extends services.Service
return return
if not calledCallback if not calledCallback
calledCallback = true calledCallback = true
@log.warn "BlackBox terminated unexpectedly during startup." @log.warn "Window manager terminated unexpectedly during startup."
cb? new Error "BlackBox terminated unexpectedly." cb? new Error "Window manager terminated unexpectedly."
@log.warn "BlackBox terminated unexpectedly, restarting." @log.warn "Window manager terminated unexpectedly, restarting."
doStart() doStart()
@process = proc @process = proc

View File

@ -10,9 +10,23 @@ merge = require "merge"
parserLog = getLogger "parser" parserLog = getLogger "parser"
escape = (value) => value.toString().replace(///\\///g, "\\\\").replace(/\//g, "\\/").replace(/\|/g, "\\|").replace(///\ ///g, "\\s") escape = (value) => value.toString()\
.replace(/\\/g, "\\\\")\
.replace(/\//g, "\\/")\
.replace(/\|/g, "\\p")\
.replace(/\n/g, "\\n")\
.replace(/\r/g, "\\r")\
.replace(/\t/g, "\\t")\
.replace(/\ /g, "\\s")
unescape = (value) => value.toString().replace(/\\s/g, " ").replace(/\\\//g, "/").replace(/\\\|/g, "|").replace(/\\\\/g, "\\") unescape = (value) => value.toString()\
.replace(/\\s/g, " ")\
.replace(/\\t/g, "\t")\
.replace(/\\r/g, "\r")\
.replace(/\\n/g, "\n")\
.replace(/\\p/g, "|")\
.replace(/\\\//g, "/")\
.replace(/\\\\/g, "\\")
buildCmd = (name, namedArgs, posArgs) => buildCmd = (name, namedArgs, posArgs) =>
# TODO: Add support for collected arguments (aka lists) # TODO: Add support for collected arguments (aka lists)

View File

@ -211,9 +211,18 @@ module.exports = class SettingsFile
select: () -> select: () ->
settingsObj.defaultIdentity = @id settingsObj.defaultIdentity = @id
update: () -> update: () ->
settingsObj.log.silly "Requested update of #{id.id}"
for own index, identity of settingsObj.identities for own index, identity of settingsObj.identities
if identity.id == id.id if identity.id == id.id
settingsObj.identities[index] = merge identity, id # remove functions from this object
cleanIdentity = merge @
for own k, v of cleanIdentity
if typeof v == "function"
delete cleanIdentity[k]
# now this is our new identity object!
settingsObj.log.silly "Updating identity #{id.id}"
settingsObj.identities[index] = cleanIdentity
return return
remove: () -> remove: () ->
for own index, identity of settingsObj.identities for own index, identity of settingsObj.identities

View File

@ -6,13 +6,18 @@ services = require("./services")
StreamSplitter = require("stream-splitter") StreamSplitter = require("stream-splitter")
require_bin = require("./require_bin") require_bin = require("./require_bin")
xdotoolBinPath = require_bin "xdotool" xdotoolBinPath = require_bin "xdotool", false
# Just some tools to work with the X11 windows # Just some tools to work with the X11 windows
module.exports = module.exports =
getWindowIdByProcessId: (pid, cb) => getWindowIdByProcessId: (pid, cb) =>
wid = null wid = null
# Return null instantly if xdotool is not available
if not xdotoolBinPath?
cb? new Error "xdotool is not available"
return
# We provide --name due to the bug mentioned at https://github.com/jordansissel/xdotool/issues/14 # We provide --name due to the bug mentioned at https://github.com/jordansissel/xdotool/issues/14
xdoproc = spawn xdotoolBinPath, [ "search", "--any", "--pid", pid, "--name", "xdosearch" ], xdoproc = spawn xdotoolBinPath, [ "search", "--any", "--pid", pid, "--name", "xdosearch" ],
env: env:
@ -43,12 +48,17 @@ module.exports =
getWindowIdByProcessIdSync: (pid) => Sync() => @getWindowIdByProcessId.sync @, pid getWindowIdByProcessIdSync: (pid) => Sync() => @getWindowIdByProcessId.sync @, pid
sendKeys: (wid, keys, cb) => sendKeys: (wid, keys, cb) =>
# blackbox needs to be running for windowactivate to work # Do not bother trying if xdotool is not available
blackboxService = services.find("BlackBox") if not xdotoolBinPath?
if blackboxService.state != "started" cb? new Error "xdotool not available."
await blackboxService.start defer(err) return
# a window manager needs to be running for windowactivate to work
xwmService = services.find("XWindowManager")
if xwmService.state != "started"
await xwmService.start defer(err)
if err if err
cb? new Error "Could not start compatible window manager." cb? new Error "Could not start a window manager."
return return
xdoproc = spawn xdotoolBinPath, [ "windowactivate", "--sync", wid, "key", "--clearmodifiers", "--delay", "100" ].concat(keys), xdoproc = spawn xdotoolBinPath, [ "windowactivate", "--sync", wid, "key", "--clearmodifiers", "--delay", "100" ].concat(keys),