Implement proper subtitle support in Go
This commit is contained in:
parent
1fa1b559ca
commit
56a5bdc3ac
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
3
go.mod
@ -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
10
go.sum
@ -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=
|
||||
|
||||
@ -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
86
http/subtitle.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user