Add local .srt subtitle support

This commit is contained in:
hensm
2020-01-19 17:12:37 +00:00
parent 81fc98dcc6
commit ffb84efb47
2 changed files with 191 additions and 36 deletions

View File

@@ -6,6 +6,7 @@ import fs from "fs";
import http from "http";
import mime from "mime-types";
import path from "path";
import stream from "stream";
import Media from "./Media";
import Session from "./Session";
@@ -199,6 +200,8 @@ async function handleMessage (message: Message) {
clientConnection.send({ type: "CONNECT" });
clientReceiver.send({ type: "STOP", requestId: 1 });
});
break;
}
}
}
@@ -279,53 +282,181 @@ function handleReceiverSelectorMessage (message: Message) {
}
}
function handleMediaServerMessage (message: Message) {
async function handleMediaServerMessage (message: Message) {
async function convertSrtToVtt (srtFilePath: string) {
const fileStream = fs.createReadStream(
srtFilePath, { encoding: "utf-8" });
let fileContents = "";
for await (let chunk of fileStream) {
// Omit BOM if present
if (!fileContents && chunk[0] === "\uFEFF") {
chunk = chunk.slice(1);
}
// Normalize line endings
fileContents += chunk.replace(/$\r\n/gm, "\n");
}
let vttText = "WEBVTT\n";
/**
* Matches a caption group within an SubRip file. Match groups
* are the index (followed by a new line), the time range
* (followed by a new line), then any text content until a blank
* line.
*/
const REGEX_CAPTION = /(?:(\d+)\n(\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}))\n((?:.+)\n?)*/g;
/**
* WebVTT is very similar to SubRip, the main differences being
* the "WEBVTT" specifier and optional metadata at the head of
* the file, the optional caption indicies and the timecode
* millisecond separator.
*/
for (const groups of fileContents.matchAll(REGEX_CAPTION)) {
const captionSource = groups[0];
const captionIndex = groups[1];
const captionTime = groups[2];
const captionText = groups[3];
vttText += `\n${captionIndex}\n`;
vttText += `${captionTime.replace(/,/g, ".")}\n`;
if (captionText) {
vttText += `${captionText}`;
}
}
return vttText;
}
switch (message.subject) {
case "bridge:/mediaServer/start": {
const { filePath, port }
: { filePath: string, port: number } = message.data;
const contentType = mime.lookup(filePath);
if (mediaServer && mediaServer.listening) {
mediaServer.close();
}
let fileDir: string;
let fileName: string;
let fileSize: number;
try {
const stat = await fs.promises.lstat(filePath);
if (stat.isFile()) {
fileDir = path.dirname(filePath);
fileName = path.basename(filePath);
fileSize = stat.size;
} else {
console.error("Error: Media path is not a file.");
sendMessage("mediaCast:/mediaServer/error");
break;
}
} catch (err) {
console.error("Error: Failed to find media path.");
sendMessage("mediaCast:/mediaServer/error");
break;
}
const contentType = mime.lookup(filePath);
if (!contentType) {
sendMessage("mediaCast:/mediaServer/error");
break;
}
if (mediaServer && mediaServer.listening) {
mediaServer.close();
// file name -> file contents
const subtitles = new Map<string, string>();
try {
const dirEntries = await fs.promises.readdir(
fileDir, { withFileTypes: true });
/**
* Find any SubRip files within the same directory and
* convert to WebVTT source.
*/
for (const dirEntry of dirEntries) {
if (dirEntry.isFile()
&& mime.lookup(dirEntry.name) === "application/x-subrip") {
subtitles.set(dirEntry.name, await convertSrtToVtt(
path.join(fileDir, dirEntry.name)));
}
}
} catch (err) {
// Subtitles optional
}
mediaServer = http.createServer((req, res) => {
const { size: fileSize } = fs.statSync(filePath);
const { range } = req.headers;
// Partial content HTTP 206
if (range) {
const bounds = range.substring(6).split("-");
const start = parseInt(bounds[0]);
const end = bounds[1] ? parseInt(bounds[1]) : fileSize - 1;
mediaServer = http.createServer(async (req, res) => {
if (!req.url) {
return;
}
res.writeHead(206, {
"Accept-Ranges": "bytes"
, "Content-Range": `bytes ${start}-${end}/${fileSize}`
, "Content-Length": (end - start) + 1
, "Content-Type": contentType
});
// Drop leading slash
if (req.url.startsWith("/")) {
req.url = req.url.slice(1);
}
fs.createReadStream(filePath, { start, end }).pipe(res);
} else {
res.writeHead(200, {
"Content-Length": fileSize
, "Content-Type": contentType
});
switch (req.url) {
case fileName: {
const { range } = req.headers;
fs.createReadStream(filePath).pipe(res);
// Partial content HTTP 206
if (range) {
const bounds = range.substring(6).split("-");
const start = parseInt(bounds[0]);
const end = bounds[1]
? parseInt(bounds[1]) : fileSize - 1;
res.writeHead(206, {
"Accept-Ranges": "bytes"
, "Content-Range": `bytes ${start}-${end}/${fileSize}`
, "Content-Length": (end - start) + 1
, "Content-Type": contentType
});
fs.createReadStream(
filePath, { start, end }).pipe(res);
} else {
res.writeHead(200, {
"Content-Length": fileSize
, "Content-Type": contentType
});
fs.createReadStream(filePath).pipe(res);
}
break;
}
default: {
if (subtitles.has(req.url)) {
const vttSource = subtitles.get(req.url)!;
const vttStream = stream.Readable.from(vttSource);
vttStream.pipe(res);
}
break;
}
}
});
mediaServer.on("listening", () => {
sendMessage("mediaCast:/mediaServer/started");
sendMessage({
subject: "mediaCast:/mediaServer/started"
, data: {
mediaPath: fileName
, subtitlePaths: Array.from(subtitles.keys())
}
});
});
mediaServer.on("close", () => {
sendMessage("mediaCast:/mediaServer/stopped");

View File

@@ -23,7 +23,10 @@ function getLocalAddress () {
});
}
function startMediaServer (filePath: string, port: number) {
function startMediaServer (filePath: string, port: number)
: Promise<{ mediaPath: string
, subtitlePaths: string[] }> {
return new Promise((resolve, reject) => {
backgroundPort.postMessage({
subject: "bridge:/mediaServer/start"
@@ -42,7 +45,7 @@ function startMediaServer (filePath: string, port: number) {
switch (message.subject) {
case "mediaCast:/mediaServer/started": {
resolve();
resolve(message.data);
break;
}
case "mediaCast:/mediaServer/error": {
@@ -110,8 +113,10 @@ function getSession (opts: InitOptions): Promise<cast.Session> {
function getMedia (opts: InitOptions): Promise<cast.media.Media> {
return new Promise(async resolve => {
let mediaUrlObject = new URL(opts.mediaUrl);
const mediaTitle = mediaUrlObject.pathname;
let mediaUrl = new URL(opts.mediaUrl);
let subtitleUrls: URL[] = [];
const mediaTitle = mediaUrl.pathname;
/**
* If the media is a local file, start an HTTP media server
@@ -123,24 +128,41 @@ function getMedia (opts: InitOptions): Promise<cast.media.Media> {
try {
// Wait until media server is listening
await startMediaServer(mediaUrlObject.pathname, port);
const { mediaPath, subtitlePaths }
= await startMediaServer(mediaTitle, port);
const baseUrl = new URL(`http://${host}:${port}/`);
mediaUrl = new URL(mediaPath, baseUrl)
subtitleUrls = subtitlePaths.map(
path => new URL(path, baseUrl));
} catch (err) {
console.error("Failed to start media server");
return;
}
mediaUrlObject = new URL(`http://${host}:${port}/`);
}
const activeTrackIds: number[] = [];
const mediaInfo = new cast.media.MediaInfo(mediaUrlObject.href, null);
const mediaInfo = new cast.media.MediaInfo(mediaUrl.href, null);
mediaInfo.metadata = new cast.media.GenericMediaMetadata();
mediaInfo.metadata.metadataType = cast.media.MetadataType.GENERIC;
mediaInfo.metadata.title = mediaTitle;
mediaInfo.tracks = [];
let trackIndex = 0;
for (const subtitleUrl of subtitleUrls) {
const castTrack = new cast.media.Track(
trackIndex, cast.media.TrackType.TEXT);
castTrack.name = subtitleUrl.pathname;
castTrack.trackContentId = subtitleUrl.href;
castTrack.trackContentType = "text/vtt";
castTrack.subtype = cast.media.TextTrackType.SUBTITLES;
mediaInfo.tracks.push(castTrack);
}
if (mediaElement) {
if (mediaElement instanceof HTMLVideoElement) {
@@ -163,7 +185,7 @@ function getMedia (opts: InitOptions): Promise<cast.media.Media> {
* and type as TrackType.TEXT.
*/
const castTrack = new cast.media.Track(
index, cast.media.TrackType.TEXT);
trackIndex, cast.media.TrackType.TEXT);
// Copy TextTrack properties
castTrack.name = track.label;
@@ -204,8 +226,10 @@ function getMedia (opts: InitOptions): Promise<cast.media.Media> {
// If enabled, mark as active track for load request
if (track.mode === "showing" || trackElement.default) {
activeTrackIds.push(index);
activeTrackIds.push(trackIndex);
}
trackIndex++;
});
}
}