Add raw video/audio playback with ffmpeg

This commit is contained in:
Calvin Montgomery 2014-06-07 16:57:25 -07:00
commit 02771e6623
17 changed files with 428 additions and 141 deletions

View file

@ -175,3 +175,10 @@ aggressive-gc: false
# Allows you to blacklist certain channels. Users will be automatically kicked
# upon trying to join one.
channel-blacklist: []
# If you have ffmpeg installed, you can query metadata from raw files, allowing
# server-synched raw file playback. This requires the following:
# * ffmpeg must be installed on the server
# * you must install the fluent-ffmpeg module (npm install fluent-ffmpeg)
ffmpeg:
enabled: false

View file

@ -36,7 +36,8 @@ LibraryModule.prototype.getItem = function (id, cb) {
if (err) {
cb(err, null);
} else {
cb(null, new Media(row.id, row.title, row.seconds, row.type, {}));
var meta = JSON.parse(row.meta || "{}");
cb(null, new Media(row.id, row.title, row.seconds, row.type, meta));
}
});
};

View file

@ -16,6 +16,7 @@ const DEFAULT_PERMISSIONS = {
oplaylistjump: 1.5,
oplaylistaddlist: 1.5,
playlistaddcustom: 3, // Add custom embed to the playlist
playlistaddrawfile: 2, // Add raw file to the playlist
playlistaddlive: 1.5, // Add a livestream to the playlist
exceedmaxlength: 2, // Add a video longer than the maximum length set
addnontemp: 2, // Add a permanent video to the playlist
@ -195,6 +196,10 @@ PermissionsModule.prototype.canAddCustom = function (account) {
return this.hasPermission(account, "playlistaddcustom");
};
PermissionsModule.prototype.canAddRawFile = function (account) {
return this.hasPermission(account, "playlistaddrawfile");
};
PermissionsModule.prototype.canMoveVideo = function (account) {
return this.hasPermission(account, "playlistmove");
};

View file

@ -110,9 +110,8 @@ PlaylistModule.prototype.load = function (data) {
var i = 0;
playlist.pos = parseInt(playlist.pos);
playlist.pl.forEach(function (item) {
/* Backwards compatibility */
var m = new Media(item.media.id, item.media.title, item.media.seconds,
item.media.type);
item.media.type, item.media.meta || {});
var newitem = new PlaylistItem(m, {
uid: self._nextuid++,
temp: item.temp,
@ -134,6 +133,7 @@ PlaylistModule.prototype.load = function (data) {
PlaylistModule.prototype.save = function (data) {
var arr = this.items.toArray().map(function (item) {
/* Clear Google Docs and Vimeo meta */
if (item.media && item.media.meta) {
delete item.media.meta.object;
delete item.media.meta.params;
@ -313,8 +313,11 @@ PlaylistModule.prototype.handleQueue = function (user, data) {
return;
}
/* Specifying a custom title is currently only allowed for custom media */
if (typeof data.title !== "string" || data.type !== "cu") {
/**
* Specifying a custom title is currently only allowed for custom media
* and raw files
*/
if (typeof data.title !== "string" || (data.type !== "cu" && data.type !== "fi")) {
data.title = false;
}
@ -348,6 +351,12 @@ PlaylistModule.prototype.handleQueue = function (user, data) {
link: link
});
return;
} else if (type === "fi" && !perms.canAddRawFile(user)) {
user.socket.emit("queueFail", {
msg: "You don't have permission to add raw video files",
link: link
});
return;
}
var temp = data.temp || !perms.canAddNonTemp(user);
@ -397,6 +406,7 @@ PlaylistModule.prototype.handleQueue = function (user, data) {
title: data.title,
link: link,
temp: temp,
shouldAddToLibrary: !temp,
queueby: queueby,
duration: duration,
maxlength: maxlength
@ -430,6 +440,8 @@ PlaylistModule.prototype.queueStandard = function (user, data) {
}
if (item !== null) {
/* Don't re-cache data we got from the library */
data.shouldAddToLibrary = false;
self._addItem(item, data, user, function () {
lock.release();
self.channel.activeLock.release();
@ -864,14 +876,33 @@ PlaylistModule.prototype._addItem = function (media, data, user, cb) {
});
}
/* Warn about high bitrate for raw files */
if (media.type === "fi" && media.meta.bitrate > 1000) {
user.socket.emit("queueWarn", {
msg: "This video has a bitrate over 1000kbps. Clients with slow " +
"connections may experience lots of buffering."
});
}
/* Warn about possibly unsupported formats */
if (media.type === "fi" && media.meta.codec.indexOf("/") !== -1 &&
media.meta.codec !== "mov/h264" &&
media.meta.codec !== "flv/h264") {
user.socket.emit("queueWarn", {
msg: "The codec <code>" + media.meta.codec + "</code> is not supported " +
"by all browsers, and is not supported by the flash fallback layer. " +
"This video may not play for some users."
});
}
var item = new PlaylistItem(media, {
uid: self._nextuid++,
temp: data.temp,
queueby: data.queueby
});
if (data.title && media.type === "cu") {
media.title = data.title;
if (data.title && (media.type === "cu" || media.type === "fi")) {
media.setTitle(data.title);
}
var success = function () {
@ -893,7 +924,7 @@ PlaylistModule.prototype._addItem = function (media, data, user, cb) {
u.socket.emit("setPlaylistMeta", self.meta);
});
if (!data.temp && !util.isLive(media.type)) {
if (data.shouldAddToLibrary && !util.isLive(media.type)) {
if (self.channel.modules.library) {
self.channel.modules.library.cacheMedia(media);
}

View file

@ -104,7 +104,10 @@ var defaults = {
"max-items": 4000,
"update-interval": 5
},
"channel-blacklist": []
"channel-blacklist": [],
ffmpeg: {
enabled: false
}
};
/**

View file

@ -440,9 +440,14 @@ module.exports = {
return;
}
db.query("INSERT INTO `chan_" + chan + "_library` (id, title, seconds, type) " +
"VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE id=id",
[media.id, media.title, media.seconds, media.type], callback);
var meta = JSON.stringify({
bitrate: media.meta.bitrate,
codec: media.meta.codec
});
db.query("INSERT INTO `chan_" + chan + "_library` (id, title, seconds, type, meta) " +
"VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE id=id",
[media.id, media.title, media.seconds, media.type, meta], callback);
},
/**

View file

@ -1,7 +1,8 @@
var db = require("../database");
var Logger = require("../logger");
var Q = require("q");
const DB_VERSION = 1;
const DB_VERSION = 2;
module.exports.checkVersion = function () {
db.query("SELECT `key`,`value` FROM `meta` WHERE `key`=?", ["db_version"], function (err, rows) {
@ -18,6 +19,9 @@ module.exports.checkVersion = function () {
});
} else {
var v = parseInt(rows[0].value);
if (v >= DB_VERSION) {
return;
}
var next = function () {
if (v < DB_VERSION) {
update(v++, next);
@ -32,5 +36,33 @@ module.exports.checkVersion = function () {
};
function update(version, cb) {
setImmediate(cb);
if (version === 1) {
addMetaColumnToLibraries(cb);
}
}
function addMetaColumnToLibraries(cb) {
Logger.syslog.log("[database] db version indicates channel libraries don't have " +
"meta column. Updating...");
Q.nfcall(db.query, "SHOW TABLES")
.then(function (rows) {
rows = rows.map(function (r) {
return r[Object.keys(r)[0]];
}).filter(function (r) {
return r.match(/_library$/);
});
var queue = [];
rows.forEach(function (table) {
queue.push(Q.nfcall(db.query, "ALTER TABLE `" + table + "` ADD meta TEXT")
.then(function () {
Logger.syslog.log("Added meta column to " + table);
})
);
});
return Q.all(queue);
}).catch(function (err) {
Logger.errlog.log("Adding meta column to library tables failed: " + err);
}).done(cb);
}

108
lib/ffmpeg.js Normal file
View file

@ -0,0 +1,108 @@
var Logger = require("./logger");
var Config = require("./config");
var Metadata;
var enabled = false;
function init() {
if (Config.get("ffmpeg.enabled")) {
try {
Metadata = require("fluent-ffmpeg").Metadata;
Logger.syslog.log("Enabling raw file support with fluent-ffmpeg");
enabled = true;
} catch (e) {
Logger.errlog.log("Failed to load fluent-ffmpeg. Did you remember to " +
"execute `npm install fluent-ffmpeg` ?");
}
}
}
var acceptedCodecs = {
"mov/h264": true,
"flv/h264": true,
"matroska/vp8": true,
"matroska/vp9": true,
"ogg/theora": true
};
var acceptedAudioCodecs = {
"mp3": true,
"vorbis": true
};
var audioOnlyContainers = {
"mp3": true
};
exports.query = function (filename, cb) {
if (!Metadata) {
init();
}
if (!enabled) {
return cb("Raw file playback is not enabled on this server");
}
if (!filename.match(/^https?:\/\//)) {
return cb("Raw file playback is only supported for links accessible via HTTP " +
"or HTTPS");
}
new Metadata(filename, function (meta, err) {
if (err) {
return cb(err);
}
if (isVideo(meta)) {
var video = meta.video;
var codec = video.container + "/" + video.codec;
if (!(codec in acceptedCodecs)) {
return cb("Unsupported video codec " + codec);
}
var data = {
title: meta.title || "Raw Video",
duration: Math.ceil(meta.durationsec),
bitrate: video.bitrate,
codec: codec
};
cb(null, data);
} else if (isAudio(meta)) {
var audio = meta.audio;
var codec = audio.codec;
if (!(codec in acceptedAudioCodecs)) {
return cb("Unsupported audio codec " + codec);
}
var data = {
title: meta.title || "Raw Audio",
duration: Math.ceil(meta.durationsec),
bitrate: audio.bitrate,
codec: codec
};
cb(null, data);
} else if (data.ffmpegErr.match(/Protocol not found/)) {
return cb("This server is unable to load videos over the " +
filename.split(":")[0] + " protocol.");
} else {
return cb("Parsed metadata did not contain a valid video or audio stream. " +
"Either the file is invalid or it has a format unsupported by " +
"this server's version of ffmpeg.");
}
});
};
function isVideo(meta) {
return meta.video &&
meta.video.bitrate > 0 &&
meta.video.container &&
meta.video.codec &&
!(meta.video.container in audioOnlyContainers);
}
function isAudio(meta) {
return meta.audio && meta.audio.bitrate > 0 && meta.audio.codec;
}

View file

@ -8,7 +8,6 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
var http = require("http");
var https = require("https");
var domain = require("domain");
@ -17,6 +16,7 @@ var Media = require("./media");
var CustomEmbedFilter = require("./customembed").filter;
var Server = require("./server");
var Config = require("./config");
var ffmpeg = require("./ffmpeg");
var urlRetrieve = function (transport, options, callback) {
// Catch any errors that crop up along the way of the request
@ -766,6 +766,21 @@ var Getters = {
callback(res, null);
}
});
},
/* ffmpeg for raw files */
fi: function (id, cb) {
ffmpeg.query(id, function (err, data) {
if (err) {
return cb(err);
}
var m = new Media(id, data.title, data.duration, "fi", {
bitrate: data.bitrate,
codec: data.codec
});
cb(null, m);
});
}
};

View file

@ -6,10 +6,7 @@ function Media(id, title, seconds, type, meta) {
}
this.id = id;
this.title = title;
if (this.title.length > 100) {
this.title = this.title.substring(0, 97) + "...";
}
this.setTitle(title);
this.seconds = seconds === "--:--" ? 0 : parseInt(seconds);
this.duration = util.formatTime(seconds);
@ -20,6 +17,13 @@ function Media(id, title, seconds, type, meta) {
}
Media.prototype = {
setTitle: function (title) {
this.title = title;
if (this.title.length > 100) {
this.title = this.title.substring(0, 97) + "...";
}
},
pack: function () {
return {
id: this.id,
@ -31,7 +35,9 @@ Media.prototype = {
object: this.meta.object,
params: this.meta.params,
direct: this.meta.direct,
restricted: this.meta.restricted
restricted: this.meta.restricted,
codec: this.meta.codec,
bitrate: this.meta.bitrate
}
};
},

View file

@ -9,7 +9,7 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const VERSION = "3.1.0";
const VERSION = "3.2.0";
var singleton = null;
var Config = require("./config");

View file

@ -261,6 +261,10 @@
return "http://imgur.com/a/" + id;
case "us":
return "http://ustream.tv/" + id;
case "gd":
return "https://docs.google.com/file/d/" + id;
case "fi":
return id;
default:
return "";
}

View file

@ -2,7 +2,7 @@
"author": "Calvin Montgomery",
"name": "CyTube",
"description": "Online media synchronizer and chat",
"version": "3.1.0",
"version": "3.2.0",
"repository": {
"url": "http://github.com/calzoneman/sync"
},

View file

@ -845,6 +845,14 @@ Callbacks = {
$("#ytapiplayer_wrapper").remove();
}
if (data.type === "fi") {
if (USEROPTS.no_h264 && data.meta.codec === "mov/h264") {
data.forceFlash = true;
}
data.url = data.id;
}
/*
VIMEO SIMULATOR 2014
@ -859,11 +867,10 @@ Callbacks = {
and unwilling to compromise on the issue.
*/
if (NO_VIMEO && data.type === "vi" && data.meta.direct) {
data.type = "fi";
// For browsers that don't support native h264 playback
if (USEROPTS.no_h264) {
data.type = "fl";
} else {
data.type = "rv";
data.forceFlash = true;
}
/* Convert youtube-style quality key to vimeo workaround quality */
@ -895,6 +902,8 @@ Callbacks = {
if (data.type === "rt") {
data.url = data.id;
data.type = "fi";
data.forceFlash = true;
}
if(data.type != PLAYER.type) {

View file

@ -800,98 +800,6 @@ function flashEventHandler(id, ev, data) {
}
}
var FlashPlayer = function (data) {
removeOld();
var self = this;
self.volume = VOLUME;
self.videoId = data.id;
self.videoUrl = data.url;
self.videoLength = data.seconds;
self.paused = false;
self.currentTime = 0;
self.init = function () {
var params = {
allowFullScreen: "true",
allowScriptAccess: "always",
allowNetworking: "all",
wMode: "direct"
};
var flashvars = {
src: encodeURIComponent(self.videoUrl),
// For some reason this param seems not to work
clipStartTime: Math.floor(data.currentTime),
javascriptCallbackFunction: "flashEventHandler",
autoPlay: true,
volume: VOLUME
};
if (self.videoUrl.indexOf("rtmp") === 0) {
flashvars.streamType = "live";
} else {
flashvars.streamType = "recorded";
}
swfobject.embedSWF("/StrobeMediaPlayback.swf",
"ytapiplayer",
VWIDTH, VHEIGHT,
"10.1.0",
null,
flashvars,
params,
{ name: "ytapiplayer" }
);
self.player = $("#ytapiplayer")[0];
};
self.load = function (data) {
self.videoId = data.id;
self.videoUrl = data.url;
self.videoLength = data.seconds;
self.init();
};
self.pause = function () {
if (self.player && self.player.pause)
self.player.pause();
};
self.play = function () {
// Why is it play2? What happened to play1?
if (self.player && self.player.play2)
self.player.play2();
};
self.isPaused = function (cb) {
cb(self.paused);
};
self.getTime = function (cb) {
cb(self.currentTime);
};
self.seek = function (to) {
if (self.player && self.player.seek) {
self.player.seek(Math.floor(to));
}
};
self.getVolume = function (cb) {
cb(self.volume);
};
self.setVolume = function (vol) {
if (self.player && self.player.setVolume)
self.player.setVolume(vol);
};
waitUntilDefined(window, "swfobject", function () {
self.init();
});
};
var JWPlayer = function (data) {
var self = this;
self.videoId = data.id;
@ -1161,12 +1069,100 @@ var GoogleDocsPlayer = function (data) {
self.init(data);
};
function RawVideoPlayer(data) {
function FilePlayer(data) {
var self = this;
self.initFlash = function (data) {
waitUntilDefined(window, "swfobject", function () {
self.volume = VOLUME;
self.videoId = data.id;
self.videoURL = data.url;
self.videoLength = data.seconds;
self.paused = false;
self.currentTime = 0;
var params = {
allowFullScreen: "true",
allowScriptAccess: "always",
allowNetworking: "all",
wMode: "direct"
};
var flashvars = {
src: encodeURIComponent(self.videoURL),
// For some reason this param seems not to work
clipStartTime: Math.floor(data.currentTime),
javascriptCallbackFunction: "flashEventHandler",
autoPlay: true,
volume: VOLUME
};
if (self.videoURL.indexOf("rtmp") === 0) {
flashvars.streamType = "live";
} else {
flashvars.streamType = "recorded";
}
swfobject.embedSWF("/StrobeMediaPlayback.swf",
"ytapiplayer",
VWIDTH, VHEIGHT,
"10.1.0",
null,
flashvars,
params,
{ name: "ytapiplayer" }
);
self.player = $("#ytapiplayer")[0];
resizeStuff();
self.pause = function () {
if (self.player && self.player.pause)
self.player.pause();
};
self.play = function () {
// Why is it play2? What happened to play1?
if (self.player && self.player.play2)
self.player.play2();
};
self.isPaused = function (cb) {
cb(self.paused);
};
self.getTime = function (cb) {
cb(self.currentTime);
};
self.seek = function (to) {
if (self.player && self.player.seek) {
self.player.seek(Math.floor(to));
}
};
self.getVolume = function (cb) {
cb(self.volume);
};
self.setVolume = function (vol) {
if (self.player && self.player.setVolume)
self.player.setVolume(vol);
};
});
};
self.init = function (data) {
self.videoId = data.id;
self.videoURL = data.url;
var video = $("<video/>")
var isAudio = data.meta.codec && data.meta.codec.match(/^mp3$|^vorbis$/);
var video;
if (isAudio) {
video = $("<audio/>");
} else {
video = $("<video/>")
}
video
.attr("src", self.videoURL)
.attr("controls", "controls")
.attr("id", "#ytapiplayer")
@ -1175,16 +1171,22 @@ function RawVideoPlayer(data) {
.html("Your browser does not support HTML5 <code>&lt;video&gt;</code> tags :(");
video.error(function (err) {
setTimeout(function () {
fallbackRaw(data);
console.log("<video> tag failed, falling back to Flash");
self.initFlash(data);
}, 100);
});
removeOld(video);
self.player = video[0];
self.setVolume(VOLUME);
resizeStuff();
};
self.load = function (data) {
if (data.forceFlash) {
self.initFlash(data);
} else {
self.init(data);
}
};
self.pause = function () {
@ -1213,7 +1215,10 @@ function RawVideoPlayer(data) {
self.seek = function (time) {
if (self.player) {
try {
self.player.currentTime = time;
} catch (e) {
}
}
};
@ -1233,10 +1238,13 @@ function RawVideoPlayer(data) {
}
};
if (data.forceFlash) {
self.initFlash(data);
} else {
self.init(data);
}
};
function handleMediaUpdate(data) {
// Don't update if the position is past the video length, but
// make an exception when the video length is 0 seconds
@ -1334,13 +1342,14 @@ var constructors = {
"tw": TwitchTVPlayer,
"jt": JustinTVPlayer,
"us": UstreamPlayer,
"rt": FlashPlayer,
"jw": JWPlayer,
"im": ImgurPlayer,
"cu": CustomPlayer,
"gd": GoogleDocsPlayer,
"rv": RawVideoPlayer,
"fl": FlashPlayer
"rt": FilePlayer,
"rv": FilePlayer,
"fl": FilePlayer,
"fi": FilePlayer
};
function loadMediaPlayer(data) {

View file

@ -340,12 +340,16 @@ function queue(pos, src) {
var link = $("#mediaurl").val();
var data = parseMediaLink(link);
var duration = undefined;
var title = undefined;
if (link.indexOf("jw:") === 0) {
duration = parseInt($("#addfromurl-duration-val").val());
if (duration <= 0 || isNaN(duration)) {
duration = undefined;
}
}
if (data.type === "fi") {
title = $("#addfromurl-title-val").val();
}
if (data.id == null || data.type == null) {
makeAlert("Error", "Failed to parse link. Please check that it is correct",
@ -354,11 +358,13 @@ function queue(pos, src) {
} else {
$("#mediaurl").val("");
$("#addfromurl-duration").remove();
$("#addfromurl-title").remove();
socket.emit("queue", {
id: data.id,
type: data.type,
pos: pos,
duration: duration,
title: title,
temp: $(".add-temp").prop("checked")
});
}
@ -373,7 +379,8 @@ $("#ce_queue_end").click(queue.bind(this, "end", "customembed"));
$("#mediaurl").keyup(function(ev) {
if (ev.keyCode === 13) {
queue("end", "url");
} else if ($("#mediaurl").val().indexOf("jw:") === 0) {
} else {
if ($("#mediaurl").val().indexOf("jw:") === 0) {
var duration = $("#addfromurl-duration");
if (duration.length === 0) {
duration = $("<div/>")
@ -389,6 +396,30 @@ $("#mediaurl").keyup(function(ev) {
} else {
$("#addfromurl-duration").remove();
}
var url = $("#mediaurl").val().split("?")[0];
if (url.match(/^https?:\/\/(.*)?\.(flv|mp4|og[gv]|webm|mp3)$/)) {
var title = $("#addfromurl-title");
if (title.length === 0) {
title = $("<div/>")
.attr("id", "addfromurl-title")
.appendTo($("#addfromurl"));
$("<span/>").text("Title (optional)")
.appendTo(title);
$("<input/>").addClass("form-control")
.attr("type", "text")
.attr("id", "addfromurl-title-val")
.keyup(function (ev) {
if (ev.keyCode === 13) {
queue("end", "url");
}
})
.appendTo($("#addfromurl-title"));
}
} else {
$("#addfromurl-title").remove();
}
}
});
$("#customembed-content").keydown(function(ev) {

View file

@ -58,6 +58,8 @@ function formatURL(data) {
return "http://ustream.tv/" + data.id;
case "gd":
return "https://docs.google.com/file/d/" + data.id;
case "fi":
return data.id;
default:
return "#";
}
@ -1284,6 +1286,24 @@ function parseMediaLink(url) {
};
}
/* Raw file */
var tmp = url.split("?")[0];
if (tmp.match(/^https?:\/\//)) {
if (tmp.match(/\.(mp4|flv|webm|og[gv]|mp3)$/)) {
return {
id: url,
type: "fi"
};
} else {
Callbacks.queueFail({
link: url,
msg: "The file you are attempting to queue does not match the supported " +
"file extensions mp4, flv, webm, ogg, ogv, mp3."
});
throw new Error("ERROR_QUEUE_UNSUPPORTED_EXTENSION");
}
}
return {
id: null,
type: null
@ -1728,6 +1748,7 @@ function genPermissionsEditor() {
makeOption("Queue playlist", "playlistaddlist", standard, CHANNEL.perms.playlistaddlist+"");
makeOption("Queue livestream", "playlistaddlive", standard, CHANNEL.perms.playlistaddlive+"");
makeOption("Embed custom media", "playlistaddcustom", standard, CHANNEL.perms.playlistaddcustom + "");
makeOption("Add raw video file", "playlistaddrawfile", standard, CHANNEL.perms.playlistaddrawfile + "");
makeOption("Exceed maximum media length", "exceedmaxlength", standard, CHANNEL.perms.exceedmaxlength+"");
makeOption("Add nontemporary media", "addnontemp", standard, CHANNEL.perms.addnontemp+"");
makeOption("Temp/untemp playlist item", "settemp", standard, CHANNEL.perms.settemp+"");