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));