From ff67ed97ed802b50516545db90a37bd2d02a514b Mon Sep 17 00:00:00 2001 From: MickaelK Date: Mon, 2 Dec 2024 15:37:15 +1100 Subject: [PATCH] feature (thumbnail): video thumbnail plugin 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 --- server/ctrl/files.go | 4 +- server/plugin/plg_video_thumbnail/index.go | 104 ++++++++++++++++++++ server/plugin/plg_video_transcoder/index.go | 27 +++-- 3 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 server/plugin/plg_video_thumbnail/index.go diff --git a/server/ctrl/files.go b/server/ctrl/files.go index 6b6e00f8..ee216116 100644 --- a/server/ctrl/files.go +++ b/server/ctrl/files.go @@ -250,7 +250,9 @@ func FileCat(ctx *App, res http.ResponseWriter, req *http.Request) { } file, err = plgHandler.Generate(file, ctx, &res, req) if err != nil { - Log.Debug("cat::thumbnailer '%s'", err.Error()) + if req.Context().Err() == nil { + Log.Debug("cat::thumbnailer '%s'", err.Error()) + } SendErrorResult(res, err) return } diff --git a/server/plugin/plg_video_thumbnail/index.go b/server/plugin/plg_video_thumbnail/index.go new file mode 100644 index 00000000..efd17afc --- /dev/null +++ b/server/plugin/plg_video_thumbnail/index.go @@ -0,0 +1,104 @@ +package plg_video_thumbnail + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + + . "github.com/mickael-kerjean/filestash/server/common" +) + +const ( + VideoCachePath = "data/cache/video-thumbnail/" +) + +var plugin_enable = func() bool { + return Config.Get("features.video.enable_thumbnail").Schema(func(f *FormElement) *FormElement { + if f == nil { + f = &FormElement{} + } + f.Name = "enable_thumbnail" + f.Type = "enable" + f.Target = []string{} + f.Description = "Enable/Disable on video thumbnail generation" + f.Default = false + return f + }).Bool() +} + +func init() { + if _, err := exec.LookPath("ffmpeg"); err != nil { + Hooks.Register.Onload(func() { + Log.Warning("plg_video_thumbnail::init error=ffmpeg_not_installed") + }) + return + } + + Hooks.Register.Onload(func() { + if plugin_enable() == false { + return + } + cachePath := GetAbsolutePath(VideoCachePath) + os.RemoveAll(cachePath) + os.MkdirAll(cachePath, os.ModePerm) + + Hooks.Register.Thumbnailer("video/mp4", &ffmpegThumbnail{}) + Hooks.Register.Thumbnailer("video/x-matroska", &ffmpegThumbnail{}) + Hooks.Register.Thumbnailer("video/x-msvideo", &ffmpegThumbnail{}) + }) +} + +type ffmpegThumbnail struct{} + +func (this *ffmpegThumbnail) Generate(reader io.ReadCloser, ctx *App, res *http.ResponseWriter, req *http.Request) (io.ReadCloser, error) { + var ( + errBuff bytes.Buffer + fullURL = strings.Replace( + fmt.Sprintf("http://127.0.0.1:%d%s?%s", Config.Get("general.port").Int(), req.URL.Path, req.URL.RawQuery), + "&thumbnail=true", "", 1, + ) + cacheName = "thumb_" + GenerateID(ctx.Session) + "_" + QuickHash(req.URL.Query().Get("path"), 10) + ".jpeg" + cachePath = GetAbsolutePath(VideoCachePath, cacheName) + ) + + reader.Close() + thumbnail, err := os.OpenFile(cachePath, os.O_RDONLY, os.ModePerm) + if err == nil { + this.setHeader(res) + return thumbnail, nil + } + + cmd := exec.CommandContext(req.Context(), "ffmpeg", []string{ + "-headers", "cookie: " + req.Header.Get("Cookie"), + "-skip_frame", "nokey", + "-i", fullURL, "-y", + "-an", "-sn", + "-vf", "thumbnail, scale=320:320: force_original_aspect_ratio=decrease", "-vsync", "passthrough", "-frames:v", "1", + "-c:v", "mjpeg", cachePath, + }...) + cmd.Stderr = &errBuff + if err := cmd.Run(); err != nil { + if req.Context().Err() == nil { + Log.Error("plg_video_thumbnail::generate::run err=%s", errBuff.String()) + return nil, err + } + return nil, err + } + cmd.Wait() + thumbnail, err = os.OpenFile(cachePath, os.O_RDONLY, os.ModePerm) + if err != nil { + Log.Error("plg_video_thumbnail::generate::open path=%s err=%s", cachePath, err.Error()) + return nil, err + } + this.setHeader(res) + return thumbnail, nil +} + +func (this *ffmpegThumbnail) setHeader(res *http.ResponseWriter) { + (*res).Header().Set("Content-Type", "image/jpeg") + (*res).Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", 3600*12)) +} diff --git a/server/plugin/plg_video_transcoder/index.go b/server/plugin/plg_video_transcoder/index.go index de9295d7..313bcfba 100644 --- a/server/plugin/plg_video_transcoder/index.go +++ b/server/plugin/plg_video_transcoder/index.go @@ -32,14 +32,19 @@ var ( ) func init() { - ffmpegIsInstalled := false - ffprobeIsInstalled := false - if _, err := exec.LookPath("ffmpeg"); err == nil { - ffmpegIsInstalled = true + 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 { - ffprobeIsInstalled = true + 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 { @@ -50,9 +55,6 @@ func init() { 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() } @@ -80,13 +82,6 @@ func init() { cachePath := GetAbsolutePath(VideoCachePath) os.RemoveAll(cachePath) os.MkdirAll(cachePath, os.ModePerm) - 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 - } }) Hooks.Register.HttpEndpoint(func(r *mux.Router, app *App) error {