Merge pull request #499 from calzoneman/gdrive-captions

Support captions/subtitles for Google Drive videos
This commit is contained in:
Calvin Montgomery 2015-07-27 17:42:32 -07:00
commit eb02ad0836
11 changed files with 283 additions and 6 deletions

1
.gitignore vendored
View file

@ -10,3 +10,4 @@ node_modules
*.key
torlist
www/cache
google-drive-subtitles

View file

@ -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
==========

View file

@ -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).

173
lib/google2vtt.js Normal file
View file

@ -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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/-->/g, '--&gt;');
}
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();

View file

@ -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
}
};
},

View file

@ -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;
},

View file

@ -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")
}));

View file

@ -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"
},

View file

@ -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})"
$('<track/>').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)
)
)

View file

@ -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;
}

View file

@ -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 $('<track/>').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));