diff --git a/.gitignore b/.gitignore
index 96ead4f0..6bf31e92 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ node_modules
*.key
torlist
www/cache
+google-drive-subtitles
diff --git a/NEWS.md b/NEWS.md
index 5ea967f2..8a4874b0 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,3 +1,11 @@
+2015-07-25
+==========
+
+ * CyTube now supports subtitles for Google Drive videos. In order to take
+ advantage of this, you must upgrade mediaquery by running `npm install
+ cytube/mediaquery`. Subtitles are cached in the google-drive-subtitles
+ folder.
+
2015-07-07
==========
diff --git a/docs/google-drive-subtitles.md b/docs/google-drive-subtitles.md
new file mode 100644
index 00000000..21bcd400
--- /dev/null
+++ b/docs/google-drive-subtitles.md
@@ -0,0 +1,24 @@
+Adding subtitles to Google Drive
+================================
+
+ 1. Upload your video to Google Drive
+ 2. Right click the video in Google Drive and click Manage caption tracks
+ 3. Click Add new captions or transcripts
+ 4. Upload a supported subtitle file
+ * I have verified that Google Drive will accept .srt and .vtt subtitles. It
+ might accept others as well, but I have not tested them.
+
+Once you have uploaded your subtitles, they should be available the next time
+the video is refreshed by CyTube (either restart it or delete the playlist item
+and add it again). On the video you should see a speech bubble icon in the
+controls, which will pop up a menu of available subtitle tracks.
+
+## Limitations ##
+
+ * Google Drive converts the subtitles you upload into a custom format which
+ loses some information from the original captions. For example, annotations
+ for who is speaking are not preserved.
+ * As far as I know, Google Drive is not able to automatically detect when
+ subtitle tracks are embedded within the video file. You must upload the
+ subtitles separately (there are plenty of tools to extract
+ captions/subtitles from MKV and MP4 files).
diff --git a/lib/google2vtt.js b/lib/google2vtt.js
new file mode 100644
index 00000000..95d154a0
--- /dev/null
+++ b/lib/google2vtt.js
@@ -0,0 +1,173 @@
+var cheerio = require('cheerio');
+var https = require('https');
+var fs = require('fs');
+var path = require('path');
+var querystring = require('querystring');
+var crypto = require('crypto');
+
+var Logger = require('./logger');
+
+function md5(input) {
+ var hash = crypto.createHash('md5');
+ hash.update(input);
+ return hash.digest('base64').replace(/\//g, ' ')
+ .replace(/\+/g, '#')
+ .replace(/=/g, '-');
+}
+
+var slice = Array.prototype.slice;
+var subtitleDir = path.resolve(__dirname, '..', 'google-drive-subtitles');
+var ONE_HOUR = 60 * 60 * 1000;
+var ONE_DAY = 24 * ONE_HOUR;
+
+function padZeros(n) {
+ n = n.toString();
+ if (n.length < 2) n = '0' + n;
+ return n;
+}
+
+function formatTime(time) {
+ var hours = Math.floor(time / 3600);
+ time = time % 3600;
+ var minutes = Math.floor(time / 60);
+ time = time % 60;
+ var seconds = Math.floor(time);
+ var ms = time - seconds;
+
+ var list = [minutes, seconds];
+ if (hours) {
+ list.unshift(hours);
+ }
+
+ return list.map(padZeros).join(':') + ms.toFixed(3).substring(1);
+}
+
+function fixText(text) {
+ return text.replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/-->/g, '-->');
+}
+
+exports.convert = function convertSubtitles(subtitles) {
+ var $ = cheerio.load(subtitles, { xmlMode: true });
+ var lines = slice.call($('transcript text').map(function (index, elem) {
+ var start = parseFloat(elem.attribs.start);
+ var end = start + parseFloat(elem.attribs.dur);
+ var text;
+ if (elem.children.length) {
+ text = elem.children[0].data;
+ } else {
+ text = '';
+ }
+
+ var line = formatTime(start) + ' --> ' + formatTime(end);
+ line += '\n' + fixText(text) + '\n';
+ return line;
+ }));
+
+ return 'WEBVTT\n\n' + lines.join('\n');
+};
+
+exports.attach = function setupRoutes(app) {
+ app.get('/gdvtt/:id/:lang/(:name)?.vtt', handleGetSubtitles);
+};
+
+function handleGetSubtitles(req, res) {
+ var id = req.params.id;
+ var lang = req.params.lang;
+ var name = req.params.name || '';
+ var vid = req.query.vid;
+ if (typeof vid !== 'string' || typeof id !== 'string' || typeof lang !== 'string') {
+ return res.sendStatus(400);
+ }
+ var file = [id, lang, md5(name)].join('_') + '.vtt';
+ var fileAbsolute = path.join(subtitleDir, file);
+
+ fs.exists(fileAbsolute, function (exists) {
+ if (exists) {
+ res.sendFile(file, { root: subtitleDir });
+ } else {
+ fetchSubtitles(id, lang, name, vid, fileAbsolute, function (err) {
+ if (err) {
+ Logger.errlog.log(err.stack);
+ return res.sendStatus(500);
+ }
+
+ res.sendFile(file, { root: subtitleDir });
+ });
+ }
+ });
+}
+
+function fetchSubtitles(id, lang, name, vid, file, cb) {
+ var query = {
+ id: id,
+ v: id,
+ vid: vid,
+ lang: lang,
+ name: name,
+ type: 'track',
+ kind: undefined
+ };
+
+ var url = 'https://drive.google.com/timedtext?' + querystring.stringify(query);
+ https.get(url, function (res) {
+ if (res.statusCode !== 200) {
+ return cb(new Error(res.statusMessage));
+ }
+
+ var buf = '';
+ res.setEncoding('utf-8');
+ res.on('data', function (data) {
+ buf += data;
+ });
+
+ res.on('end', function () {
+ try {
+ buf = exports.convert(buf);
+ } catch (e) {
+ return cb(e);
+ }
+
+ fs.writeFile(file, buf, function (err) {
+ if (err) {
+ cb(err);
+ } else {
+ Logger.syslog.log('Saved subtitle file ' + file);
+ cb();
+ }
+ });
+ });
+ }).on('error', function (err) {
+ cb(err);
+ });
+}
+
+function clearOldSubtitles() {
+ fs.readdir(subtitleDir, function (err, files) {
+ if (err) {
+ Logger.errlog.log(err.stack);
+ return;
+ }
+
+ files.forEach(function (file) {
+ fs.stat(path.join(subtitleDir, file), function (err, stats) {
+ if (err) {
+ Logger.errlog.log(err.stack);
+ return;
+ }
+
+ if (stats.mtime.getTime() < Date.now() - ONE_DAY) {
+ Logger.syslog.log('Deleting old subtitle file: ' + file);
+ fs.unlink(path.join(subtitleDir, file));
+ }
+ });
+ });
+ });
+}
+
+setInterval(clearOldSubtitles, ONE_HOUR);
+clearOldSubtitles();
diff --git a/lib/media.js b/lib/media.js
index 4b3ebc30..a07416e2 100644
--- a/lib/media.js
+++ b/lib/media.js
@@ -37,7 +37,8 @@ Media.prototype = {
codec: this.meta.codec,
bitrate: this.meta.bitrate,
scuri: this.meta.scuri,
- embed: this.meta.embed
+ embed: this.meta.embed,
+ gdrive_subtitles: this.meta.gdrive_subtitles
}
};
},
diff --git a/lib/server.js b/lib/server.js
index 70d47a54..6b956b74 100644
--- a/lib/server.js
+++ b/lib/server.js
@@ -14,6 +14,11 @@ module.exports = {
fs.exists(chandumppath, function (exists) {
exists || fs.mkdir(chandumppath);
});
+
+ var gdvttpath = path.join(__dirname, "../google-drive-subtitles");
+ fs.exists(gdvttpath, function (exists) {
+ exists || fs.mkdir(gdvttpath);
+ });
singleton = new Server();
return singleton;
},
diff --git a/lib/web/webserver.js b/lib/web/webserver.js
index 2b33d24b..d81c6238 100644
--- a/lib/web/webserver.js
+++ b/lib/web/webserver.js
@@ -243,6 +243,7 @@ module.exports = {
require("./auth").init(app);
require("./account").init(app);
require("./acp").init(app);
+ require("../google2vtt").attach(app);
app.use(static(path.join(__dirname, "..", "..", "www"), {
maxAge: Config.get("http.max-age") || Config.get("http.cache-ttl")
}));
diff --git a/package.json b/package.json
index effd60e1..e22215a9 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"author": "Calvin Montgomery",
"name": "CyTube",
"description": "Online media synchronizer and chat",
- "version": "3.8.2",
+ "version": "3.9.0",
"repository": {
"url": "http://github.com/calzoneman/sync"
},
diff --git a/player/videojs.coffee b/player/videojs.coffee
index fb357fb5..b58b510f 100644
--- a/player/videojs.coffee
+++ b/player/videojs.coffee
@@ -66,6 +66,20 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
).appendTo(video)
)
+ if data.meta.gdrive_subtitles
+ data.meta.gdrive_subtitles.available.forEach((subt) ->
+ label = subt.lang_original
+ if subt.name
+ label += " (#{subt.name})"
+ $('').attr(
+ src: "/gdvtt/#{data.id}/#{subt.lang}/#{subt.name}.vtt?\
+ vid=#{data.meta.gdrive_subtitles.vid}"
+ kind: 'subtitles'
+ srclang: subt.lang
+ label: label
+ ).appendTo(video)
+ )
+
@player = videojs(video[0], autoplay: true, controls: true)
@player.ready(=>
@setVolume(VOLUME)
@@ -91,6 +105,21 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
@player.on('seeked', =>
$('.vjs-waiting').removeClass('vjs-waiting')
)
+
+ # Workaround for Chrome-- it seems that the click bindings for
+ # the subtitle menu aren't quite set up until after the ready
+ # event finishes, so set a timeout for 1ms to force this code
+ # not to run until the ready() function returns.
+ setTimeout(->
+ $('#ytapiplayer .vjs-subtitles-button .vjs-menu-item').each((i, elem) ->
+ if elem.textContent == localStorage.lastSubtitle
+ elem.click()
+
+ elem.onclick = ->
+ if elem.attributes['aria-selected'].value == 'true'
+ localStorage.lastSubtitle = elem.textContent
+ )
+ , 1)
)
)
diff --git a/www/css/cytube.css b/www/css/cytube.css
index 4fa7eed3..840f6f4a 100644
--- a/www/css/cytube.css
+++ b/www/css/cytube.css
@@ -631,3 +631,11 @@ table td {
#videowrap .embed-responsive:-ms-full-screen { width: 100%; }
#videowrap .embed-responsive:-o-full-screen { width: 100%; }
#videowrap .embed-responsive:full-screen { width: 100%; }
+
+li.vjs-menu-item.vjs-selected {
+ background-color: #66a8cc !important;
+}
+
+.video-js video::-webkit-media-text-track-container {
+ bottom: 50px;
+}
diff --git a/www/js/player.js b/www/js/player.js
index b2d37a88..95688d78 100644
--- a/www/js/player.js
+++ b/www/js/player.js
@@ -445,7 +445,7 @@
})(Player);
sortSources = function(sources) {
- var flv, flvOrder, i, idx, len, nonflv, pref, qualities, quality, qualityOrder, sourceOrder;
+ var flv, flvOrder, idx, j, len, nonflv, pref, qualities, quality, qualityOrder, sourceOrder;
if (!sources) {
console.error('sortSources() called with null source list');
return [];
@@ -459,8 +459,8 @@
qualityOrder = qualities.slice(idx).concat(qualities.slice(0, idx));
sourceOrder = [];
flvOrder = [];
- for (i = 0, len = qualityOrder.length; i < len; i++) {
- quality = qualityOrder[i];
+ for (j = 0, len = qualityOrder.length; j < len; j++) {
+ quality = qualityOrder[j];
if (quality in sources) {
flv = [];
nonflv = [];
@@ -524,6 +524,21 @@
'data-quality': source.quality
}).appendTo(video);
});
+ if (data.meta.gdrive_subtitles) {
+ data.meta.gdrive_subtitles.available.forEach(function(subt) {
+ var label;
+ label = subt.lang_original;
+ if (subt.name) {
+ label += " (" + subt.name + ")";
+ }
+ return $('').attr({
+ src: "/gdvtt/" + data.id + "/" + subt.lang + "/" + subt.name + ".vtt?vid=" + data.meta.gdrive_subtitles.vid,
+ kind: 'subtitles',
+ srclang: subt.lang,
+ label: label
+ }).appendTo(video);
+ });
+ }
_this.player = videojs(video[0], {
autoplay: true,
controls: true
@@ -547,9 +562,21 @@
return sendVideoUpdate();
}
});
- return _this.player.on('seeked', function() {
+ _this.player.on('seeked', function() {
return $('.vjs-waiting').removeClass('vjs-waiting');
});
+ return setTimeout(function() {
+ return $('#ytapiplayer .vjs-subtitles-button .vjs-menu-item').each(function(i, elem) {
+ if (elem.textContent === localStorage.lastSubtitle) {
+ elem.click();
+ }
+ return elem.onclick = function() {
+ if (elem.attributes['aria-selected'].value === 'true') {
+ return localStorage.lastSubtitle = elem.textContent;
+ }
+ };
+ });
+ }, 1);
});
};
})(this));