From 6d9fc73701de01a584f4ae98e686bff6d44cb432 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Wed, 22 Jul 2015 21:23:50 -0700 Subject: [PATCH 1/7] Work on fetching/converting google drive subtitles --- .gitignore | 1 + lib/google2vtt.js | 139 +++++++++++++++++++++++++++++++++++++++++++ lib/server.js | 5 ++ lib/web/webserver.js | 1 + 4 files changed, 146 insertions(+) create mode 100644 lib/google2vtt.js 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/lib/google2vtt.js b/lib/google2vtt.js new file mode 100644 index 00000000..b9b74f7f --- /dev/null +++ b/lib/google2vtt.js @@ -0,0 +1,139 @@ +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'); + +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 unescapeHtmlEntities(text) { + return text.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 = elem.children[0].data; + + var line = formatTime(start) + ' --> ' + formatTime(end); + line += '\n' + unescapeHtmlEntities(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' || + typeof name !== '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 { + cb(); + } + }); + }); + }).on('error', function (err) { + cb(err); + }); +} 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") })); From 33e7f81fa7923cd634bef43da70a5b789e67d2b9 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Sat, 25 Jul 2015 01:19:32 -0700 Subject: [PATCH 2/7] Wire up google drive subtitles --- NEWS.md | 8 ++++++++ lib/google2vtt.js | 29 +++++++++++++++++++++++++++++ lib/media.js | 3 ++- package.json | 2 +- player/videojs.coffee | 11 +++++++++++ www/js/player.js | 10 ++++++++++ 6 files changed, 61 insertions(+), 2 deletions(-) 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/lib/google2vtt.js b/lib/google2vtt.js index b9b74f7f..70dcdef3 100644 --- a/lib/google2vtt.js +++ b/lib/google2vtt.js @@ -17,6 +17,8 @@ function md5(input) { 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(); @@ -129,6 +131,7 @@ function fetchSubtitles(id, lang, name, vid, file, cb) { if (err) { cb(err); } else { + Logger.syslog.log('Saved subtitle file ' + file); cb(); } }); @@ -137,3 +140,29 @@ function fetchSubtitles(id, lang, name, vid, file, cb) { 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/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..4107bc04 100644 --- a/player/videojs.coffee +++ b/player/videojs.coffee @@ -66,6 +66,17 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player ).appendTo(video) ) + if data.meta.gdrive_subtitles + data.meta.gdrive_subtitles.available.forEach((subt) -> + $('').attr( + src: "/gdvtt/#{data.id}/#{subt.lang}/#{subt.name}.vtt?\ + vid=#{data.meta.gdrive_subtitles.vid}" + kind: 'subtitles' + srclang: subt.lang + label: subt.name + ).appendTo(video) + ) + @player = videojs(video[0], autoplay: true, controls: true) @player.ready(=> @setVolume(VOLUME) diff --git a/www/js/player.js b/www/js/player.js index b2d37a88..4cfc296c 100644 --- a/www/js/player.js +++ b/www/js/player.js @@ -524,6 +524,16 @@ 'data-quality': source.quality }).appendTo(video); }); + if (data.meta.gdrive_subtitles) { + data.meta.gdrive_subtitles.available.forEach(function(subt) { + return $('').attr({ + src: "/gdvtt/" + data.id + "/" + subt.lang + "/" + subt.name + ".vtt?vid=" + data.meta.gdrive_subtitles.vid, + kind: 'subtitles', + srclang: subt.lang, + label: subt.name + }).appendTo(video); + }); + } _this.player = videojs(video[0], { autoplay: true, controls: true From b0e6de389e24ace2bead9f72344596f16b072b3a Mon Sep 17 00:00:00 2001 From: calzoneman Date: Sat, 25 Jul 2015 01:27:41 -0700 Subject: [PATCH 3/7] Add gdrive subtitle docs --- docs/google-drive-subtitles.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docs/google-drive-subtitles.md 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). From 174ad8d81e8e3c765c9868971cadc7c4731e4cee Mon Sep 17 00:00:00 2001 From: calzoneman Date: Sat, 25 Jul 2015 10:31:21 -0700 Subject: [PATCH 4/7] Fixes for google drive subtitles --- lib/google2vtt.js | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/google2vtt.js b/lib/google2vtt.js index 70dcdef3..95d154a0 100644 --- a/lib/google2vtt.js +++ b/lib/google2vtt.js @@ -42,12 +42,13 @@ function formatTime(time) { return list.map(padZeros).join(':') + ms.toFixed(3).substring(1); } -function unescapeHtmlEntities(text) { +function fixText(text) { return text.replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') - .replace(/'/g, "'"); + .replace(/'/g, "'") + .replace(/-->/g, '-->'); } exports.convert = function convertSubtitles(subtitles) { @@ -55,10 +56,15 @@ exports.convert = function convertSubtitles(subtitles) { 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 = elem.children[0].data; + var text; + if (elem.children.length) { + text = elem.children[0].data; + } else { + text = ''; + } var line = formatTime(start) + ' --> ' + formatTime(end); - line += '\n' + unescapeHtmlEntities(text) + '\n'; + line += '\n' + fixText(text) + '\n'; return line; })); @@ -66,16 +72,15 @@ exports.convert = function convertSubtitles(subtitles) { }; exports.attach = function setupRoutes(app) { - app.get('/gdvtt/:id/:lang/:name.vtt', handleGetSubtitles); + 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 name = req.params.name || ''; var vid = req.query.vid; - if (typeof vid !== 'string' || typeof id !== 'string' || typeof lang !== 'string' || - typeof name !== 'string') { + if (typeof vid !== 'string' || typeof id !== 'string' || typeof lang !== 'string') { return res.sendStatus(400); } var file = [id, lang, md5(name)].join('_') + '.vtt'; From 4a0cbce5750c2d8d89927bb9458251c7917c9e09 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Sat, 25 Jul 2015 11:46:18 -0700 Subject: [PATCH 5/7] Use lang_original if subtitle name is empty --- player/videojs.coffee | 2 +- www/js/player.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/player/videojs.coffee b/player/videojs.coffee index 4107bc04..b8d384c3 100644 --- a/player/videojs.coffee +++ b/player/videojs.coffee @@ -73,7 +73,7 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player vid=#{data.meta.gdrive_subtitles.vid}" kind: 'subtitles' srclang: subt.lang - label: subt.name + label: subt.name or subt.lang_original ).appendTo(video) ) diff --git a/www/js/player.js b/www/js/player.js index 4cfc296c..cd8c3927 100644 --- a/www/js/player.js +++ b/www/js/player.js @@ -530,7 +530,7 @@ src: "/gdvtt/" + data.id + "/" + subt.lang + "/" + subt.name + ".vtt?vid=" + data.meta.gdrive_subtitles.vid, kind: 'subtitles', srclang: subt.lang, - label: subt.name + label: subt.name || subt.lang_original }).appendTo(video); }); } From f12397db23df6daecd46ca24260c0baa94ad74e8 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Sun, 26 Jul 2015 12:28:43 -0700 Subject: [PATCH 6/7] Minor fixes for Google Drive subtitles --- player/videojs.coffee | 14 +++++++++++++- www/css/cytube.css | 4 ++++ www/js/player.js | 25 ++++++++++++++++++++----- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/player/videojs.coffee b/player/videojs.coffee index b8d384c3..b5039491 100644 --- a/player/videojs.coffee +++ b/player/videojs.coffee @@ -68,12 +68,15 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player 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: subt.name or subt.lang_original + label: label ).appendTo(video) ) @@ -102,6 +105,15 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player @player.on('seeked', => $('.vjs-waiting').removeClass('vjs-waiting') ) + + $('#ytapiplayer .vjs-subtitles-button .vjs-menu-item').each((i, elem) -> + if elem.textContent == localStorage.lastSubtitle + elem.click() + + elem.onclick = -> + if this.attributes['aria-selected'].value == 'true' + localStorage.lastSubtitle = this.textContent + ) ) ) diff --git a/www/css/cytube.css b/www/css/cytube.css index 4fa7eed3..1f16c427 100644 --- a/www/css/cytube.css +++ b/www/css/cytube.css @@ -631,3 +631,7 @@ 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; +} diff --git a/www/js/player.js b/www/js/player.js index cd8c3927..86692898 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 = []; @@ -526,11 +526,16 @@ }); 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: subt.name || subt.lang_original + label: label }).appendTo(video); }); } @@ -557,9 +562,19 @@ return sendVideoUpdate(); } }); - return _this.player.on('seeked', function() { + _this.player.on('seeked', function() { return $('.vjs-waiting').removeClass('vjs-waiting'); }); + return $('#ytapiplayer .vjs-subtitles-button .vjs-menu-item').each(function(i, elem) { + if (elem.textContent === localStorage.lastSubtitle) { + elem.click(); + } + return elem.onclick = function() { + if (this.attributes['aria-selected'].value === 'true') { + return localStorage.lastSubtitle = this.textContent; + } + }; + }); }); }; })(this)); From d86c62664cc1fbe27403d0ddbfe5e918e8d4da51 Mon Sep 17 00:00:00 2001 From: calzoneman Date: Sun, 26 Jul 2015 13:29:06 -0700 Subject: [PATCH 7/7] Fixes for Chrome --- player/videojs.coffee | 20 +++++++++++++------- www/css/cytube.css | 4 ++++ www/js/player.js | 20 +++++++++++--------- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/player/videojs.coffee b/player/videojs.coffee index b5039491..b58b510f 100644 --- a/player/videojs.coffee +++ b/player/videojs.coffee @@ -106,14 +106,20 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player $('.vjs-waiting').removeClass('vjs-waiting') ) - $('#ytapiplayer .vjs-subtitles-button .vjs-menu-item').each((i, elem) -> - if elem.textContent == localStorage.lastSubtitle - elem.click() + # 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 this.attributes['aria-selected'].value == 'true' - localStorage.lastSubtitle = this.textContent - ) + 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 1f16c427..840f6f4a 100644 --- a/www/css/cytube.css +++ b/www/css/cytube.css @@ -635,3 +635,7 @@ table td { 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 86692898..95688d78 100644 --- a/www/js/player.js +++ b/www/js/player.js @@ -565,16 +565,18 @@ _this.player.on('seeked', function() { return $('.vjs-waiting').removeClass('vjs-waiting'); }); - return $('#ytapiplayer .vjs-subtitles-button .vjs-menu-item').each(function(i, elem) { - if (elem.textContent === localStorage.lastSubtitle) { - elem.click(); - } - return elem.onclick = function() { - if (this.attributes['aria-selected'].value === 'true') { - return localStorage.lastSubtitle = this.textContent; + 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));