Merge pull request #618 from calzoneman/gdrive-userscript

Implement last resort solution: Google Drive userscript
This commit is contained in:
Calvin Montgomery 2016-08-24 19:14:57 -07:00 committed by GitHub
commit 459ae4dec8
16 changed files with 485 additions and 14 deletions

13
NEWS.md
View file

@ -1,3 +1,16 @@
2016-08-23
==========
A few weeks ago, the previous Google Drive player stopped working. This is
nothing new; Google Drive has consistently broken a few times a year ever since
support for it was added. However, it's becoming increasingly difficult and
complicated to provide good support for Google Drive, so I've made the decision
to phase out the native player and require a userscript for it, in order to
bypass CORS and allow each browser to request the video stream itself.
See [the updated documentation](docs/gdrive-userscript-serveradmins.md) for
details on how to enable this for your users.
2016-04-27
==========

View file

@ -8,6 +8,7 @@ var order = [
'youtube.coffee',
'dailymotion.coffee',
'videojs.coffee',
'gdrive-player.coffee',
'raw-file.coffee',
'soundcloud.coffee',
'embed.coffee',

View file

@ -0,0 +1,24 @@
# Google Drive Userscript Setup
In response to increasing difficulty and complexity of maintaining Google Drive
support, the native player is being phased out in favor of requiring a
userscript to allow each client to fetch the video stream links for themselves.
Users will be prompted with a link to `/google_drive_userscript`, which explains
the situation and instructs how to install the userscript.
As a server admin, you must generate the userscript from the template by using
the following command:
```sh
npm run generate-userscript <site name> <url> [<url>...]
```
The first argument is the site name as it will appear in the userscript title.
The remaining arguments are the URL patterns on which the script will run. For
example, for cytu.be I use:
```sh
npm run generate-userscript CyTube http://cytu.be/r/* https://cytu.be/r/*
```
This will generate `www/js/cytube-google-drive.user.js`.

View file

@ -0,0 +1,210 @@
// ==UserScript==
// @name Google Drive Video Player for {SITENAME}
// @namespace gdcytube
// @description Play Google Drive videos on {SITENAME}
// {INCLUDE_BLOCK}
// @grant unsafeWindow
// @grant GM_xmlhttpRequest
// @connect docs.google.com
// @run-at document-end
// @version 1.1.0
// ==/UserScript==
try {
function debug(message) {
if (!unsafeWindow.enableCyTubeGoogleDriveUserscriptDebug) {
return;
}
try {
unsafeWindow.console.log(message);
} catch (error) {
unsafeWindow.console.error(error);
}
}
var ITAG_QMAP = {
37: 1080,
46: 1080,
22: 720,
45: 720,
59: 480,
44: 480,
35: 480,
18: 360,
43: 360,
34: 360
};
var ITAG_CMAP = {
43: 'video/webm',
44: 'video/webm',
45: 'video/webm',
46: 'video/webm',
18: 'video/mp4',
22: 'video/mp4',
37: 'video/mp4',
59: 'video/mp4',
35: 'video/flv',
34: 'video/flv'
};
function getVideoInfo(id, cb) {
var url = 'https://docs.google.com/file/d/' + id + '/get_video_info';
debug('Fetching ' + url);
GM_xmlhttpRequest({
method: 'GET',
url: url,
onload: function (res) {
try {
debug('Got response ' + res.responseText);
var data = {};
var error;
res.responseText.split('&').forEach(function (kv) {
var pair = kv.split('=');
data[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1]);
});
if (data.status === 'fail') {
error = new Error('Google Docs request failed: ' +
'metadata indicated status=fail');
error.response = res.responseText;
error.reason = 'RESPONSE_STATUS_FAIL';
return cb(error);
}
if (!data.fmt_stream_map) {
error = new Error('Google Docs request failed: ' +
'metadata lookup returned no valid links');
error.response = res.responseText;
error.reason = 'MISSING_LINKS';
return cb(error);
}
data.links = {};
data.fmt_stream_map.split(',').forEach(function (item) {
var pair = item.split('|');
data.links[pair[0]] = pair[1];
});
data.videoMap = mapLinks(data.links);
cb(null, data);
} catch (error) {
unsafeWindow.console.error(error);
}
},
onerror: function () {
var error = new Error('Google Docs request failed: ' +
'metadata lookup HTTP request failed');
error.reason = 'HTTP_ONERROR';
return cb(error);
}
});
}
function mapLinks(links) {
var videos = {
1080: [],
720: [],
480: [],
360: []
};
Object.keys(links).forEach(function (itag) {
itag = parseInt(itag, 10);
if (!ITAG_QMAP.hasOwnProperty(itag)) {
return;
}
videos[ITAG_QMAP[itag]].push({
itag: itag,
contentType: ITAG_CMAP[itag],
link: links[itag]
});
});
return videos;
}
/*
* Greasemonkey 2.0 has this wonderful sandbox that attempts
* to prevent script developers from shooting themselves in
* the foot by removing the trigger from the gun, i.e. it's
* impossible to cross the boundary between the browser JS VM
* and the privileged sandbox that can run GM_xmlhttpRequest().
*
* So in this case, we have to resort to polling a special
* variable to see if getGoogleDriveMetadata needs to be called
* and deliver the result into another special variable that is
* being polled on the browser side.
*/
/*
* Browser side function -- sets gdUserscript.pollID to the
* ID of the Drive video to be queried and polls
* gdUserscript.pollResult for the result.
*/
function getGoogleDriveMetadata_GM(id, callback) {
debug('Setting GD poll ID to ' + id);
unsafeWindow.gdUserscript.pollID = id;
var tries = 0;
var i = setInterval(function () {
if (unsafeWindow.gdUserscript.pollResult) {
debug('Got result');
clearInterval(i);
var result = unsafeWindow.gdUserscript.pollResult;
unsafeWindow.gdUserscript.pollResult = null;
callback(result.error, result.result);
} else if (++tries > 100) {
// Took longer than 10 seconds, give up
clearInterval(i);
}
}, 100);
}
/*
* Sandbox side function -- polls gdUserscript.pollID for
* the ID of a Drive video to be queried, looks up the
* metadata, and stores it in gdUserscript.pollResult
*/
function setupGDPoll() {
unsafeWindow.gdUserscript = cloneInto({}, unsafeWindow);
var pollInterval = setInterval(function () {
if (unsafeWindow.gdUserscript.pollID) {
var id = unsafeWindow.gdUserscript.pollID;
unsafeWindow.gdUserscript.pollID = null;
debug('Polled and got ' + id);
getVideoInfo(id, function (error, data) {
unsafeWindow.gdUserscript.pollResult = cloneInto({
error: error,
result: data
}, unsafeWindow);
});
}
}, 1000);
}
function isRunningTampermonkey() {
try {
return GM_info.scriptHandler === 'Tampermonkey';
} catch (error) {
return false;
}
}
if (isRunningTampermonkey()) {
unsafeWindow.getGoogleDriveMetadata = getVideoInfo;
} else {
debug('Using non-TM polling workaround');
unsafeWindow.getGoogleDriveMetadata = exportFunction(
getGoogleDriveMetadata_GM, unsafeWindow);
setupGDPoll();
}
unsafeWindow.console.log('Initialized userscript Google Drive player');
unsafeWindow.hasDriveUserscript = true;
} catch (error) {
unsafeWindow.console.error(error);
}

View file

@ -0,0 +1,19 @@
var fs = require('fs');
var path = require('path');
var sitename = process.argv[2];
var includes = process.argv.slice(3).map(function (include) {
return '// @include ' + include;
}).join('\n');
var lines = String(fs.readFileSync(
path.resolve(__dirname, 'cytube-google-drive.user.js'))).split('\n');
lines.forEach(function (line) {
if (line.match(/\{INCLUDE_BLOCK\}/)) {
console.log(includes);
} else if (line.match(/\{SITENAME\}/)) {
console.log(line.replace(/\{SITENAME\}/, sitename));
} else {
console.log(line);
}
});

View file

@ -2,7 +2,7 @@
"author": "Calvin Montgomery",
"name": "CyTube",
"description": "Online media synchronizer and chat",
"version": "3.19.0",
"version": "3.20.0",
"repository": {
"url": "http://github.com/calzoneman/sync"
},
@ -47,7 +47,8 @@
"build-player": "$npm_node_execpath build-player.js",
"build-server": "babel -D --source-maps --loose es6.destructuring,es6.forOf --out-dir lib/ src/",
"postinstall": "./postinstall.sh",
"server-dev": "babel -D --watch --source-maps --loose es6.destructuring,es6.forOf --out-dir lib/ src/"
"server-dev": "babel -D --watch --source-maps --loose es6.destructuring,es6.forOf --out-dir lib/ src/",
"generate-userscript": "$npm_node_execpath gdrive-userscript/generate-userscript $@ > www/js/cytube-google-drive.user.js"
},
"devDependencies": {
"coffee-script": "^1.9.2"

View file

@ -0,0 +1,22 @@
window.GoogleDrivePlayer = class GoogleDrivePlayer extends VideoJSPlayer
constructor: (data) ->
if not (this instanceof GoogleDrivePlayer)
return new GoogleDrivePlayer(data)
super(data)
load: (data) ->
if typeof window.getGoogleDriveMetadata is 'function'
window.getGoogleDriveMetadata(data.id, (error, metadata) =>
if error
console.error(error)
alertBox = window.document.createElement('div')
alertBox.className = 'alert alert-danger'
alertBox.textContent = error.message
document.getElementById('ytapiplayer').appendChild(alertBox)
else
data.meta.direct = metadata.videoMap
super(data)
)
else
super(data)

View file

@ -7,6 +7,7 @@ window.GoogleDriveYouTubePlayer = class GoogleDriveYouTubePlayer extends Player
@init(data)
init: (data) ->
window.promptToInstallDriveUserscript()
embed = $('<embed />').attr(
type: 'application/x-shockwave-flash'
src: "https://www.youtube.com/get_player?docid=#{data.id}&ps=docs\
@ -102,3 +103,30 @@ window.GoogleDriveYouTubePlayer = class GoogleDriveYouTubePlayer extends Player
cb(@yt.getVolume() / 100)
else
cb(VOLUME)
window.promptToInstallDriveUserscript = ->
if document.getElementById('prompt-install-drive-userscript')
return
alertBox = document.createElement('div')
alertBox.id = 'prompt-install-drive-userscript'
alertBox.className = 'alert alert-info'
alertBox.innerHTML = """
Due to continual breaking changes making it increasingly difficult to
maintain Google Drive support, you can now install a userscript that
simplifies the code and has better compatibility. In the future, the
old player will be removed."""
alertBox.appendChild(document.createElement('br'))
infoLink = document.createElement('a')
infoLink.className = 'btn btn-info'
infoLink.href = '/google_drive_userscript'
infoLink.textContent = 'Click here for details'
infoLink.target = '_blank'
alertBox.appendChild(infoLink)
closeButton = document.createElement('button')
closeButton.className = 'close pull-right'
closeButton.innerHTML = '&times;'
closeButton.onclick = ->
alertBox.parentNode.removeChild(alertBox)
alertBox.insertBefore(closeButton, alertBox.firstChild)
document.getElementById('videowrap').appendChild(alertBox)

View file

@ -2,7 +2,7 @@ TYPE_MAP =
yt: YouTubePlayer
vi: VimeoPlayer
dm: DailymotionPlayer
gd: GoogleDriveYouTubePlayer
gd: GoogleDrivePlayer
gp: VideoJSPlayer
fi: FilePlayer
jw: FilePlayer
@ -32,8 +32,8 @@ window.loadMediaPlayer = (data) ->
console.error e
else if data.type is 'gd'
try
if data.meta.html5hack
window.PLAYER = new VideoJSPlayer(data)
if data.meta.html5hack or window.hasDriveUserscript
window.PLAYER = new GoogleDrivePlayer(data)
else
window.PLAYER = new GoogleDriveYouTubePlayer(data)
catch e

View file

@ -43,8 +43,7 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
if not (this instanceof VideoJSPlayer)
return new VideoJSPlayer(data)
@setMediaProperties(data)
@loadPlayer(data)
@load(data)
loadPlayer: (data) ->
waitUntilDefined(window, 'videojs', =>
@ -94,6 +93,8 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
@player.src(@sources[@sourceIdx])
else
console.error('Out of sources, video will not play')
if @mediaType is 'gd' and not window.hasDriveUserscript
window.promptToInstallDriveUserscript()
)
@setVolume(VOLUME)
@player.on('ended', ->

View file

@ -36,6 +36,9 @@ function getBaseUrl(res) {
* Renders and serves a pug template
*/
function sendPug(res, view, locals) {
if (!locals) {
locals = {};
}
locals.loggedIn = locals.loggedIn || !!res.user;
locals.loginName = locals.loginName || res.user ? res.user.name : false;
locals.superadmin = locals.superadmin || res.user ? res.user.global_rank >= 255 : false;

View file

@ -0,0 +1,7 @@
import { sendPug } from '../pug';
export default function initialize(app) {
app.get('/google_drive_userscript', (req, res) => {
return sendPug(res, 'google_drive_userscript')
});
}

View file

@ -177,6 +177,7 @@ module.exports = {
require('./account').init(app);
require('./acp').init(app);
require('../google2vtt').attach(app);
require('./routes/google_drive_userscript')(app);
app.use(serveStatic(path.join(__dirname, '..', '..', 'www'), {
maxAge: webConfig.getCacheTTL()
}));

View file

@ -0,0 +1,74 @@
doctype html
html(lang="en")
head
include head
+head()
body
#wrap
nav.navbar.navbar-inverse.navbar-fixed-top(role="navigation")
include nav
+navheader()
#nav-collapsible.collapse.navbar-collapse
ul.nav.navbar-nav
+navdefaultlinks("/google_drive_userscript")
+navloginlogout("/google_drive_userscript")
section#mainpage
.container
.col-md-8.col-md-offset-2
h1 Google Drive Userscript
h2 Why?
p.
Since Google Drive support was launched in early 2014, it has broken
at least 4-5 times, requiring increasing effort to get it working again
and disrupting many channels. This is because there is no official API
for it like there is for YouTube videos, which means support for it
relies on undocumented tricks. In August 2016, the decision was made
to phase out the native support for Google Drive and instead require
users to install a userscript, which allows to bypass certain browser
restrictions and make the code easier, simpler, and less prone to failure
(it could still break due to future Google Drive changes, but is less
likely to be difficult to fix).
h2 How It Works
p.
The userscript is a short script that you can install using a browser
extension such as Greasemonkey or Tampermonkey that runs on the page
and provides additional functionality needed to play Google Drive
videos.
h2 Installation
ul
li
strong Chrome
| &mdash;Install <a href="https://chrome.google.com/webstore/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo" target="_blank">Tampermonkey</a>.
li
strong Firefox
| &mdash;Install <a href="https://addons.mozilla.org/en-US/firefox/addon/tampermonkey/" target="_blank">Tampermonkey</a>
| or <a href="https://addons.mozilla.org/en-US/firefox/addon/greasemonkey/" target="_blank">Greasemonkey</a>.
li
strong Other Browsers
| &mdash;Install the appropriate userscript plugin for your browser.
| Tampermonkey supports many browsers besides Chrome.
p.
Once you have installed the userscript manager addon for your browser,
you can <a href="/js/cytube-google-drive.user.js?v=1.1" target="_blank">
install the userscript</a>. If this link 404s, it means the administrator
of this server hasn't generated it yet.
p.
You can find a guide with screenshots of the installation process
<a href="https://github.com/calzoneman/sync/wiki/Google-Drive-Userscript-Installation-Guide" target="_blank">on GitHub</a>.
include footer
+footer()
script(type="text/javascript").
function showEmail(btn, email, key) {
email = unescape(email);
key = unescape(key);
var dest = new Array(email.length);
for (var i = 0; i < email.length; i++) {
dest[i] = String.fromCharCode(email.charCodeAt(i) ^ key.charCodeAt(i % key.length));
}
email = dest.join("");
$("<a/>").attr("href", "mailto:" + email)
.text(email)
.insertBefore(btn);
$(btn).remove();
}

View file

@ -216,3 +216,5 @@ function eraseCookie(name) {
/* to be implemented in callbacks.js */
function setupCallbacks() { }
window.enableCyTubeGoogleDriveUserscriptDebug = getOrDefault("cytube_drive_debug", false);

View file

@ -1,5 +1,5 @@
(function() {
var CUSTOM_EMBED_WARNING, CustomEmbedPlayer, DEFAULT_ERROR, DailymotionPlayer, EmbedPlayer, FilePlayer, GoogleDriveYouTubePlayer, HITBOX_ERROR, HLSPlayer, HitboxPlayer, ImgurPlayer, LivestreamPlayer, Player, RTMPPlayer, SoundCloudPlayer, TYPE_MAP, TwitchPlayer, USTREAM_ERROR, UstreamPlayer, VideoJSPlayer, VimeoPlayer, YouTubePlayer, codecToMimeType, genParam, sortSources,
var CUSTOM_EMBED_WARNING, CustomEmbedPlayer, DEFAULT_ERROR, DailymotionPlayer, EmbedPlayer, FilePlayer, GoogleDrivePlayer, GoogleDriveYouTubePlayer, HITBOX_ERROR, HLSPlayer, HitboxPlayer, ImgurPlayer, LivestreamPlayer, Player, RTMPPlayer, SoundCloudPlayer, TYPE_MAP, TwitchPlayer, USTREAM_ERROR, UstreamPlayer, VideoJSPlayer, VimeoPlayer, YouTubePlayer, codecToMimeType, genParam, sortSources,
extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
hasProp = {}.hasOwnProperty;
@ -503,8 +503,7 @@
if (!(this instanceof VideoJSPlayer)) {
return new VideoJSPlayer(data);
}
this.setMediaProperties(data);
this.loadPlayer(data);
this.load(data);
}
VideoJSPlayer.prototype.loadPlayer = function(data) {
@ -559,7 +558,10 @@
if (_this.sourceIdx < _this.sources.length) {
return _this.player.src(_this.sources[_this.sourceIdx]);
} else {
return console.error('Out of sources, video will not play');
console.error('Out of sources, video will not play');
if (_this.mediaType === 'gd' && !window.hasDriveUserscript) {
return window.promptToInstallDriveUserscript();
}
}
}
});
@ -666,6 +668,42 @@
})(Player);
window.GoogleDrivePlayer = GoogleDrivePlayer = (function(superClass) {
extend(GoogleDrivePlayer, superClass);
function GoogleDrivePlayer(data) {
if (!(this instanceof GoogleDrivePlayer)) {
return new GoogleDrivePlayer(data);
}
GoogleDrivePlayer.__super__.constructor.call(this, data);
}
GoogleDrivePlayer.prototype.load = function(data) {
if (typeof window.getGoogleDriveMetadata === 'function') {
return window.getGoogleDriveMetadata(data.id, (function(_this) {
return function(error, metadata) {
var alertBox;
if (error) {
console.error(error);
alertBox = window.document.createElement('div');
alertBox.className = 'alert alert-danger';
alertBox.textContent = error.message;
return document.getElementById('ytapiplayer').appendChild(alertBox);
} else {
data.meta.direct = metadata.videoMap;
return GoogleDrivePlayer.__super__.load.call(_this, data);
}
};
})(this));
} else {
return GoogleDrivePlayer.__super__.load.call(this, data);
}
};
return GoogleDrivePlayer;
})(VideoJSPlayer);
codecToMimeType = function(codec) {
switch (codec) {
case 'mov/h264':
@ -1144,6 +1182,7 @@
GoogleDriveYouTubePlayer.prototype.init = function(data) {
var embed;
window.promptToInstallDriveUserscript();
embed = $('<embed />').attr({
type: 'application/x-shockwave-flash',
src: "https://www.youtube.com/get_player?docid=" + data.id + "&ps=docs&partnerid=30&enablejsapi=1&cc_load_policy=1&auth_timeout=86400000000",
@ -1273,6 +1312,32 @@
})(Player);
window.promptToInstallDriveUserscript = function() {
var alertBox, closeButton, infoLink;
if (document.getElementById('prompt-install-drive-userscript')) {
return;
}
alertBox = document.createElement('div');
alertBox.id = 'prompt-install-drive-userscript';
alertBox.className = 'alert alert-info';
alertBox.innerHTML = "Due to continual breaking changes making it increasingly difficult to\nmaintain Google Drive support, you can now install a userscript that\nsimplifies the code and has better compatibility. In the future, the\nold player will be removed.";
alertBox.appendChild(document.createElement('br'));
infoLink = document.createElement('a');
infoLink.className = 'btn btn-info';
infoLink.href = '/google_drive_userscript';
infoLink.textContent = 'Click here for details';
infoLink.target = '_blank';
alertBox.appendChild(infoLink);
closeButton = document.createElement('button');
closeButton.className = 'close pull-right';
closeButton.innerHTML = '&times;';
closeButton.onclick = function() {
return alertBox.parentNode.removeChild(alertBox);
};
alertBox.insertBefore(closeButton, alertBox.firstChild);
return document.getElementById('videowrap').appendChild(alertBox);
};
window.HLSPlayer = HLSPlayer = (function(superClass) {
extend(HLSPlayer, superClass);
@ -1308,7 +1373,7 @@
yt: YouTubePlayer,
vi: VimeoPlayer,
dm: DailymotionPlayer,
gd: GoogleDriveYouTubePlayer,
gd: GoogleDrivePlayer,
gp: VideoJSPlayer,
fi: FilePlayer,
jw: FilePlayer,
@ -1344,8 +1409,8 @@
}
} else if (data.type === 'gd') {
try {
if (data.meta.html5hack) {
return window.PLAYER = new VideoJSPlayer(data);
if (data.meta.html5hack || window.hasDriveUserscript) {
return window.PLAYER = new GoogleDrivePlayer(data);
} else {
return window.PLAYER = new GoogleDriveYouTubePlayer(data);
}