From 3f959087af9d3f28397b3ebec0d0cbcb96fa1560 Mon Sep 17 00:00:00 2001 From: Calvin Montgomery Date: Wed, 9 Jul 2014 21:20:14 -0700 Subject: [PATCH 1/7] Initial improvements to playback system --- lib/channel/channel.js | 1 + lib/channel/mediarefresher.js | 93 +++++++++++++++++++++++++++++++++++ lib/channel/playlist.js | 90 ++++++++++----------------------- www/js/player.js | 8 +++ 4 files changed, 128 insertions(+), 64 deletions(-) create mode 100644 lib/channel/mediarefresher.js diff --git a/lib/channel/channel.js b/lib/channel/channel.js index 0f573185..6636ae84 100644 --- a/lib/channel/channel.js +++ b/lib/channel/channel.js @@ -114,6 +114,7 @@ Channel.prototype.initModules = function () { "./opts" : "options", "./library" : "library", "./playlist" : "playlist", + "./mediarefresher": "mediarefresher", "./voteskip" : "voteskip", "./poll" : "poll", "./kickban" : "kickban", diff --git a/lib/channel/mediarefresher.js b/lib/channel/mediarefresher.js new file mode 100644 index 00000000..ec758c7a --- /dev/null +++ b/lib/channel/mediarefresher.js @@ -0,0 +1,93 @@ +var ChannelModule = require("./module"); +var Config = require("../config"); +var InfoGetter = require("../get-info"); + +function MediaRefresherModule(channel) { + ChannelModule.apply(this, arguments); + this._interval = false; + this._media = null; +} + +MediaRefresherModule.prototype = Object.create(ChannelModule.prototype); + +MediaRefresherModule.prototype.onMediaChange = function (data) { + if (this._interval) clearInterval(this._interval); + + this._media = data; + + switch (data.type) { + case "gd": + return this.initGoogleDocs(data); + case "vi": + return this.initVimeo(data); + } +}; + +MediaRefresherModule.prototype.initGoogleDocs = function (data) { + var self = this; + self.refreshGoogleDocs(data, true); + + /* + * Refresh every 55 minutes. + * The expiration is 1 hour, but refresh 5 minutes early to be safe + */ + self._interval = setInterval(function () { + self.refreshGoogleDocs(data, false); + }, 55 * 60 * 1000); +}; + +MediaRefresherModule.prototype.initVimeo = function (data) { + if (!Config.get("vimeo-workaround")) { + return; + } + + var self = this; + self.channel.activeLock.lock(); + InfoGetter.vimeoWorkaround(data.id, function (hack) { + if (self._media === data) { + self.channel.logger.log("[mediarefresher] Refreshed vimeo video with ID " + + data.id); + data.meta.direct = hack; + self.channel.broadcastAll("changeMedia", data.getFullUpdate()); + } + self.channel.activeLock.release(); + }); +}; + +MediaRefresherModule.prototype.refreshGoogleDocs = function (media, update) { + var self = this; + + if (self.dead || self.channel.dead) { + return; + } + + self.channel.activeLock.lock(); + InfoGetter.getMedia(media.id, "gd", function (err, data) { + switch (err) { + case "HTTP 302": + case "Video not found": + case "Private video": + return; + default: + if (err) { + Logger.errlog.log("Google Docs refresh failed for ID " + media.id + + ": " + err); + return self.channel.activeLock.release(); + } + } + + if (media !== self._media) { + return self.channel.activeLock.release(); + } + + self.channel.logger.log("[mediarefresher] Refreshed Google Docs video with ID " + + media.id); + media.meta = data.meta; + if (update) { + self.channel.broadcastAll("changeMedia", data.getFullUpdate()); + } + self.channel.activeLock.release(); + }); +}; + +module.exports = MediaRefresherModule; diff --git a/lib/channel/playlist.js b/lib/channel/playlist.js index bf4a17d7..24b22431 100644 --- a/lib/channel/playlist.js +++ b/lib/channel/playlist.js @@ -977,72 +977,34 @@ PlaylistModule.prototype.startPlayback = function (time) { var media = self.current.media; media.reset(); - var continuePlayback = function () { - if (self.leader != null) { - media.paused = false; - media.currentTime = time || 0; - self.sendChangeMedia(self.channel.users); - self.channel.notifyModules("onMediaChange", self.current.media); - return; - } - - /* Lead-in time of 3 seconds to allow clients to buffer */ - time = time || -3; - media.paused = time < 0; - media.currentTime = time; - - /* Module was already leading, stop the previous timer */ - if (self._leadInterval) { - clearInterval(self._leadInterval); - self._leadInterval = false; - } - + if (self.leader != null) { + media.paused = false; + media.currentTime = time || 0; self.sendChangeMedia(self.channel.users); - self.channel.notifyModules("onMediaChange", self.current.media); - - /* Only start the timer if the media item is not live, i.e. has a duration */ - if (media.seconds > 0) { - self._lastUpdate = Date.now(); - self._leadInterval = setInterval(function() { - self._leadLoop(); - }, 1000); - } - - /* Google Docs autorefresh */ - if (self._gdRefreshTimer) { - clearInterval(self._gdRefreshTimer); - self._gdRefreshTimer = false; - } - - if (media.type === "gd") { - self._gdRefreshTimer = setInterval(self.refreshGoogleDocs.bind(self), 3600000); - if (media.meta.expiration && media.meta.expiration < Date.now() + 3600000) { - setTimeout(self.refreshGoogleDocs.bind(self), media.meta.expiration - Date.now()); - } - } - }; - - if (media.type === "vi" && !media.meta.direct && Config.get("vimeo-workaround")) { - self.channel.activeLock.lock(); - vimeoWorkaround(media.id, function (direct) { - self.channel.activeLock.release(); - if (self.current && self.current.media === media) { - self.current.media.meta.direct = direct; - continuePlayback(); - } - }); + self.channel.notifyModules("onMediaChange", [self.current.media]); return; - } else if (media.type === "gd" && isExpired(media) && !media.meta.failed) { - self.channel.activeLock.lock(); - self.refreshGoogleDocs(function () { - self.channel.activeLock.release(); - if (self.current && self.current.media === media) { - continuePlayback(); - } - }); - return; - } else { - continuePlayback(); + } + + /* Lead-in time of 3 seconds to allow clients to buffer */ + time = time || -3; + media.paused = time < 0; + media.currentTime = time; + + /* Module was already leading, stop the previous timer */ + if (self._leadInterval) { + clearInterval(self._leadInterval); + self._leadInterval = false; + } + + self.sendChangeMedia(self.channel.users); + self.channel.notifyModules("onMediaChange", [self.current.media]); + + /* Only start the timer if the media item is not live, i.e. has a duration */ + if (media.seconds > 0) { + self._lastUpdate = Date.now(); + self._leadInterval = setInterval(function() { + self._leadLoop(); + }, 1000); } } diff --git a/www/js/player.js b/www/js/player.js index 58ee605c..b40d28dc 100644 --- a/www/js/player.js +++ b/www/js/player.js @@ -1002,7 +1002,15 @@ var GoogleDocsPlayer = function (data) { self.videoLength = data.seconds; self.paused = false; var wmode = USEROPTS.wmode_transparent ? "transparent" : "opaque"; + var meta = data.meta; + if (!meta || !meta.object || !meta.params) { + // Reset videoId so that a changeMedia with the appropriate data + // will properly reset the player + self.videoId = ""; + return; + } + self.player = $("", meta.object)[0]; $(self.player).attr("data", meta.object.data); $(self.player).attr("width", VWIDTH) From f36d2b0258aefca091e53bc129bf0124c0c28bdd Mon Sep 17 00:00:00 2001 From: Calvin Montgomery Date: Wed, 9 Jul 2014 21:46:45 -0700 Subject: [PATCH 2/7] Add onPreChangeMedia and improve refreshing --- lib/channel/chat.js | 2 +- lib/channel/drink.js | 2 +- lib/channel/mediarefresher.js | 38 ++++++++++++++++++++++------------- lib/channel/module.js | 9 ++++++++- lib/channel/playlist.js | 38 +++++++++++++++++++++++++---------- 5 files changed, 61 insertions(+), 28 deletions(-) diff --git a/lib/channel/chat.js b/lib/channel/chat.js index 1775012b..fc67e9e7 100644 --- a/lib/channel/chat.js +++ b/lib/channel/chat.js @@ -131,7 +131,7 @@ ChatModule.prototype.handleChatMsg = function (user, data) { } data.meta = meta; - this.channel.checkModules("onUserChat", [user, data], function (err, result) { + this.channel.checkModules("onUserPreChat", [user, data], function (err, result) { if (result === ChannelModule.PASSTHROUGH) { self.processChatMsg(user, data); } diff --git a/lib/channel/drink.js b/lib/channel/drink.js index bb702f43..91959725 100644 --- a/lib/channel/drink.js +++ b/lib/channel/drink.js @@ -11,7 +11,7 @@ DrinkModule.prototype.onUserPostJoin = function (user) { user.socket.emit("drinkCount", this.drinks); }; -DrinkModule.prototype.onUserChat = function (user, data, cb) { +DrinkModule.prototype.onUserPreChat = function (user, data, cb) { var msg = data.msg; var perms = this.channel.modules.permissions; if (msg.match(/^\/d-?[0-9]*/) && perms.canCallDrink(user)) { diff --git a/lib/channel/mediarefresher.js b/lib/channel/mediarefresher.js index ec758c7a..ca1fd022 100644 --- a/lib/channel/mediarefresher.js +++ b/lib/channel/mediarefresher.js @@ -1,6 +1,7 @@ var ChannelModule = require("./module"); var Config = require("../config"); var InfoGetter = require("../get-info"); +var Logger = require("../logger"); function MediaRefresherModule(channel) { ChannelModule.apply(this, arguments); @@ -10,33 +11,39 @@ function MediaRefresherModule(channel) { MediaRefresherModule.prototype = Object.create(ChannelModule.prototype); -MediaRefresherModule.prototype.onMediaChange = function (data) { +MediaRefresherModule.prototype.onPreMediaChange = function (data, cb) { if (this._interval) clearInterval(this._interval); this._media = data; switch (data.type) { case "gd": - return this.initGoogleDocs(data); + return this.initGoogleDocs(data, function () { + cb(null, ChannelModule.PASSTHROUGH); + }); case "vi": - return this.initVimeo(data); + return this.initVimeo(data, function () { + cb(null, ChannelModule.PASSTHROUGH); + }); + default: + return cb(null, ChannelModule.PASSTHROUGH); } }; -MediaRefresherModule.prototype.initGoogleDocs = function (data) { +MediaRefresherModule.prototype.initGoogleDocs = function (data, cb) { var self = this; - self.refreshGoogleDocs(data, true); + self.refreshGoogleDocs(data, cb); /* * Refresh every 55 minutes. * The expiration is 1 hour, but refresh 5 minutes early to be safe */ self._interval = setInterval(function () { - self.refreshGoogleDocs(data, false); + self.refreshGoogleDocs(data); }, 55 * 60 * 1000); }; -MediaRefresherModule.prototype.initVimeo = function (data) { +MediaRefresherModule.prototype.initVimeo = function (data, cb) { if (!Config.get("vimeo-workaround")) { return; } @@ -48,13 +55,14 @@ MediaRefresherModule.prototype.initVimeo = function (data) { self.channel.logger.log("[mediarefresher] Refreshed vimeo video with ID " + data.id); data.meta.direct = hack; - self.channel.broadcastAll("changeMedia", data.getFullUpdate()); } self.channel.activeLock.release(); + + if (cb) cb(); }); }; -MediaRefresherModule.prototype.refreshGoogleDocs = function (media, update) { +MediaRefresherModule.prototype.refreshGoogleDocs = function (media, cb) { var self = this; if (self.dead || self.channel.dead) { @@ -72,21 +80,23 @@ MediaRefresherModule.prototype.refreshGoogleDocs = function (media, update) { if (err) { Logger.errlog.log("Google Docs refresh failed for ID " + media.id + ": " + err); - return self.channel.activeLock.release(); + self.channel.activeLock.release(); + if (cb) cb(); + return; } } if (media !== self._media) { - return self.channel.activeLock.release(); + self.channel.activeLock.release(); + if (cb) cb(); + return; } self.channel.logger.log("[mediarefresher] Refreshed Google Docs video with ID " + media.id); media.meta = data.meta; - if (update) { - self.channel.broadcastAll("changeMedia", data.getFullUpdate()); - } self.channel.activeLock.release(); + if (cb) cb(); }); }; diff --git a/lib/channel/module.js b/lib/channel/module.js index 430efab3..c8c19ec0 100644 --- a/lib/channel/module.js +++ b/lib/channel/module.js @@ -54,7 +54,14 @@ ChannelModule.prototype = { /** * Called when a chatMsg event is received */ - onUserChat: function (user, data, cb) { + onUserPreChat: function (user, data, cb) { + cb(null, ChannelModule.PASSTHROUGH); + }, + + /** + * Called before a new video begins playing + */ + onPreMediaChange: function (data, cb) { cb(null, ChannelModule.PASSTHROUGH); }, diff --git a/lib/channel/playlist.js b/lib/channel/playlist.js index 24b22431..4dcd9197 100644 --- a/lib/channel/playlist.js +++ b/lib/channel/playlist.js @@ -980,8 +980,16 @@ PlaylistModule.prototype.startPlayback = function (time) { if (self.leader != null) { media.paused = false; media.currentTime = time || 0; - self.sendChangeMedia(self.channel.users); - self.channel.notifyModules("onMediaChange", [self.current.media]); + self.channel.checkModules("onPreMediaChange", [self.current.media], + function () { + /* + * onPreMediaChange doesn't care about the callback result. + * Its purpose is to allow modification of playback data before + * users are sent a changeMedia + */ + self.sendChangeMedia(self.channel.users); + } + ); return; } @@ -996,16 +1004,24 @@ PlaylistModule.prototype.startPlayback = function (time) { self._leadInterval = false; } - self.sendChangeMedia(self.channel.users); - self.channel.notifyModules("onMediaChange", [self.current.media]); + self.channel.checkModules("onPreMediaChange", [self.current.media], + function () { + /* + * onPreMediaChange currently doesn't care about the callback result. + * Its purpose is to allow modification of playback data before + * users are sent a changeMedia + */ + self.sendChangeMedia(self.channel.users); - /* Only start the timer if the media item is not live, i.e. has a duration */ - if (media.seconds > 0) { - self._lastUpdate = Date.now(); - self._leadInterval = setInterval(function() { - self._leadLoop(); - }, 1000); - } + /* Only start the timer if the media item is not live, i.e. has a duration */ + if (media.seconds > 0) { + self._lastUpdate = Date.now(); + self._leadInterval = setInterval(function() { + self._leadLoop(); + }, 1000); + } + } + ); } const UPDATE_INTERVAL = Config.get("playlist.update-interval"); From 2d0fe02a195326d6092e9dbc466fa077ec06693a Mon Sep 17 00:00:00 2001 From: Calvin Montgomery Date: Wed, 9 Jul 2014 21:55:49 -0700 Subject: [PATCH 3/7] Move vimeo simulator out of the changemedia callback --- www/js/callbacks.js | 54 +++++---------------------------------------- www/js/util.js | 50 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 48 deletions(-) diff --git a/www/js/callbacks.js b/www/js/callbacks.js index d18af9f6..f495528a 100644 --- a/www/js/callbacks.js +++ b/www/js/callbacks.js @@ -850,16 +850,16 @@ Callbacks = { }); } - if(CHANNEL.opts.allow_voteskip) + if (CHANNEL.opts.allow_voteskip) $("#voteskip").attr("disabled", false); $("#currenttitle").text("Currently Playing: " + data.title); - if(data.type != "sc" && PLAYER.type == "sc") + if (data.type != "sc" && PLAYER.type == "sc") // [](/goddamnitmango) fixSoundcloudShit(); - if(data.type != "jw" && PLAYER.type == "jw") { + if (data.type != "jw" && PLAYER.type == "jw") { // Is it so hard to not mess up my DOM? $("
").attr("id", "ytapiplayer") .insertBefore($("#ytapiplayer_wrapper")); @@ -874,60 +874,18 @@ Callbacks = { data.url = data.id; } - /* - VIMEO SIMULATOR 2014 - - Vimeo decided to block my domain. After repeated emails, they refused to - unblock it. Rather than give in to their demands, there is a serverside - option which extracts direct links to the h264 encoded MP4 video files. - These files can be loaded in a custom player to allow Vimeo playback without - triggering their dumb API domain block. - - It's a little bit hacky, but my only other option is to keep buying new - domains every time one gets blocked. No thanks to Vimeo, who were of no help - 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.forceFlash = true; - } - - /* Convert youtube-style quality key to vimeo workaround quality */ - var q = { - small: "mobile", - medium: "sd", - large: "sd", - hd720: "hd", - hd1080:"hd", - highres: "hd" - }[USEROPTS.default_quality] || "sd"; - - var fallback = { - hd: "sd", - sd: "mobile", - mobile: false - }; - - while (!(q in data.meta.direct) && q != false) { - q = fallback[q]; - } - - if (!q) { - q = "sd"; - } - - data.url = data.meta.direct[q].url; + data = vimeoSimulator2014(data); } + /* RTMP player has been replaced with the general flash player */ if (data.type === "rt") { data.url = data.id; data.type = "fi"; data.forceFlash = true; } - if(data.type != PLAYER.type) { + if (data.type != PLAYER.type) { loadMediaPlayer(data); } diff --git a/www/js/util.js b/www/js/util.js index b1e42eaf..31dc2c67 100644 --- a/www/js/util.js +++ b/www/js/util.js @@ -2674,3 +2674,53 @@ function formatScriptAccessPrefs() { }); }); } + +/* + VIMEO SIMULATOR 2014 + + Vimeo decided to block my domain. After repeated emails, they refused to + unblock it. Rather than give in to their demands, there is a serverside + option which extracts direct links to the h264 encoded MP4 video files. + These files can be loaded in a custom player to allow Vimeo playback without + triggering their dumb API domain block. + + It's a little bit hacky, but my only other option is to keep buying new + domains every time one gets blocked. No thanks to Vimeo, who were of no help + and unwilling to compromise on the issue. +*/ +function vimeoSimulator2014(data) { + /* Vimeo Simulator uses the raw file player */ + data.type = "fi"; + + /* For browsers that don't support native h264 playback */ + if (USEROPTS.no_h264) { + data.forceFlash = true; + } + + /* Convert youtube-style quality key to vimeo workaround quality */ + var q = { + small: "mobile", + medium: "sd", + large: "sd", + hd720: "hd", + hd1080:"hd", + highres: "hd" + }[USEROPTS.default_quality] || "sd"; + + var fallback = { + hd: "sd", + sd: "mobile", + mobile: false + }; + + /* Pick highest quality less than or equal to user's preference from the options */ + while (!(q in data.meta.direct) && q != false) { + q = fallback[q]; + } + if (!q) { + q = "sd"; + } + + data.url = data.meta.direct[q].url; + return data; +} From f92d4bc5d450402c1f5de41f65769a1dccf47ad6 Mon Sep 17 00:00:00 2001 From: Calvin Montgomery Date: Wed, 9 Jul 2014 22:15:14 -0700 Subject: [PATCH 4/7] Fix vimeo with no vimeo-workaround --- lib/channel/mediarefresher.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/channel/mediarefresher.js b/lib/channel/mediarefresher.js index ca1fd022..1288b8c4 100644 --- a/lib/channel/mediarefresher.js +++ b/lib/channel/mediarefresher.js @@ -45,6 +45,7 @@ MediaRefresherModule.prototype.initGoogleDocs = function (data, cb) { MediaRefresherModule.prototype.initVimeo = function (data, cb) { if (!Config.get("vimeo-workaround")) { + if (cb) cb(); return; } From 1c77a248391dbeea3a1bd52325b69dfb6781d207 Mon Sep 17 00:00:00 2001 From: Calvin Montgomery Date: Wed, 9 Jul 2014 22:37:11 -0700 Subject: [PATCH 5/7] Fix activeLock bug --- lib/channel/channel.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/channel/channel.js b/lib/channel/channel.js index 6636ae84..2ea5f70f 100644 --- a/lib/channel/channel.js +++ b/lib/channel/channel.js @@ -282,9 +282,11 @@ Channel.prototype.notifyModules = function (fn, args) { Channel.prototype.joinUser = function (user, data) { var self = this; + self.activeLock.lock(); self.waitFlag(Flags.C_READY, function () { /* User closed the connection before the channel finished loading */ if (user.socket.disconnected) { + self.activeLock.release(); return; } @@ -293,6 +295,7 @@ Channel.prototype.joinUser = function (user, data) { if (err) { Logger.errlog.log("user.refreshAccount failed at Channel.joinUser"); Logger.errlog.log(err.stack); + self.activeLock.release(); return; } @@ -304,6 +307,7 @@ Channel.prototype.joinUser = function (user, data) { function afterAccount() { if (self.dead || user.socket.disconnected) { + if (self.activeLock) self.activeLock.release(); return; } @@ -312,11 +316,11 @@ Channel.prototype.joinUser = function (user, data) { if (user.account.channelRank !== user.account.globalRank) { user.socket.emit("rank", user.account.effectiveRank); } - self.activeLock.lock(); self.acceptUser(user); } else { user.account.channelRank = 0; user.account.effectiveRank = user.account.globalRank; + self.activeLock.release(); } }); } From 0f11615a1fb9c25a70a7f19313cd691ce36acf02 Mon Sep 17 00:00:00 2001 From: Calvin Montgomery Date: Thu, 10 Jul 2014 19:52:16 -0700 Subject: [PATCH 6/7] Increment version number --- lib/channel/mediarefresher.js | 1 + lib/server.js | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/channel/mediarefresher.js b/lib/channel/mediarefresher.js index 1288b8c4..d0f20f29 100644 --- a/lib/channel/mediarefresher.js +++ b/lib/channel/mediarefresher.js @@ -76,6 +76,7 @@ MediaRefresherModule.prototype.refreshGoogleDocs = function (media, cb) { case "HTTP 302": case "Video not found": case "Private video": + self.channel.activeLock.release(); return; default: if (err) { diff --git a/lib/server.js b/lib/server.js index 56890a98..754843c7 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.3.0"; +const VERSION = "3.3.1"; var singleton = null; var Config = require("./config"); diff --git a/package.json b/package.json index 05785221..56efe4cb 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Calvin Montgomery", "name": "CyTube", "description": "Online media synchronizer and chat", - "version": "3.3.0", + "version": "3.3.1", "repository": { "url": "http://github.com/calzoneman/sync" }, From c7ef76c51842f785fc7444ff686c21398d904268 Mon Sep 17 00:00:00 2001 From: Calvin Montgomery Date: Thu, 10 Jul 2014 19:54:26 -0700 Subject: [PATCH 7/7] Minor change --- lib/channel/mediarefresher.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/channel/mediarefresher.js b/lib/channel/mediarefresher.js index d0f20f29..b5759a61 100644 --- a/lib/channel/mediarefresher.js +++ b/lib/channel/mediarefresher.js @@ -77,6 +77,7 @@ MediaRefresherModule.prototype.refreshGoogleDocs = function (media, cb) { case "Video not found": case "Private video": self.channel.activeLock.release(); + if (cb) cb(); return; default: if (err) {