Merge pull request #499 from calzoneman/gdrive-captions
Support captions/subtitles for Google Drive videos
This commit is contained in:
commit
eb02ad0836
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -10,3 +10,4 @@ node_modules
|
||||||
*.key
|
*.key
|
||||||
torlist
|
torlist
|
||||||
www/cache
|
www/cache
|
||||||
|
google-drive-subtitles
|
||||||
|
|
8
NEWS.md
8
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
|
2015-07-07
|
||||||
==========
|
==========
|
||||||
|
|
||||||
|
|
24
docs/google-drive-subtitles.md
Normal file
24
docs/google-drive-subtitles.md
Normal 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
173
lib/google2vtt.js
Normal 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(/&/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();
|
|
@ -37,7 +37,8 @@ Media.prototype = {
|
||||||
codec: this.meta.codec,
|
codec: this.meta.codec,
|
||||||
bitrate: this.meta.bitrate,
|
bitrate: this.meta.bitrate,
|
||||||
scuri: this.meta.scuri,
|
scuri: this.meta.scuri,
|
||||||
embed: this.meta.embed
|
embed: this.meta.embed,
|
||||||
|
gdrive_subtitles: this.meta.gdrive_subtitles
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
@ -14,6 +14,11 @@ module.exports = {
|
||||||
fs.exists(chandumppath, function (exists) {
|
fs.exists(chandumppath, function (exists) {
|
||||||
exists || fs.mkdir(chandumppath);
|
exists || fs.mkdir(chandumppath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var gdvttpath = path.join(__dirname, "../google-drive-subtitles");
|
||||||
|
fs.exists(gdvttpath, function (exists) {
|
||||||
|
exists || fs.mkdir(gdvttpath);
|
||||||
|
});
|
||||||
singleton = new Server();
|
singleton = new Server();
|
||||||
return singleton;
|
return singleton;
|
||||||
},
|
},
|
||||||
|
|
|
@ -243,6 +243,7 @@ module.exports = {
|
||||||
require("./auth").init(app);
|
require("./auth").init(app);
|
||||||
require("./account").init(app);
|
require("./account").init(app);
|
||||||
require("./acp").init(app);
|
require("./acp").init(app);
|
||||||
|
require("../google2vtt").attach(app);
|
||||||
app.use(static(path.join(__dirname, "..", "..", "www"), {
|
app.use(static(path.join(__dirname, "..", "..", "www"), {
|
||||||
maxAge: Config.get("http.max-age") || Config.get("http.cache-ttl")
|
maxAge: Config.get("http.max-age") || Config.get("http.cache-ttl")
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
"author": "Calvin Montgomery",
|
"author": "Calvin Montgomery",
|
||||||
"name": "CyTube",
|
"name": "CyTube",
|
||||||
"description": "Online media synchronizer and chat",
|
"description": "Online media synchronizer and chat",
|
||||||
"version": "3.8.2",
|
"version": "3.9.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"url": "http://github.com/calzoneman/sync"
|
"url": "http://github.com/calzoneman/sync"
|
||||||
},
|
},
|
||||||
|
|
|
@ -66,6 +66,20 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
|
||||||
).appendTo(video)
|
).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 = videojs(video[0], autoplay: true, controls: true)
|
||||||
@player.ready(=>
|
@player.ready(=>
|
||||||
@setVolume(VOLUME)
|
@setVolume(VOLUME)
|
||||||
|
@ -91,6 +105,21 @@ window.VideoJSPlayer = class VideoJSPlayer extends Player
|
||||||
@player.on('seeked', =>
|
@player.on('seeked', =>
|
||||||
$('.vjs-waiting').removeClass('vjs-waiting')
|
$('.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)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -631,3 +631,11 @@ table td {
|
||||||
#videowrap .embed-responsive:-ms-full-screen { width: 100%; }
|
#videowrap .embed-responsive:-ms-full-screen { width: 100%; }
|
||||||
#videowrap .embed-responsive:-o-full-screen { width: 100%; }
|
#videowrap .embed-responsive:-o-full-screen { width: 100%; }
|
||||||
#videowrap .embed-responsive: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;
|
||||||
|
}
|
||||||
|
|
|
@ -445,7 +445,7 @@
|
||||||
})(Player);
|
})(Player);
|
||||||
|
|
||||||
sortSources = function(sources) {
|
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) {
|
if (!sources) {
|
||||||
console.error('sortSources() called with null source list');
|
console.error('sortSources() called with null source list');
|
||||||
return [];
|
return [];
|
||||||
|
@ -459,8 +459,8 @@
|
||||||
qualityOrder = qualities.slice(idx).concat(qualities.slice(0, idx));
|
qualityOrder = qualities.slice(idx).concat(qualities.slice(0, idx));
|
||||||
sourceOrder = [];
|
sourceOrder = [];
|
||||||
flvOrder = [];
|
flvOrder = [];
|
||||||
for (i = 0, len = qualityOrder.length; i < len; i++) {
|
for (j = 0, len = qualityOrder.length; j < len; j++) {
|
||||||
quality = qualityOrder[i];
|
quality = qualityOrder[j];
|
||||||
if (quality in sources) {
|
if (quality in sources) {
|
||||||
flv = [];
|
flv = [];
|
||||||
nonflv = [];
|
nonflv = [];
|
||||||
|
@ -524,6 +524,21 @@
|
||||||
'data-quality': source.quality
|
'data-quality': source.quality
|
||||||
}).appendTo(video);
|
}).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], {
|
_this.player = videojs(video[0], {
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
controls: true
|
controls: true
|
||||||
|
@ -547,9 +562,21 @@
|
||||||
return sendVideoUpdate();
|
return sendVideoUpdate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return _this.player.on('seeked', function() {
|
_this.player.on('seeked', function() {
|
||||||
return $('.vjs-waiting').removeClass('vjs-waiting');
|
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));
|
})(this));
|
||||||
|
|
Loading…
Reference in a new issue