Implement proper subtitle support in Go

This commit is contained in:
Kloon ImKloon 2023-10-16 18:02:13 +02:00
parent 1fa1b559ca
commit 56a5bdc3ac
No known key found for this signature in database
GPG Key ID: CCF1C86A995C5B6A
8 changed files with 174 additions and 112 deletions

View File

@ -8,6 +8,7 @@ import (
"encoding/hex"
"hash"
"io"
"io/fs"
"log"
"mime"
"net/http"
@ -27,6 +28,11 @@ import (
const PermFile = 0664
const PermDir = 0755
var (
reSubDirs = regexp.MustCompile("(?i)^sub(s|titles)$")
reSubExts = regexp.MustCompile("(?i)(.vtt|.srt|.ass|.ssa)$")
)
// FileInfo describes a file.
type FileInfo struct {
*Listing
@ -308,28 +314,46 @@ func (i *FileInfo) detectSubtitles() {
}
base := strings.TrimSuffix(i.Name, ext)
subDirsRegex := regexp.MustCompile("(?i)^sub(s|titles)$")
for _, f := range dir {
// load all .vtt/.srt subtitles from subs directories
if f.IsDir() && subDirsRegex.MatchString(f.Name()) {
// load all supported subtitles from subs directories
// should cover all instances of subtitle distributions
// like tv-shows with multiple episodes in single dir
if f.IsDir() && reSubDirs.MatchString(f.Name()) {
subsDir := path.Join(parentDir, f.Name())
var err error
dir, err = afero.ReadDir(i.Fs, subsDir)
if err == nil {
for _, f := range dir {
if !f.IsDir() && (strings.HasSuffix(f.Name(), ".vtt") ||
strings.HasSuffix(f.Name(), ".srt")) {
i.Subtitles = append(i.Subtitles, path.Join(subsDir, f.Name()))
}
}
}
} else if !f.IsDir() && strings.HasPrefix(f.Name(), base) &&
(strings.HasSuffix(f.Name(), ".vtt") || strings.HasSuffix(f.Name(), ".srt")) {
i.Subtitles = append(i.Subtitles, path.Join(parentDir, f.Name()))
i.loadSubtitles(subsDir, base, true)
} else if isSubtitleMatch(f, base) {
i.addSubtitle(path.Join(parentDir, f.Name()))
}
}
}
func (i *FileInfo) loadSubtitles(subsPath string, baseName string, recursive bool) {
dir, err := afero.ReadDir(i.Fs, subsPath)
if err == nil {
for _, f := range dir {
if isSubtitleMatch(f, "") {
i.addSubtitle(path.Join(subsPath, f.Name()))
} else if f.IsDir() && recursive && strings.HasPrefix(f.Name(), baseName) {
subsDir := path.Join(subsPath, f.Name())
i.loadSubtitles(subsDir, baseName, false)
}
}
}
}
func IsSupportedSubtitle(fileName string) bool {
return reSubExts.MatchString(fileName)
}
func isSubtitleMatch(f fs.FileInfo, baseName string) bool {
return !f.IsDir() && strings.HasPrefix(f.Name(), baseName) &&
IsSupportedSubtitle(f.Name())
}
func (i *FileInfo) addSubtitle(path string) {
i.Subtitles = append(i.Subtitles, path)
}
func (i *FileInfo) readListing(checker rules.Checker, readHeader bool) error {
afs := &afero.Afero{Fs: i.Fs}
dir, err := afs.ReadDir(i.Path)

View File

@ -205,7 +205,7 @@ export function getSubtitlesURL(file: ResourceItem) {
inline: "true",
};
return file.subtitles?.map((d) => createURL("api/raw" + d, params));
return file.subtitles?.map((d) => createURL("api/subtitle" + d, params));
}
export async function usage(url: string) {

View File

@ -1,11 +1,14 @@
<template>
<video
ref="videoPlayer"
class="video-js"
controls
style="width: 100%; height: 100%"
>
<video ref="videoPlayer" class="video-max video-js" controls>
<source :src="source" />
<track
kind="subtitles"
v-for="(sub, index) in subtitles"
:key="index"
:src="sub"
:label="subLabel(sub)"
:default="index === 0"
/>
<p class="vjs-no-js">
Sorry, your browser doesn't support embedded videos, but don't worry, you
can <a :href="source">download it</a>
@ -20,7 +23,6 @@ import videojs from "video.js";
import type Player from "video.js/dist/types/player";
import "videojs-mobile-ui";
import "videojs-hotkeys";
import { loadSubtitle } from "@/utils/subtitle";
import "video.js/dist/video-js.min.css";
import "videojs-mobile-ui/dist/videojs-mobile-ui.css";
@ -45,6 +47,7 @@ onMounted(() => {
{
html5: {
// needed for customizable subtitles
// TODO: add to user settings
nativeTextTracks: false,
},
plugins: {
@ -59,7 +62,6 @@ onMounted(() => {
// onReady callback
async () => {
// player.value!.log("onPlayerReady", this);
addSubtitles(props.subtitles);
}
);
// TODO: need to test on mobile
@ -74,21 +76,29 @@ onBeforeUnmount(() => {
}
});
const addSubtitles = async (subtitles: string[] | undefined) => {
if (!subtitles) return;
// add subtitles dynamically (srt is converted on-the-fly)
const subs = await Promise.all(
subtitles.map(async (s) => await loadSubtitle(s))
);
// TODO: player.value wouldnt work here, no idea why
const _player = videojs.getPlayer(videoPlayer.value!);
for (const [idx, sub] of subs.filter((s) => !!s.src).entries()) {
_player.addRemoteTextTrack({
src: sub.src,
label: sub.label,
kind: "subtitles",
default: idx === 0,
});
const subLabel = (subUrl: string) => {
let url: URL;
try {
url = new URL(subUrl);
} catch (_) {
// treat it as a relative url
// we only need this for filename
url = new URL(subUrl, window.location.origin);
}
const label = decodeURIComponent(
url.pathname
.split("/")
.pop()!
.replace(/\.[^/.]+$/, "")
);
return label;
};
</script>
<style scoped>
.video-max {
width: 100%;
height: 100%;
}
</style>

View File

@ -1,72 +0,0 @@
import { StatusError } from "@/api/utils";
export async function loadSubtitle(subUrl: string) {
let url: URL;
try {
url = new URL(subUrl);
} catch (_) {
// treat it as a relative url
// we only need this for filename
url = new URL(subUrl, window.location.origin);
}
const label = decodeURIComponent(
url.pathname
.split("/")
.pop()!
.replace(/\.[^/.]+$/, "")
);
let src;
if (url.pathname.toLowerCase().endsWith(".srt")) {
try {
const resp = await fetch(subUrl);
if (!resp.ok) {
throw new StatusError(
`Failed to fetch subtitle from ${subUrl}!`,
resp.status
);
}
const vtt = srtToVttBlob(await resp.text());
src = URL.createObjectURL(vtt);
} catch (error) {
console.error(error);
}
} else if (url.pathname.toLowerCase().endsWith(".vtt")) {
src = subUrl;
}
return { src, label };
}
export function srtToVttBlob(srtData: string) {
const VTT_HEAD = "WEBVTT\n\n";
// Replace line breaks with \n
let subtitles = srtData.replace(/\r\n|\r|\n/g, "\n");
// commas -> dots in timestamps
subtitles = subtitles.replace(/(\d\d:\d\d:\d\d),(\d\d\d)/g, "$1.$2");
// map SRT font colors to VTT cue span classes
const colorMap: Record<string, string> = {};
// font tags -> ::cue span tags
subtitles = subtitles.replace(
/<font color="([^"]+)">([\s\S]*?)<\/font>/g,
function (_match, color, text) {
const key =
"c_" + color.replace(/^rgb/, "").replace(/\W/g, "").toLowerCase();
colorMap[key] = color;
return `<c.${key}>${text.replace("\n", "").trim()}</c>`;
}
);
subtitles = subtitles.replace(/<br\s*\/?>/g, "\n");
let vttSubtitles = VTT_HEAD;
if (Object.keys(colorMap).length) {
let vttStyles = "";
for (const cssClass in colorMap) {
const color = colorMap[cssClass];
// add cue style declaration
vttStyles += `::cue(.${cssClass}) {color: ${color};}\n`;
}
vttSubtitles += vttStyles;
}
vttSubtitles += "\n"; // an empty line MUST separate styles from subs
vttSubtitles += subtitles;
return new Blob([vttSubtitles], { type: "text/vtt" });
}

3
go.mod
View File

@ -32,6 +32,9 @@ require (
require (
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/asticode/go-astikit v0.20.0 // indirect
github.com/asticode/go-astisub v0.25.0 // indirect
github.com/asticode/go-astits v1.8.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/dsoprea/go-logging v0.0.0-20200517223158-a10564966e9d // indirect

10
go.sum
View File

@ -47,6 +47,14 @@ github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
github.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8=
github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/asticode/go-astisub v0.25.0 h1:wEHp7QSwcZytKA6LhA4I0XpyCidqGR0qB2r3p4vkEHc=
github.com/asticode/go-astisub v0.25.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
github.com/asticode/go-astisub v0.26.0 h1:Ka1oUyWzo/lIx7RX97GI1QdbClqYVxI0ExKuZRN/cDk=
github.com/asticode/go-astisub v0.26.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
github.com/asticode/go-astits v1.8.0 h1:rf6aiiGn/QhlFjNON1n5plqF3Fs025XLUwiQ0NB6oZg=
github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
@ -219,6 +227,7 @@ github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -359,6 +368,7 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=

View File

@ -84,6 +84,7 @@ func NewHandler(
Handler(monkey(previewHandler(imgSvc, fileCache, server.EnableThumbnails, server.ResizePreview), "/api/preview")).Methods("GET")
api.PathPrefix("/command").Handler(monkey(commandsHandler, "/api/command")).Methods("GET")
api.PathPrefix("/search").Handler(monkey(searchHandler, "/api/search")).Methods("GET")
api.PathPrefix("/subtitle").Handler(monkey(subtitleHandler, "/api/subtitle")).Methods("GET")
public := api.PathPrefix("/public").Subrouter()
public.PathPrefix("/dl").Handler(monkey(publicDlHandler, "/api/public/dl/")).Methods("GET")

86
http/subtitle.go Normal file
View File

@ -0,0 +1,86 @@
package http
import (
"bytes"
"net/http"
"strings"
"github.com/asticode/go-astisub"
"github.com/filebrowser/filebrowser/v2/files"
)
var subtitleHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Download {
return http.StatusAccepted, nil
}
file, err := files.NewFileInfo(files.FileOptions{
Fs: d.user.Fs,
Path: r.URL.Path,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
})
if err != nil {
return errToStatus(err), err
}
// TODO: no idea what this does
// if files.IsNamedPipe(file.Mode) {
// setContentDisposition(w, r, file)
// return 0, nil
// }
if file.IsDir {
return http.StatusBadRequest, nil
}
return subtitleFileHandler(w, r, file)
})
func subtitleFileHandler(w http.ResponseWriter, r *http.Request, file *files.FileInfo) (int, error) {
// if its not a subtitle file, reject
if !files.IsSupportedSubtitle(file.Name) {
return http.StatusBadRequest, nil
}
fd, err := file.Fs.Open(file.Path)
if err != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
// load subtitle for conversion to vtt
var sub *astisub.Subtitles
if strings.HasSuffix(file.Name, ".srt") {
sub, err = astisub.ReadFromSRT(fd)
} else if strings.HasSuffix(file.Name, ".ass") || strings.HasSuffix(file.Name, ".ssa") {
sub, err = astisub.ReadFromSSA(fd)
}
if err != nil {
return http.StatusInternalServerError, err
}
// TODO: not sure if this is needed here
setContentDisposition(w, r, file)
w.Header().Add("Content-Security-Policy", `script-src 'none';`)
w.Header().Set("Cache-Control", "private")
// force type to text/vtt
w.Header().Set("Content-Type", "text/vtt")
// serve vtt file directly
if sub == nil {
http.ServeContent(w, r, file.Name, file.ModTime, fd)
return 0, nil
}
// convert others to vtt and serve from buffer
var buf = &bytes.Buffer{}
err = sub.WriteToWebVTT(buf)
if err != nil {
return http.StatusInternalServerError, err
}
http.ServeContent(w, r, file.Name, file.ModTime, bytes.NewReader(buf.Bytes()))
return 0, nil
}