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
This commit is contained in:
MickaelK
2024-12-02 15:37:15 +11:00
parent c343338983
commit ff67ed97ed
3 changed files with 118 additions and 17 deletions

View File

@ -250,7 +250,9 @@ func FileCat(ctx *App, res http.ResponseWriter, req *http.Request) {
} }
file, err = plgHandler.Generate(file, ctx, &res, req) file, err = plgHandler.Generate(file, ctx, &res, req)
if err != nil { 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) SendErrorResult(res, err)
return return
} }

View File

@ -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))
}

View File

@ -32,14 +32,19 @@ var (
) )
func init() { func init() {
ffmpegIsInstalled := false if _, err := exec.LookPath("ffmpeg"); err != nil {
ffprobeIsInstalled := false Hooks.Register.Onload(func() {
if _, err := exec.LookPath("ffmpeg"); err == nil { Log.Warning("plg_video_thumbnail::init error=ffmpeg_not_installed")
ffmpegIsInstalled = true })
return
} }
if _, err := exec.LookPath("ffprobe"); err == nil { if _, err := exec.LookPath("ffprobe"); err != nil {
ffprobeIsInstalled = true Hooks.Register.Onload(func() {
Log.Warning("plg_video_thumbnail::init error=ffprobe_not_installed")
})
return
} }
plugin_enable = func() bool { plugin_enable = func() bool {
return Config.Get("features.video.enable_transcoder").Schema(func(f *FormElement) *FormElement { return Config.Get("features.video.enable_transcoder").Schema(func(f *FormElement) *FormElement {
if f == nil { if f == nil {
@ -50,9 +55,6 @@ func init() {
f.Target = []string{"transcoding_blacklist_format"} f.Target = []string{"transcoding_blacklist_format"}
f.Description = "Enable/Disable on demand video transcoding. The transcoder" f.Description = "Enable/Disable on demand video transcoding. The transcoder"
f.Default = true f.Default = true
if ffmpegIsInstalled == false || ffprobeIsInstalled == false {
f.Default = false
}
return f return f
}).Bool() }).Bool()
} }
@ -80,13 +82,6 @@ func init() {
cachePath := GetAbsolutePath(VideoCachePath) cachePath := GetAbsolutePath(VideoCachePath)
os.RemoveAll(cachePath) os.RemoveAll(cachePath)
os.MkdirAll(cachePath, os.ModePerm) 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 { Hooks.Register.HttpEndpoint(func(r *mux.Router, app *App) error {