Files
2024-03-13 00:18:24 +11:00

265 lines
7.4 KiB
Go

package plg_video_transcoder
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
. "github.com/mickael-kerjean/filestash/server/common"
. "github.com/mickael-kerjean/filestash/server/middleware"
)
const (
HLS_SEGMENT_LENGTH = 5
FPS = 30
CLEAR_CACHE_AFTER = 12
VideoCachePath = "data/cache/video/"
)
var (
plugin_enable func() bool
blacklist_format func() string
)
func init() {
ffmpegIsInstalled := false
ffprobeIsInstalled := false
if _, err := exec.LookPath("ffmpeg"); err == nil {
ffmpegIsInstalled = true
}
if _, err := exec.LookPath("ffprobe"); err == nil {
ffprobeIsInstalled = true
}
plugin_enable = func() bool {
return Config.Get("features.video.enable_transcoder").Schema(func(f *FormElement) *FormElement {
if f == nil {
f = &FormElement{}
}
f.Name = "enable_transcoder"
f.Type = "enable"
f.Target = []string{"transcoding_blacklist_format"}
f.Description = "Enable/Disable on demand video transcoding. The transcoder"
f.Default = true
if ffmpegIsInstalled == false || ffprobeIsInstalled == false {
f.Default = false
}
return f
}).Bool()
}
blacklist_format = func() string {
return Config.Get("features.video.blacklist_format").Schema(func(f *FormElement) *FormElement {
if f == nil {
f = &FormElement{}
}
f.Id = "transcoding_blacklist_format"
f.Name = "blacklist_format"
f.Type = "text"
f.Description = "Video format that won't be transcoded"
f.Default = os.Getenv("FEATURE_TRANSCODING_VIDEO_BLACKLIST")
if f.Default != "" {
f.Placeholder = fmt.Sprintf("Default: '%s'", f.Default)
}
return f
}).String()
}
Hooks.Register.Onload(func() {
blacklist_format()
if plugin_enable() == false {
return
} else if ffmpegIsInstalled == false {
Log.Warning("[plugin video transcoder] ffmpeg needs to be installed")
return
} else if ffprobeIsInstalled == false {
Log.Warning("[plugin video transcoder] ffprobe needs to be installed")
return
}
cachePath := GetAbsolutePath(VideoCachePath)
os.RemoveAll(cachePath)
os.MkdirAll(cachePath, os.ModePerm)
Hooks.Register.ProcessFileContentBeforeSend(hls_playlist)
Hooks.Register.HttpEndpoint(func(r *mux.Router, app *App) error {
r.PathPrefix("/hls/hls_{segment}.ts").Handler(NewMiddlewareChain(
hls_transcode,
[]Middleware{SecureHeaders},
*app,
)).Methods("GET")
return nil
})
Hooks.Register.HttpEndpoint(func(r *mux.Router, app *App) error {
r.HandleFunc(OverrideVideoSourceMapper, func(res http.ResponseWriter, req *http.Request) {
res.Header().Set("Content-Type", GetMimeType(req.URL.String()))
res.Write([]byte(`window.overrides["video-map-sources"] = function(sources){`))
res.Write([]byte(` return sources.map(function(source){`))
blacklists := strings.Split(blacklist_format(), ",")
for i := 0; i < len(blacklists); i++ {
blacklists[i] = strings.TrimSpace(blacklists[i])
res.Write([]byte(fmt.Sprintf(`if(source.type == "%s"){ return source; } `, GetMimeType("."+blacklists[i]))))
}
res.Write([]byte(` source.src = source.src + "&transcode=hls";`))
res.Write([]byte(` source.type = "application/x-mpegURL";`))
res.Write([]byte(` return source;`))
res.Write([]byte(` })`))
res.Write([]byte(`}`))
})
return nil
})
})
}
func hls_playlist(reader io.ReadCloser, ctx *App, res *http.ResponseWriter, req *http.Request) (io.ReadCloser, error) {
query := req.URL.Query()
if query.Get("transcode") != "hls" {
return reader, nil
}
path := query.Get("path")
if strings.HasPrefix(GetMimeType(path), "video/") == false {
return reader, nil
}
cacheName := "vid_" + GenerateID(ctx) + "_" + QuickHash(path, 10) + ".dat"
cachePath := GetAbsolutePath(
VideoCachePath,
cacheName,
)
f, err := os.OpenFile(cachePath, os.O_CREATE|os.O_RDWR, os.ModePerm)
if err != nil {
Log.Stdout("ERR %+v", err)
return reader, err
}
io.Copy(f, reader)
reader.Close()
f.Close()
time.AfterFunc(CLEAR_CACHE_AFTER*time.Hour, func() { os.Remove(cachePath) })
p, err := ffprobe(cachePath)
if err != nil {
return reader, err
}
var response string
var i int
response = "#EXTM3U\n"
response += "#EXT-X-VERSION:3\n"
response += "#EXT-X-MEDIA-SEQUENCE:0\n"
response += "#EXT-X-ALLOW-CACHE:YES\n"
response += fmt.Sprintf("#EXT-X-TARGETDURATION:%d\n", HLS_SEGMENT_LENGTH)
for i = 0; i < int(p.Format.Duration)/HLS_SEGMENT_LENGTH; i++ {
response += fmt.Sprintf("#EXTINF:%d.0000, nodesc\n", HLS_SEGMENT_LENGTH)
response += fmt.Sprintf("/hls/hls_%d.ts?path=%s\n", i, cacheName)
}
if md := math.Mod(p.Format.Duration, HLS_SEGMENT_LENGTH); md > 0 {
response += fmt.Sprintf("#EXTINF:%.4f, nodesc\n", md)
response += fmt.Sprintf("/hls/hls_%d.ts?path=%s\n", i, cacheName)
}
response += "#EXT-X-ENDLIST\n"
(*res).Header().Set("Content-Type", "application/x-mpegURL")
return NewReadCloserFromBytes([]byte(response)), nil
}
func hls_transcode(ctx *App, res http.ResponseWriter, req *http.Request) {
segmentNumber, err := strconv.Atoi(mux.Vars(req)["segment"])
if err != nil {
Log.Info("[plugin hls] invalid segment request '%s'", mux.Vars(req)["segment"])
res.WriteHeader(http.StatusBadRequest)
return
}
startTime := segmentNumber * HLS_SEGMENT_LENGTH
cachePath := GetAbsolutePath(
VideoCachePath,
req.URL.Query().Get("path"),
)
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
Log.Info("[plugin hls]: invalid video")
res.WriteHeader(http.StatusServiceUnavailable)
return
}
cmd := exec.CommandContext(req.Context(), "ffmpeg", []string{
"-timelimit", "30",
"-ss", fmt.Sprintf("%d.00", startTime),
"-i", cachePath,
"-t", fmt.Sprintf("%d.00", HLS_SEGMENT_LENGTH),
"-vf", fmt.Sprintf("scale=-2:%d", 720),
"-vcodec", "libx264",
"-preset", "veryfast",
"-acodec", "aac",
"-ab", "128k",
"-ac", "2",
"-pix_fmt", "yuv420p",
"-x264opts", strings.Join([]string{
"subme=0",
"me_range=4",
"rc_lookahead=10",
"me=dia",
"no_chroma_me",
"8x8dct=0",
"partitions=none",
}, ":"),
"-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d.000)", HLS_SEGMENT_LENGTH),
"-f", "ssegment",
"-segment_time", fmt.Sprintf("%d.00", HLS_SEGMENT_LENGTH),
"-segment_start_number", fmt.Sprintf("%d", segmentNumber),
"-initial_offset", fmt.Sprintf("%d.00", startTime),
"-vsync", "2",
"pipe:out%03d.ts",
}...)
var buffer bytes.Buffer
cmd.Stdout = res
cmd.Stderr = &buffer
err = cmd.Run()
if err != nil {
Log.Error("plg_video_transcoder::ffmpeg::run '%s' - %s", err.Error(), base64.StdEncoding.EncodeToString(buffer.Bytes()))
}
}
type FFProbeData struct {
Format struct {
Duration float64 `json:"duration,string"`
BitRate int `json:"bit_rate,string"`
} `json: "format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
PixelFormat string `json:"pix_fmt"`
} `json:"streams"`
}
func ffprobe(videoPath string) (FFProbeData, error) {
var stream bytes.Buffer
var probe FFProbeData
cmd := exec.Command(
"ffprobe", strings.Split(fmt.Sprintf(
"-v quiet -print_format json -show_format -show_streams %s",
videoPath,
), " ")...,
)
cmd.Stdout = &stream
if err := cmd.Run(); err != nil {
return probe, nil
}
cmd.Run()
if err := json.Unmarshal([]byte(stream.String()), &probe); err != nil {
return probe, err
}
return probe, nil
}