diff --git a/config.template.yaml b/config.template.yaml index 1073791b..049cbf8b 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -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 diff --git a/lib/channel/library.js b/lib/channel/library.js index e286e608..ece7a825 100644 --- a/lib/channel/library.js +++ b/lib/channel/library.js @@ -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)); } }); }; diff --git a/lib/channel/permissions.js b/lib/channel/permissions.js index 3820725f..33966674 100644 --- a/lib/channel/permissions.js +++ b/lib/channel/permissions.js @@ -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"); }; diff --git a/lib/channel/playlist.js b/lib/channel/playlist.js index a34d9bd9..fadf0e48 100644 --- a/lib/channel/playlist.js +++ b/lib/channel/playlist.js @@ -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 " + media.meta.codec + " 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); } diff --git a/lib/config.js b/lib/config.js index fbd4ed2a..37d6645c 100644 --- a/lib/config.js +++ b/lib/config.js @@ -104,7 +104,10 @@ var defaults = { "max-items": 4000, "update-interval": 5 }, - "channel-blacklist": [] + "channel-blacklist": [], + ffmpeg: { + enabled: false + } }; /** diff --git a/lib/database/channels.js b/lib/database/channels.js index 1b54da6c..c8e2702b 100644 --- a/lib/database/channels.js +++ b/lib/database/channels.js @@ -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); }, /** diff --git a/lib/database/update.js b/lib/database/update.js index 8cd4706c..8db7b092 100644 --- a/lib/database/update.js +++ b/lib/database/update.js @@ -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); } diff --git a/lib/ffmpeg.js b/lib/ffmpeg.js new file mode 100644 index 00000000..bf4d2329 --- /dev/null +++ b/lib/ffmpeg.js @@ -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; +} diff --git a/lib/get-info.js b/lib/get-info.js index 882ee3b3..282c3667 100644 --- a/lib/get-info.js +++ b/lib/get-info.js @@ -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); + }); } }; diff --git a/lib/media.js b/lib/media.js index 4d56c436..7cfcda81 100644 --- a/lib/media.js +++ b/lib/media.js @@ -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 } }; }, diff --git a/lib/server.js b/lib/server.js index 40fdea96..be530ba2 100644 --- a/lib/server.js +++ b/lib/server.js @@ -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"); diff --git a/lib/utilities.js b/lib/utilities.js index 81848173..c62c2428 100644 --- a/lib/utilities.js +++ b/lib/utilities.js @@ -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 ""; } diff --git a/package.json b/package.json index 7cd1ba7d..0af9bf93 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/www/js/callbacks.js b/www/js/callbacks.js index fcd5c7d6..3d296bbe 100644 --- a/www/js/callbacks.js +++ b/www/js/callbacks.js @@ -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) { diff --git a/www/js/player.js b/www/js/player.js index 12271e9d..58ee605c 100644 --- a/www/js/player.js +++ b/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 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 = $("