better subtitle handling

This commit is contained in:
frost768 2023-03-26 10:33:24 +03:00
parent 4d5f03337d
commit 307f555b70
10 changed files with 76 additions and 140 deletions

View File

@ -19,7 +19,6 @@
"json5": "^2.2.0", "json5": "^2.2.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"nanoid": "^4.0.0", "nanoid": "^4.0.0",
"node-webvtt": "^1.9.4",
"ofetch": "^1.0.0", "ofetch": "^1.0.0",
"pako": "^2.1.0", "pako": "^2.1.0",
"react": "^17.0.2", "react": "^17.0.2",
@ -31,7 +30,7 @@
"react-stickynode": "^4.1.0", "react-stickynode": "^4.1.0",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"react-use": "^17.4.0", "react-use": "^17.4.0",
"srt-webvtt": "^2.0.0", "subsrt-ts": "^2.1.0",
"unpacker": "^1.0.1" "unpacker": "^1.0.1"
}, },
"scripts": { "scripts": {

View File

@ -1,27 +0,0 @@
declare module "node-webvtt" {
interface Cue {
identifier: string;
start: number;
end: number;
text: string;
styles: string;
}
interface Options {
meta?: boolean;
strict?: boolean;
}
type ParserError = Error;
interface ParseResult {
valid: boolean;
strict: boolean;
cues: Cue[];
errors: ParserError[];
meta?: Map<string, string>;
}
interface Segment {
duration: number;
cues: Cue[];
}
function parse(text: string, options: Options): ParseResult;
function segment(input: string, segmentLength?: number): Segment[];
}

View File

@ -1,14 +1,16 @@
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch"; import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams"; import { MWCaption } from "@/backend/helpers/streams";
import toWebVTT from "srt-webvtt";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { list, parse, detect } from "subsrt-ts";
import { ContentCaption } from "subsrt-ts/dist/types/handler";
export const subtitleTypeList = list()
.map((v) => `.${v}`)
.join(",");
export const sanitize = DOMPurify.sanitize; export const sanitize = DOMPurify.sanitize;
export const CUSTOM_CAPTION_ID = "customCaption";
export async function getCaptionUrl(caption: MWCaption): Promise<string> { export async function getCaptionUrl(caption: MWCaption): Promise<string> {
if (caption.type === MWCaptionType.SRT) { if (caption.url.startsWith("blob:")) return caption.url;
let captionBlob: Blob; let captionBlob: Blob;
if (caption.needsProxy) { if (caption.needsProxy) {
captionBlob = await proxiedFetch<Blob>(caption.url, { captionBlob = await proxiedFetch<Blob>(caption.url, {
responseType: "blob" as any, responseType: "blob" as any,
@ -18,31 +20,7 @@ export async function getCaptionUrl(caption: MWCaption): Promise<string> {
responseType: "blob" as any, responseType: "blob" as any,
}); });
} }
return URL.createObjectURL(captionBlob);
return toWebVTT(captionBlob);
}
if (caption.type === MWCaptionType.VTT) {
if (caption.needsProxy) {
const blob = await proxiedFetch<Blob>(caption.url, {
responseType: "blob" as any,
});
return URL.createObjectURL(blob);
}
return caption.url;
}
throw new Error("invalid type");
}
export async function convertCustomCaptionFileToWebVTT(file: File) {
const header = await file.slice(0, 6).text();
const isWebVTT = header === "WEBVTT";
if (!isWebVTT) {
return toWebVTT(file);
}
return URL.createObjectURL(file);
} }
export function revokeCaptionBlob(url: string | undefined) { export function revokeCaptionBlob(url: string | undefined) {
@ -50,3 +28,12 @@ export function revokeCaptionBlob(url: string | undefined) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
} }
export function parseSubtitles(text: string): ContentCaption[] {
if (detect(text) === "") {
throw new Error("Invalid subtitle format");
}
return parse(text)
.filter((cue) => cue.type === "caption")
.map((cue) => cue as ContentCaption);
}

View File

@ -20,7 +20,7 @@ export enum MWStreamQuality {
export type MWCaption = { export type MWCaption = {
needsProxy?: boolean; needsProxy?: boolean;
url: string; url: string;
type: MWCaptionType; type?: MWCaptionType;
langIso: string; langIso: string;
}; };

View File

@ -80,7 +80,7 @@
"noCaptions": "No captions", "noCaptions": "No captions",
"linkedCaptions": "Linked captions", "linkedCaptions": "Linked captions",
"customCaption": "Custom caption", "customCaption": "Custom caption",
"uploadCustomCaption": "Upload caption (SRT, VTT)", "uploadCustomCaption": "Upload caption",
"noEmbeds": "No embeds were found for this source", "noEmbeds": "No embeds were found for this source",
"errors": { "errors": {
"loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}", "loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}",

View File

@ -62,7 +62,7 @@
"noCaptions": "Pas de sous-titres", "noCaptions": "Pas de sous-titres",
"linkedCaptions": "Sous-titres liés", "linkedCaptions": "Sous-titres liés",
"customCaption": "Sous-titres personnalisés", "customCaption": "Sous-titres personnalisés",
"uploadCustomCaption": "Télécharger des sous-titres (SRT, VTT)", "uploadCustomCaption": "Télécharger des sous-titres",
"noEmbeds": "Aucun contenu intégré n'a été trouvé pour cette source", "noEmbeds": "Aucun contenu intégré n'a été trouvé pour cette source",
"errors": { "errors": {
"loadingWentWong": "Un problème est survenu lors du chargement des épisodes pour {{seasonTitle}}", "loadingWentWong": "Un problème est survenu lors du chargement des épisodes pour {{seasonTitle}}",

View File

@ -1,7 +1,7 @@
import { Transition } from "@/components/Transition"; import { Transition } from "@/components/Transition";
import { useSettings } from "@/state/settings"; import { useSettings } from "@/state/settings";
import { sanitize } from "@/backend/helpers/captions"; import { sanitize, parseSubtitles } from "@/backend/helpers/captions";
import { parse, Cue } from "node-webvtt"; import { ContentCaption } from "subsrt-ts/dist/types/handler";
import { useRef } from "react"; import { useRef } from "react";
import { useAsync } from "react-use"; import { useAsync } from "react-use";
import { useVideoPlayerDescriptor } from "../../state/hooks"; import { useVideoPlayerDescriptor } from "../../state/hooks";
@ -48,16 +48,18 @@ export function CaptionRendererAction({
const source = useSource(descriptor).source; const source = useSource(descriptor).source;
const videoTime = useProgress(descriptor).time; const videoTime = useProgress(descriptor).time;
const { captionSettings } = useSettings(); const { captionSettings } = useSettings();
const captions = useRef<Cue[]>([]); const captions = useRef<ContentCaption[]>([]);
useAsync(async () => { useAsync(async () => {
const url = source?.caption?.url; const blobUrl = source?.caption?.url;
if (url) { if (blobUrl) {
// Is there a better way? const result = await fetch(blobUrl);
const result = await fetch(url);
// Uses UTF-8 by default
const text = await result.text(); const text = await result.text();
captions.current = parse(text, { strict: false }).cues; try {
captions.current = parseSubtitles(text);
} catch (error) {
captions.current = [];
}
} else { } else {
captions.current = []; captions.current = [];
} }
@ -65,8 +67,8 @@ export function CaptionRendererAction({
if (!captions.current.length) return null; if (!captions.current.length) return null;
const isVisible = (start: number, end: number): boolean => { const isVisible = (start: number, end: number): boolean => {
const delayedStart = start + captionSettings.delay; const delayedStart = start / 1000 + captionSettings.delay;
const delayedEnd = end + captionSettings.delay; const delayedEnd = end / 1000 + captionSettings.delay;
return ( return (
Math.max(0, delayedStart) <= videoTime && Math.max(0, delayedStart) <= videoTime &&
Math.max(0, delayedEnd) >= videoTime Math.max(0, delayedEnd) >= videoTime
@ -82,9 +84,9 @@ export function CaptionRendererAction({
show show
> >
{captions.current.map( {captions.current.map(
({ identifier, end, start, text }) => ({ start, end, content }) =>
isVisible(start, end) && ( isVisible(start, end) && (
<CaptionCue key={identifier || `${start}-${end}`} text={text} /> <CaptionCue key={`${start}-${end}`} text={content} />
) )
)} )}
</Transition> </Transition>

View File

@ -1,7 +1,7 @@
import { import {
getCaptionUrl, getCaptionUrl,
convertCustomCaptionFileToWebVTT, parseSubtitles,
CUSTOM_CAPTION_ID, subtitleTypeList,
} from "@/backend/helpers/captions"; } from "@/backend/helpers/captions";
import { MWCaption } from "@/backend/helpers/streams"; import { MWCaption } from "@/backend/helpers/streams";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
@ -13,10 +13,11 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls"; import { useControls } from "@/video/state/logic/controls";
import { useMeta } from "@/video/state/logic/meta"; import { useMeta } from "@/video/state/logic/meta";
import { useSource } from "@/video/state/logic/source"; import { useSource } from "@/video/state/logic/source";
import { ChangeEvent, useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PopoutListEntry, PopoutSection } from "./PopoutUtils"; import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
const customCaption = "external-custom";
function makeCaptionId(caption: MWCaption, isLinked: boolean): string { function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`; return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
} }
@ -41,35 +42,17 @@ export function CaptionSelectionPopout(props: {
async (caption: MWCaption, isLinked: boolean) => { async (caption: MWCaption, isLinked: boolean) => {
const id = makeCaptionId(caption, isLinked); const id = makeCaptionId(caption, isLinked);
loadingId.current = id; loadingId.current = id;
controls.setCaption(id, await getCaptionUrl(caption)); const blobUrl = await getCaptionUrl(caption);
const result = await fetch(blobUrl);
const text = await result.text();
parseSubtitles(text); // This will throw if the file is invalid
controls.setCaption(id, blobUrl);
controls.closePopout(); controls.closePopout();
} }
); );
const currentCaption = source.source?.caption?.id; const currentCaption = source.source?.caption?.id;
const customCaptionUploadElement = useRef<HTMLInputElement>(null); const customCaptionUploadElement = useRef<HTMLInputElement>(null);
const [setCustomCaption, loadingCustomCaption, errorCustomCaption] =
useLoading(async (captionFile: File) => {
if (
!captionFile.name.endsWith(".srt") &&
!captionFile.name.endsWith(".vtt")
) {
throw new Error("Only SRT or VTT files are allowed");
}
controls.setCaption(
CUSTOM_CAPTION_ID,
await convertCustomCaptionFileToWebVTT(captionFile)
);
controls.closePopout();
});
async function handleUploadCaption(e: ChangeEvent<HTMLInputElement>) {
if (!e.target.files) {
return;
}
const captionFile = e.target.files[0];
setCustomCaption(captionFile);
}
return ( return (
<FloatingView <FloatingView
{...props.router.pageProps(props.prefix)} {...props.router.pageProps(props.prefix)}
@ -105,23 +88,28 @@ export function CaptionSelectionPopout(props: {
{t("videoPlayer.popouts.noCaptions")} {t("videoPlayer.popouts.noCaptions")}
</PopoutListEntry> </PopoutListEntry>
<PopoutListEntry <PopoutListEntry
key={CUSTOM_CAPTION_ID} key={customCaption}
active={currentCaption === CUSTOM_CAPTION_ID} active={currentCaption === customCaption}
loading={loadingCustomCaption} loading={loading && loadingId.current === customCaption}
errored={!!errorCustomCaption} errored={error && loadingId.current === customCaption}
onClick={() => { onClick={() => customCaptionUploadElement.current?.click()}
customCaptionUploadElement.current?.click();
}}
> >
{currentCaption === CUSTOM_CAPTION_ID {currentCaption === customCaption
? t("videoPlayer.popouts.customCaption") ? t("videoPlayer.popouts.customCaption")
: t("videoPlayer.popouts.uploadCustomCaption")} : t("videoPlayer.popouts.uploadCustomCaption")}
<input <input
ref={customCaptionUploadElement}
type="file"
onChange={handleUploadCaption}
className="hidden" className="hidden"
accept=".vtt, .srt" ref={customCaptionUploadElement}
accept={subtitleTypeList}
type="file"
onChange={(e) => {
if (!e.target.files) return;
const customSubtitle = {
langIso: "custom",
url: URL.createObjectURL(e.target.files[0]),
};
setCaption(customSubtitle, false);
}}
/> />
</PopoutListEntry> </PopoutListEntry>
</PopoutSection> </PopoutSection>

View File

@ -16,7 +16,6 @@
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"baseUrl": "./src", "baseUrl": "./src",
"typeRoots": ["./src/@types"],
"paths": { "paths": {
"@/*": ["./*"] "@/*": ["./*"]
}, },

View File

@ -2123,11 +2123,6 @@ commander@^2.20.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
commander@^7.1.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
commander@^8.0.0: commander@^8.0.0:
version "8.3.0" version "8.3.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66"
@ -3854,13 +3849,6 @@ node-releases@^2.0.8:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.10.tgz#c311ebae3b6a148c89b1813fd7c4d3c024ef537f" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.10.tgz#c311ebae3b6a148c89b1813fd7c4d3c024ef537f"
integrity sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w== integrity sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==
node-webvtt@^1.9.4:
version "1.9.4"
resolved "https://registry.yarnpkg.com/node-webvtt/-/node-webvtt-1.9.4.tgz#b71b98f879c6c88ebeda40c358bd45a882ca5d89"
integrity sha512-EjrJdKdxSyd8j4LMLW6s2Ah4yNoeVXp18Ob04CQl1In18xcUmKzEE8pcsxxnFVqanTyjbGYph2VnvtwIXR4EjA==
dependencies:
commander "^7.1.0"
normalize-path@^3.0.0, normalize-path@~3.0.0: normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
@ -4699,11 +4687,6 @@ sourcemap-codec@^1.4.8:
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
srt-webvtt@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/srt-webvtt/-/srt-webvtt-2.0.0.tgz#debd2f56dd2b6600894caa11bb78893e5fc6509b"
integrity sha512-G2Z7/Jf2NRKrmLYNSIhSYZZYE6OFlKXFp9Au2/zJBKgrioUzmrAys1x7GT01dwl6d2sEnqr5uahEIOd0JW/Rbw==
stack-generator@^2.0.5: stack-generator@^2.0.5:
version "2.0.10" version "2.0.10"
resolved "https://registry.yarnpkg.com/stack-generator/-/stack-generator-2.0.10.tgz#8ae171e985ed62287d4f1ed55a1633b3fb53bb4d" resolved "https://registry.yarnpkg.com/stack-generator/-/stack-generator-2.0.10.tgz#8ae171e985ed62287d4f1ed55a1633b3fb53bb4d"
@ -4859,6 +4842,11 @@ subscribe-ui-event@^2.0.6:
lodash "^4.17.15" lodash "^4.17.15"
raf "^3.0.0" raf "^3.0.0"
subsrt-ts@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/subsrt-ts/-/subsrt-ts-2.1.0.tgz#97b5e0f97800fb08b64465b53c7c4f14f43d6fd4"
integrity sha512-LOdp6A91l/yPLPFuEaYvGzFDusUz0J52ksZjaCFdl347DOhedZOVQEciTaH7KaVDRlb7wstOx4dPFdjf9AyuFw==
supports-color@^5.3.0: supports-color@^5.3.0:
version "5.5.0" version "5.5.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"