mirror of
https://github.com/mickael-kerjean/filestash.git
synced 2025-10-29 17:18:43 +08:00
up until now, the stance was to refuse video thumbnail because it's too slow but really many people don't seem to care that much about it and keep insisting to have it. With this solution, it's not in the base build but it gives an option for those people to make it happen
262 lines
7.2 KiB
Go
262 lines
7.2 KiB
Go
package plg_video_transcoder
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"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() {
|
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
|
Hooks.Register.Onload(func() {
|
|
Log.Warning("plg_video_thumbnail::init error=ffmpeg_not_installed")
|
|
})
|
|
return
|
|
}
|
|
if _, err := exec.LookPath("ffprobe"); err != nil {
|
|
Hooks.Register.Onload(func() {
|
|
Log.Warning("plg_video_thumbnail::init error=ffprobe_not_installed")
|
|
})
|
|
return
|
|
}
|
|
|
|
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
|
|
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()
|
|
plugin_enable()
|
|
|
|
cachePath := GetAbsolutePath(VideoCachePath)
|
|
os.RemoveAll(cachePath)
|
|
os.MkdirAll(cachePath, os.ModePerm)
|
|
})
|
|
|
|
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()))
|
|
if plugin_enable() == false {
|
|
return
|
|
}
|
|
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
|
|
})
|
|
Hooks.Register.HttpEndpoint(func(r *mux.Router, app *App) error {
|
|
r.PathPrefix("/hls/hls_{segment}.ts").Handler(NewMiddlewareChain(
|
|
hlsTranscodeHandler,
|
|
[]Middleware{SecureHeaders},
|
|
*app,
|
|
)).Methods("GET")
|
|
return nil
|
|
})
|
|
Hooks.Register.ProcessFileContentBeforeSend(hlsPlaylistHandler)
|
|
}
|
|
|
|
func hlsPlaylistHandler(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.Session) + "_" + 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 hlsTranscodeHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
|
|
if plugin_enable() == false {
|
|
return
|
|
}
|
|
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
|
|
}
|