Add raw video/audio playback with ffmpeg
This commit is contained in:
commit
02771e6623
|
|
@ -175,3 +175,10 @@ aggressive-gc: false
|
||||||
# Allows you to blacklist certain channels. Users will be automatically kicked
|
# Allows you to blacklist certain channels. Users will be automatically kicked
|
||||||
# upon trying to join one.
|
# upon trying to join one.
|
||||||
channel-blacklist: []
|
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
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ LibraryModule.prototype.getItem = function (id, cb) {
|
||||||
if (err) {
|
if (err) {
|
||||||
cb(err, null);
|
cb(err, null);
|
||||||
} else {
|
} 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));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ const DEFAULT_PERMISSIONS = {
|
||||||
oplaylistjump: 1.5,
|
oplaylistjump: 1.5,
|
||||||
oplaylistaddlist: 1.5,
|
oplaylistaddlist: 1.5,
|
||||||
playlistaddcustom: 3, // Add custom embed to the playlist
|
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
|
playlistaddlive: 1.5, // Add a livestream to the playlist
|
||||||
exceedmaxlength: 2, // Add a video longer than the maximum length set
|
exceedmaxlength: 2, // Add a video longer than the maximum length set
|
||||||
addnontemp: 2, // Add a permanent video to the playlist
|
addnontemp: 2, // Add a permanent video to the playlist
|
||||||
|
|
@ -195,6 +196,10 @@ PermissionsModule.prototype.canAddCustom = function (account) {
|
||||||
return this.hasPermission(account, "playlistaddcustom");
|
return this.hasPermission(account, "playlistaddcustom");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
PermissionsModule.prototype.canAddRawFile = function (account) {
|
||||||
|
return this.hasPermission(account, "playlistaddrawfile");
|
||||||
|
};
|
||||||
|
|
||||||
PermissionsModule.prototype.canMoveVideo = function (account) {
|
PermissionsModule.prototype.canMoveVideo = function (account) {
|
||||||
return this.hasPermission(account, "playlistmove");
|
return this.hasPermission(account, "playlistmove");
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -110,9 +110,8 @@ PlaylistModule.prototype.load = function (data) {
|
||||||
var i = 0;
|
var i = 0;
|
||||||
playlist.pos = parseInt(playlist.pos);
|
playlist.pos = parseInt(playlist.pos);
|
||||||
playlist.pl.forEach(function (item) {
|
playlist.pl.forEach(function (item) {
|
||||||
/* Backwards compatibility */
|
|
||||||
var m = new Media(item.media.id, item.media.title, item.media.seconds,
|
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, {
|
var newitem = new PlaylistItem(m, {
|
||||||
uid: self._nextuid++,
|
uid: self._nextuid++,
|
||||||
temp: item.temp,
|
temp: item.temp,
|
||||||
|
|
@ -134,6 +133,7 @@ PlaylistModule.prototype.load = function (data) {
|
||||||
|
|
||||||
PlaylistModule.prototype.save = function (data) {
|
PlaylistModule.prototype.save = function (data) {
|
||||||
var arr = this.items.toArray().map(function (item) {
|
var arr = this.items.toArray().map(function (item) {
|
||||||
|
/* Clear Google Docs and Vimeo meta */
|
||||||
if (item.media && item.media.meta) {
|
if (item.media && item.media.meta) {
|
||||||
delete item.media.meta.object;
|
delete item.media.meta.object;
|
||||||
delete item.media.meta.params;
|
delete item.media.meta.params;
|
||||||
|
|
@ -313,8 +313,11 @@ PlaylistModule.prototype.handleQueue = function (user, data) {
|
||||||
return;
|
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;
|
data.title = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,6 +351,12 @@ PlaylistModule.prototype.handleQueue = function (user, data) {
|
||||||
link: link
|
link: link
|
||||||
});
|
});
|
||||||
return;
|
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);
|
var temp = data.temp || !perms.canAddNonTemp(user);
|
||||||
|
|
@ -397,6 +406,7 @@ PlaylistModule.prototype.handleQueue = function (user, data) {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
link: link,
|
link: link,
|
||||||
temp: temp,
|
temp: temp,
|
||||||
|
shouldAddToLibrary: !temp,
|
||||||
queueby: queueby,
|
queueby: queueby,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
maxlength: maxlength
|
maxlength: maxlength
|
||||||
|
|
@ -430,6 +440,8 @@ PlaylistModule.prototype.queueStandard = function (user, data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item !== null) {
|
if (item !== null) {
|
||||||
|
/* Don't re-cache data we got from the library */
|
||||||
|
data.shouldAddToLibrary = false;
|
||||||
self._addItem(item, data, user, function () {
|
self._addItem(item, data, user, function () {
|
||||||
lock.release();
|
lock.release();
|
||||||
self.channel.activeLock.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, {
|
var item = new PlaylistItem(media, {
|
||||||
uid: self._nextuid++,
|
uid: self._nextuid++,
|
||||||
temp: data.temp,
|
temp: data.temp,
|
||||||
queueby: data.queueby
|
queueby: data.queueby
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.title && media.type === "cu") {
|
if (data.title && (media.type === "cu" || media.type === "fi")) {
|
||||||
media.title = data.title;
|
media.setTitle(data.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
var success = function () {
|
var success = function () {
|
||||||
|
|
@ -893,7 +924,7 @@ PlaylistModule.prototype._addItem = function (media, data, user, cb) {
|
||||||
u.socket.emit("setPlaylistMeta", self.meta);
|
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) {
|
if (self.channel.modules.library) {
|
||||||
self.channel.modules.library.cacheMedia(media);
|
self.channel.modules.library.cacheMedia(media);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,10 @@ var defaults = {
|
||||||
"max-items": 4000,
|
"max-items": 4000,
|
||||||
"update-interval": 5
|
"update-interval": 5
|
||||||
},
|
},
|
||||||
"channel-blacklist": []
|
"channel-blacklist": [],
|
||||||
|
ffmpeg: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -440,9 +440,14 @@ module.exports = {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
db.query("INSERT INTO `chan_" + chan + "_library` (id, title, seconds, type) " +
|
var meta = JSON.stringify({
|
||||||
"VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE id=id",
|
bitrate: media.meta.bitrate,
|
||||||
[media.id, media.title, media.seconds, media.type], callback);
|
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);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
var db = require("../database");
|
var db = require("../database");
|
||||||
var Logger = require("../logger");
|
var Logger = require("../logger");
|
||||||
|
var Q = require("q");
|
||||||
|
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 2;
|
||||||
|
|
||||||
module.exports.checkVersion = function () {
|
module.exports.checkVersion = function () {
|
||||||
db.query("SELECT `key`,`value` FROM `meta` WHERE `key`=?", ["db_version"], function (err, rows) {
|
db.query("SELECT `key`,`value` FROM `meta` WHERE `key`=?", ["db_version"], function (err, rows) {
|
||||||
|
|
@ -18,6 +19,9 @@ module.exports.checkVersion = function () {
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
var v = parseInt(rows[0].value);
|
var v = parseInt(rows[0].value);
|
||||||
|
if (v >= DB_VERSION) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
var next = function () {
|
var next = function () {
|
||||||
if (v < DB_VERSION) {
|
if (v < DB_VERSION) {
|
||||||
update(v++, next);
|
update(v++, next);
|
||||||
|
|
@ -32,5 +36,33 @@ module.exports.checkVersion = function () {
|
||||||
};
|
};
|
||||||
|
|
||||||
function update(version, cb) {
|
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
108
lib/ffmpeg.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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.
|
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 http = require("http");
|
||||||
var https = require("https");
|
var https = require("https");
|
||||||
var domain = require("domain");
|
var domain = require("domain");
|
||||||
|
|
@ -17,6 +16,7 @@ var Media = require("./media");
|
||||||
var CustomEmbedFilter = require("./customembed").filter;
|
var CustomEmbedFilter = require("./customembed").filter;
|
||||||
var Server = require("./server");
|
var Server = require("./server");
|
||||||
var Config = require("./config");
|
var Config = require("./config");
|
||||||
|
var ffmpeg = require("./ffmpeg");
|
||||||
|
|
||||||
var urlRetrieve = function (transport, options, callback) {
|
var urlRetrieve = function (transport, options, callback) {
|
||||||
// Catch any errors that crop up along the way of the request
|
// Catch any errors that crop up along the way of the request
|
||||||
|
|
@ -766,6 +766,21 @@ var Getters = {
|
||||||
callback(res, null);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
16
lib/media.js
16
lib/media.js
|
|
@ -6,10 +6,7 @@ function Media(id, title, seconds, type, meta) {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.title = title;
|
this.setTitle(title);
|
||||||
if (this.title.length > 100) {
|
|
||||||
this.title = this.title.substring(0, 97) + "...";
|
|
||||||
}
|
|
||||||
|
|
||||||
this.seconds = seconds === "--:--" ? 0 : parseInt(seconds);
|
this.seconds = seconds === "--:--" ? 0 : parseInt(seconds);
|
||||||
this.duration = util.formatTime(seconds);
|
this.duration = util.formatTime(seconds);
|
||||||
|
|
@ -20,6 +17,13 @@ function Media(id, title, seconds, type, meta) {
|
||||||
}
|
}
|
||||||
|
|
||||||
Media.prototype = {
|
Media.prototype = {
|
||||||
|
setTitle: function (title) {
|
||||||
|
this.title = title;
|
||||||
|
if (this.title.length > 100) {
|
||||||
|
this.title = this.title.substring(0, 97) + "...";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
pack: function () {
|
pack: function () {
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
|
|
@ -31,7 +35,9 @@ Media.prototype = {
|
||||||
object: this.meta.object,
|
object: this.meta.object,
|
||||||
params: this.meta.params,
|
params: this.meta.params,
|
||||||
direct: this.meta.direct,
|
direct: this.meta.direct,
|
||||||
restricted: this.meta.restricted
|
restricted: this.meta.restricted,
|
||||||
|
codec: this.meta.codec,
|
||||||
|
bitrate: this.meta.bitrate
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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 singleton = null;
|
||||||
var Config = require("./config");
|
var Config = require("./config");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,10 @@
|
||||||
return "http://imgur.com/a/" + id;
|
return "http://imgur.com/a/" + id;
|
||||||
case "us":
|
case "us":
|
||||||
return "http://ustream.tv/" + id;
|
return "http://ustream.tv/" + id;
|
||||||
|
case "gd":
|
||||||
|
return "https://docs.google.com/file/d/" + id;
|
||||||
|
case "fi":
|
||||||
|
return id;
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"author": "Calvin Montgomery",
|
"author": "Calvin Montgomery",
|
||||||
"name": "CyTube",
|
"name": "CyTube",
|
||||||
"description": "Online media synchronizer and chat",
|
"description": "Online media synchronizer and chat",
|
||||||
"version": "3.1.0",
|
"version": "3.2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"url": "http://github.com/calzoneman/sync"
|
"url": "http://github.com/calzoneman/sync"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -845,6 +845,14 @@ Callbacks = {
|
||||||
$("#ytapiplayer_wrapper").remove();
|
$("#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
|
VIMEO SIMULATOR 2014
|
||||||
|
|
||||||
|
|
@ -859,11 +867,10 @@ Callbacks = {
|
||||||
and unwilling to compromise on the issue.
|
and unwilling to compromise on the issue.
|
||||||
*/
|
*/
|
||||||
if (NO_VIMEO && data.type === "vi" && data.meta.direct) {
|
if (NO_VIMEO && data.type === "vi" && data.meta.direct) {
|
||||||
|
data.type = "fi";
|
||||||
// For browsers that don't support native h264 playback
|
// For browsers that don't support native h264 playback
|
||||||
if (USEROPTS.no_h264) {
|
if (USEROPTS.no_h264) {
|
||||||
data.type = "fl";
|
data.forceFlash = true;
|
||||||
} else {
|
|
||||||
data.type = "rv";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Convert youtube-style quality key to vimeo workaround quality */
|
/* Convert youtube-style quality key to vimeo workaround quality */
|
||||||
|
|
@ -895,6 +902,8 @@ Callbacks = {
|
||||||
|
|
||||||
if (data.type === "rt") {
|
if (data.type === "rt") {
|
||||||
data.url = data.id;
|
data.url = data.id;
|
||||||
|
data.type = "fi";
|
||||||
|
data.forceFlash = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(data.type != PLAYER.type) {
|
if(data.type != PLAYER.type) {
|
||||||
|
|
|
||||||
207
www/js/player.js
207
www/js/player.js
|
|
@ -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 JWPlayer = function (data) {
|
||||||
var self = this;
|
var self = this;
|
||||||
self.videoId = data.id;
|
self.videoId = data.id;
|
||||||
|
|
@ -1161,12 +1069,100 @@ var GoogleDocsPlayer = function (data) {
|
||||||
self.init(data);
|
self.init(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
function RawVideoPlayer(data) {
|
function FilePlayer(data) {
|
||||||
var self = this;
|
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.init = function (data) {
|
||||||
self.videoId = data.id;
|
self.videoId = data.id;
|
||||||
self.videoURL = data.url;
|
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("src", self.videoURL)
|
||||||
.attr("controls", "controls")
|
.attr("controls", "controls")
|
||||||
.attr("id", "#ytapiplayer")
|
.attr("id", "#ytapiplayer")
|
||||||
|
|
@ -1175,16 +1171,22 @@ function RawVideoPlayer(data) {
|
||||||
.html("Your browser does not support HTML5 <code><video></code> tags :(");
|
.html("Your browser does not support HTML5 <code><video></code> tags :(");
|
||||||
video.error(function (err) {
|
video.error(function (err) {
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
fallbackRaw(data);
|
console.log("<video> tag failed, falling back to Flash");
|
||||||
|
self.initFlash(data);
|
||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
removeOld(video);
|
removeOld(video);
|
||||||
self.player = video[0];
|
self.player = video[0];
|
||||||
self.setVolume(VOLUME);
|
self.setVolume(VOLUME);
|
||||||
|
resizeStuff();
|
||||||
};
|
};
|
||||||
|
|
||||||
self.load = function (data) {
|
self.load = function (data) {
|
||||||
|
if (data.forceFlash) {
|
||||||
|
self.initFlash(data);
|
||||||
|
} else {
|
||||||
self.init(data);
|
self.init(data);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.pause = function () {
|
self.pause = function () {
|
||||||
|
|
@ -1213,7 +1215,10 @@ function RawVideoPlayer(data) {
|
||||||
|
|
||||||
self.seek = function (time) {
|
self.seek = function (time) {
|
||||||
if (self.player) {
|
if (self.player) {
|
||||||
|
try {
|
||||||
self.player.currentTime = time;
|
self.player.currentTime = time;
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1233,10 +1238,13 @@ function RawVideoPlayer(data) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (data.forceFlash) {
|
||||||
|
self.initFlash(data);
|
||||||
|
} else {
|
||||||
self.init(data);
|
self.init(data);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
function handleMediaUpdate(data) {
|
function handleMediaUpdate(data) {
|
||||||
// Don't update if the position is past the video length, but
|
// Don't update if the position is past the video length, but
|
||||||
// make an exception when the video length is 0 seconds
|
// make an exception when the video length is 0 seconds
|
||||||
|
|
@ -1334,13 +1342,14 @@ var constructors = {
|
||||||
"tw": TwitchTVPlayer,
|
"tw": TwitchTVPlayer,
|
||||||
"jt": JustinTVPlayer,
|
"jt": JustinTVPlayer,
|
||||||
"us": UstreamPlayer,
|
"us": UstreamPlayer,
|
||||||
"rt": FlashPlayer,
|
|
||||||
"jw": JWPlayer,
|
"jw": JWPlayer,
|
||||||
"im": ImgurPlayer,
|
"im": ImgurPlayer,
|
||||||
"cu": CustomPlayer,
|
"cu": CustomPlayer,
|
||||||
"gd": GoogleDocsPlayer,
|
"gd": GoogleDocsPlayer,
|
||||||
"rv": RawVideoPlayer,
|
"rt": FilePlayer,
|
||||||
"fl": FlashPlayer
|
"rv": FilePlayer,
|
||||||
|
"fl": FilePlayer,
|
||||||
|
"fi": FilePlayer
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadMediaPlayer(data) {
|
function loadMediaPlayer(data) {
|
||||||
|
|
|
||||||
33
www/js/ui.js
33
www/js/ui.js
|
|
@ -340,12 +340,16 @@ function queue(pos, src) {
|
||||||
var link = $("#mediaurl").val();
|
var link = $("#mediaurl").val();
|
||||||
var data = parseMediaLink(link);
|
var data = parseMediaLink(link);
|
||||||
var duration = undefined;
|
var duration = undefined;
|
||||||
|
var title = undefined;
|
||||||
if (link.indexOf("jw:") === 0) {
|
if (link.indexOf("jw:") === 0) {
|
||||||
duration = parseInt($("#addfromurl-duration-val").val());
|
duration = parseInt($("#addfromurl-duration-val").val());
|
||||||
if (duration <= 0 || isNaN(duration)) {
|
if (duration <= 0 || isNaN(duration)) {
|
||||||
duration = undefined;
|
duration = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (data.type === "fi") {
|
||||||
|
title = $("#addfromurl-title-val").val();
|
||||||
|
}
|
||||||
|
|
||||||
if (data.id == null || data.type == null) {
|
if (data.id == null || data.type == null) {
|
||||||
makeAlert("Error", "Failed to parse link. Please check that it is correct",
|
makeAlert("Error", "Failed to parse link. Please check that it is correct",
|
||||||
|
|
@ -354,11 +358,13 @@ function queue(pos, src) {
|
||||||
} else {
|
} else {
|
||||||
$("#mediaurl").val("");
|
$("#mediaurl").val("");
|
||||||
$("#addfromurl-duration").remove();
|
$("#addfromurl-duration").remove();
|
||||||
|
$("#addfromurl-title").remove();
|
||||||
socket.emit("queue", {
|
socket.emit("queue", {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
type: data.type,
|
type: data.type,
|
||||||
pos: pos,
|
pos: pos,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
|
title: title,
|
||||||
temp: $(".add-temp").prop("checked")
|
temp: $(".add-temp").prop("checked")
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -373,7 +379,8 @@ $("#ce_queue_end").click(queue.bind(this, "end", "customembed"));
|
||||||
$("#mediaurl").keyup(function(ev) {
|
$("#mediaurl").keyup(function(ev) {
|
||||||
if (ev.keyCode === 13) {
|
if (ev.keyCode === 13) {
|
||||||
queue("end", "url");
|
queue("end", "url");
|
||||||
} else if ($("#mediaurl").val().indexOf("jw:") === 0) {
|
} else {
|
||||||
|
if ($("#mediaurl").val().indexOf("jw:") === 0) {
|
||||||
var duration = $("#addfromurl-duration");
|
var duration = $("#addfromurl-duration");
|
||||||
if (duration.length === 0) {
|
if (duration.length === 0) {
|
||||||
duration = $("<div/>")
|
duration = $("<div/>")
|
||||||
|
|
@ -389,6 +396,30 @@ $("#mediaurl").keyup(function(ev) {
|
||||||
} else {
|
} else {
|
||||||
$("#addfromurl-duration").remove();
|
$("#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) {
|
$("#customembed-content").keydown(function(ev) {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ function formatURL(data) {
|
||||||
return "http://ustream.tv/" + data.id;
|
return "http://ustream.tv/" + data.id;
|
||||||
case "gd":
|
case "gd":
|
||||||
return "https://docs.google.com/file/d/" + data.id;
|
return "https://docs.google.com/file/d/" + data.id;
|
||||||
|
case "fi":
|
||||||
|
return data.id;
|
||||||
default:
|
default:
|
||||||
return "#";
|
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 {
|
return {
|
||||||
id: null,
|
id: null,
|
||||||
type: null
|
type: null
|
||||||
|
|
@ -1728,6 +1748,7 @@ function genPermissionsEditor() {
|
||||||
makeOption("Queue playlist", "playlistaddlist", standard, CHANNEL.perms.playlistaddlist+"");
|
makeOption("Queue playlist", "playlistaddlist", standard, CHANNEL.perms.playlistaddlist+"");
|
||||||
makeOption("Queue livestream", "playlistaddlive", standard, CHANNEL.perms.playlistaddlive+"");
|
makeOption("Queue livestream", "playlistaddlive", standard, CHANNEL.perms.playlistaddlive+"");
|
||||||
makeOption("Embed custom media", "playlistaddcustom", standard, CHANNEL.perms.playlistaddcustom + "");
|
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("Exceed maximum media length", "exceedmaxlength", standard, CHANNEL.perms.exceedmaxlength+"");
|
||||||
makeOption("Add nontemporary media", "addnontemp", standard, CHANNEL.perms.addnontemp+"");
|
makeOption("Add nontemporary media", "addnontemp", standard, CHANNEL.perms.addnontemp+"");
|
||||||
makeOption("Temp/untemp playlist item", "settemp", standard, CHANNEL.perms.settemp+"");
|
makeOption("Temp/untemp playlist item", "settemp", standard, CHANNEL.perms.settemp+"");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue